This commit is contained in:
Im The Justice Man 2025-12-26 13:58:03 -05:00 committed by GitHub
commit 0df20120e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2576 additions and 515 deletions

View file

@ -2,20 +2,19 @@ import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
@ -31,7 +30,8 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
...builtins,
],
format: "cjs",
target: "es2018",
logLevel: "info",
@ -41,7 +41,7 @@ const context = await esbuild.context({
loader: {
".png": "dataurl",
".gif": "dataurl",
}
},
});
if (prod) {
@ -49,4 +49,4 @@ if (prod) {
process.exit(0);
} else {
await context.watch();
}
}

313
main.ts
View file

@ -1,313 +0,0 @@
import { App, debounce, Plugin, PluginSettingTab, Setting } from 'obsidian';
import EMERGE_MOTION from './animations/gemmy_emerge.gif';
import POP_MOTION from './animations/gemmy_pop.gif';
import DISAPPEAR_MOTION from './animations/gemmy_disappear.gif';
import ANGRY_MOTION from './animations/gemmy_angry.gif';
import LOOK_MOTION from './animations/gemmy_lookAround.gif'
import IDLE_MOTION from './animations/gemmy_idle.gif'
import DISAPPOINT_IMG from './animations/gemmy_disappoint.gif'
interface GemmySettings {
// how often does Gemmy talk in idle mode, in minutes
idleTalkFrequency: number;
// the number of minutes you must write before Gemmy appears to mock you
writingModeGracePeriod: number;
}
const DEFAULT_SETTINGS: GemmySettings = {
idleTalkFrequency: 5,
writingModeGracePeriod: 5
};
const GEMMY_IDLE_QUOTES = [
"Did you know that a vault is just a folder of plain text notes?",
"I see you're checking out a ChatGPT plugin, would you consider me instead?",
"You have plugins that you can update!",
"Hi I'm Gemmy! Like Clippy but shinier!",
"Everything is connected. Everything.",
"Cant decide which note to work on? Try the Random Note core plugin!",
"Are you sure you dont want to upload all your notes just so we can chat?",
"How tall would all your notes be if you stacked them up?",
"Wanna teach me to say things? Find me on GitHub and open a pull request!",
"Have you considered using Comic Sans?",
"A blank page is just a masterpiece in waiting"
];
const WRITING_MODE_QUOTES = [
"Is that the best you can do? Keep writing!",
"Write first, edit later.",
"I love hearing your keyboard. Don't stop.",
"How about we review some old notes today?",
"Stuck? Try journaling what happened today and see if that gives you inspiration.",
"Maybe it's time to go get some water or coffee.",
"Anything is better than a blank page, even me. Write something!",
"Oh, taking a break? Dont let the ideas get cold!",
"Whats this? Writers block already?",
"Youve got this! Every keystroke is a step closer to brilliance.",
"Youre doing amazing! Keep the momentum going.",
"Bold choice to open Obsidian and then do absolutely nothing.",
"Is this a journal or a staring contest with the cursor?",
"Hmm, I didnt realize staring at the screen was a new note-taking strategy."
];
const BUBBLE_DURATION = 5000;
export default class Gemmy extends Plugin {
settings: GemmySettings;
gemmyEl: HTMLElement;
imageEl: HTMLElement;
inWritingMode: boolean = false;
idleTimeout: number;
writingModeTimeout: number;
appeared: boolean = false;
async onload() {
await this.loadSettings();
let gemmyEl = this.gemmyEl = createDiv('gemmy-container');
gemmyEl.setAttribute('aria-label-position', 'top');
gemmyEl.setAttribute('aria-label-delay', '0');
gemmyEl.setAttribute('aria-label-classes', 'gemmy-tooltip');
this.imageEl = gemmyEl.createEl('img', {});
this.addCommand({
id: 'show',
name: 'Show Gemmy',
callback: () => {
this.appear();
}
});
this.addCommand({
id: 'hide',
name: 'Hide Gemmy',
callback: () => {
this.disappear();
}
});
this.addCommand({
id: 'enter-writing-mode',
name: 'Enter writing mode',
callback: () => {
this.enterWritingMode();
}
});
this.addCommand({
id: 'leave-writing-mode',
name: 'Leave writing mode',
callback: () => {
this.leaveWritingMode();
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new GemmySettingTab(this.app, this));
this.gemmyEl.addEventListener('mouseenter', () => {
if (this.inWritingMode) {
return;
}
this.saySomething(GEMMY_IDLE_QUOTES, true);
this.idleTimeout && clearTimeout(this.idleTimeout);
});
this.gemmyEl.addEventListener('mouseleave', () => {
if (this.inWritingMode) {
return;
}
this.imageEl.setAttribute('src', IDLE_MOTION);
this.startNextIdleTimeout();
});
this.startNextIdleTimeout();
// debounce editor-change event on workspace
this.registerEvent(this.app.workspace.on('editor-change', debounce(() => {
if (!this.inWritingMode) {
return;
}
this.disappear();
this.setWritingModeTimeout();
}, 500)));
app.workspace.onLayoutReady(this.appear.bind(this));
}
appear() {
let { gemmyEl, imageEl } = this;
imageEl.setAttribute('src', EMERGE_MOTION);
// Quicker if we're in writing mode
if (this.inWritingMode) {
imageEl.setAttribute('src', POP_MOTION);
setTimeout(() => {
this.appeared = true;
this.saySomething(WRITING_MODE_QUOTES, true);
}, 1800);
}
else {
imageEl.setAttribute('src', EMERGE_MOTION);
setTimeout(() => {
imageEl.setAttribute('src', IDLE_MOTION);
this.appeared = true;
}, 3800);
}
document.body.appendChild(gemmyEl);
gemmyEl.show();
}
disappear() {
this.idleTimeout && window.clearTimeout(this.idleTimeout);
this.writingModeTimeout && window.clearTimeout(this.writingModeTimeout);
this.imageEl.setAttribute('src', DISAPPEAR_MOTION);
// remote tooltip
this.gemmyEl.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, clientX: 10, clientY: 10 }));
setTimeout(() => {
this.gemmyEl.hide();
this.appeared = false;
}, 1300);
}
enterWritingMode() {
this.inWritingMode = true;
this.disappear();
this.setWritingModeTimeout();
}
leaveWritingMode() {
this.inWritingMode = false;
this.disappear();
window.clearTimeout(this.writingModeTimeout);
}
setWritingModeTimeout() {
if (this.writingModeTimeout) {
window.clearTimeout(this.writingModeTimeout);
}
this.writingModeTimeout = window.setTimeout(() => {
if (!this.inWritingMode) {
return;
}
this.appear();
}, this.settings.writingModeGracePeriod * 1000);
}
startNextIdleTimeout() {
// if the set time is 5 minutes, this will set timeout to be a random time between 4-6 minutes
// the range will be 80% - 120%
let randomFactor = 0.8 + 0.4 * Math.random();
let randomizedTimeout = randomFactor * this.settings.idleTalkFrequency * 60000;
if (this.idleTimeout) {
window.clearTimeout(this.idleTimeout);
}
this.idleTimeout = window.setTimeout(() => {
if (this.inWritingMode) {
return;
}
this.saySomething(GEMMY_IDLE_QUOTES, false);
this.startNextIdleTimeout();
}, randomizedTimeout);
}
saySomething(quotes: string[], persistent: boolean) {
if (!this.appeared) {
return;
}
let randomThing = quotes[Math.floor(Math.random() * quotes.length)];
this.gemmyEl.setAttr('aria-label', randomThing);
this.gemmyEl.setAttr('aria-label-position', 'top');
this.gemmyEl.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: 10, clientY: 10 }))
if (this.inWritingMode) {
this.imageEl.setAttribute('src', ANGRY_MOTION);
setTimeout(() => {
this.imageEl.setAttribute('src', DISAPPOINT_IMG);
}, 1000);
}
else {
this.imageEl.setAttribute('src', LOOK_MOTION);
}
if (!persistent) {
setTimeout(() => {
this.gemmyEl.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, clientX: 10, clientY: 10 }));
this.imageEl.setAttribute('src', IDLE_MOTION);
}, BUBBLE_DURATION);
}
}
onunload() {
this.disappear();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class GemmySettingTab extends PluginSettingTab {
plugin: Gemmy;
constructor(app: App, plugin: Gemmy) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Idle talk frequency')
.setDesc('How often does Gemmy speak when idle, in minutes.')
.addSlider(slider => slider
.setLimits(5, 60, 5)
.setValue(this.plugin.settings.idleTalkFrequency)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.idleTalkFrequency = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Writing mode grace period')
.setDesc('How soon Gemmy starts to get disappointed after you stop tying in writing mode, in seconds.')
.addSlider(slider => slider
.setLimits(5, 180, 5)
.setDynamicTooltip()
.setValue(this.plugin.settings.writingModeGracePeriod)
.onChange(async (value) => {
this.plugin.settings.writingModeGracePeriod = value;
await this.plugin.saveSettings();
}));
}
}

824
package-lock.json generated

File diff suppressed because it is too large Load diff

217
src/main.ts Normal file
View file

@ -0,0 +1,217 @@
import { App, debounce, Plugin, PluginSettingTab, Setting } from "obsidian";
import { GemmyMascot } from "./mascot";
interface GemmySettings {
// how often does Gemmy talk in idle mode, in minutes
idleTalkFrequency: number;
// the number of minutes you must write before Gemmy appears to mock you
writingModeGracePeriod: number;
}
const DEFAULT_SETTINGS: GemmySettings = {
idleTalkFrequency: 5,
writingModeGracePeriod: 5,
};
export default class Gemmy extends Plugin {
mascot: GemmyMascot;
settings: GemmySettings;
inWritingMode: boolean = false;
writingModeTimeout: number;
idleTimeout: number;
async onload() {
await this.loadSettings();
this.addSettingTab(new GemmySettingTab(this.app, this));
this.mascot = new GemmyMascot(this, document.body);
this.mascot.mount();
this.startNextIdleTimeout();
this.addCommand({
id: "show",
name: "Show Gemmy",
callback: () => {
this.appear();
},
});
this.addCommand({
id: "hide",
name: "Hide Gemmy",
callback: () => {
this.disappear();
},
});
this.addCommand({
id: "stealth-mode",
name: "Stealth Mode",
callback: () => this.mascot.hide(),
});
this.addCommand({
id: "enter-writing-mode",
name: "Enter writing mode",
callback: () => {
this.enterWritingMode();
},
});
this.addCommand({
id: "leave-writing-mode",
name: "Leave writing mode",
callback: () => {
this.leaveWritingMode();
},
});
// debounce editor-change event on workspace
this.registerEvent(
this.app.workspace.on(
"editor-change",
debounce(() => {
if (!this.inWritingMode) {
return;
}
this.disappear();
this.setWritingModeTimeout();
}, 500)
)
);
}
appear() {
if (this.inWritingMode) {
this.mascot.wakeForWriting();
} else {
this.mascot.wake();
}
}
disappear() {
Math.random() < 0.9 ? this.mascot.hide() : this.mascot.sleep();
if (this.writingModeTimeout) {
window.clearTimeout(this.writingModeTimeout);
}
if (this.idleTimeout) {
window.clearTimeout(this.idleTimeout);
}
}
enterWritingMode() {
this.inWritingMode = true;
this.disappear();
this.setWritingModeTimeout();
}
leaveWritingMode() {
this.inWritingMode = false;
this.disappear();
if (this.writingModeTimeout) {
window.clearTimeout(this.writingModeTimeout);
}
this.startNextIdleTimeout();
}
setWritingModeTimeout() {
if (this.writingModeTimeout) {
window.clearTimeout(this.writingModeTimeout);
}
this.writingModeTimeout = window.setTimeout(() => {
if (!this.inWritingMode) {
return;
}
this.appear();
setTimeout(() => {
this.setWritingModeTimeout();
}, Math.random() * 3000 + 2000); // repeat every 2-5 seconds
}, this.settings.writingModeGracePeriod * 1000);
}
startNextIdleTimeout() {
// if the set time is 5 minutes, this will set timeout to be a random time between 4-6 minutes
// the range will be 80% - 120%
let randomFactor = 0.8 + 0.4 * Math.random();
let randomizedTimeout =
randomFactor * this.settings.idleTalkFrequency * 60000;
if (this.idleTimeout) {
window.clearTimeout(this.idleTimeout);
}
this.idleTimeout = window.setTimeout(() => {
if (this.inWritingMode) {
return;
}
this.mascot.setMood("is-happy");
this.startNextIdleTimeout();
}, randomizedTimeout);
}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
}
async saveSettings() {
await this.saveData(this.settings);
}
onunload() {
this.mascot.unmount();
}
}
class GemmySettingTab extends PluginSettingTab {
plugin: Gemmy;
constructor(app: App, plugin: Gemmy) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Idle talk frequency")
.setDesc("How often does Gemmy speak when idle, in minutes.")
.addSlider((slider) =>
slider
.setLimits(5, 60, 5)
.setValue(this.plugin.settings.idleTalkFrequency)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.idleTalkFrequency = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Writing mode grace period")
.setDesc(
"How soon Gemmy starts to get disappointed after you stop typing in writing mode, in seconds."
)
.addSlider((slider) =>
slider
.setLimits(5, 180, 5)
.setDynamicTooltip()
.setValue(this.plugin.settings.writingModeGracePeriod)
.onChange(async (value) => {
this.plugin.settings.writingModeGracePeriod = value;
await this.plugin.saveSettings();
})
);
}
}

1149
src/mascot.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,32 +8,560 @@ If your plugin does not need CSS, delete this file.
*/
:root {
--gemmy-size: 80px;
--gemmy-size: 120px;
}
.gemmy-container {
position: fixed;
bottom: 30px;
right: 30px;
display: flex;
flex-direction: column;
align-items: flex-end;
/* --- GEMMY MASCOT STYLES --- */
.mascot-wrapper {
--gemmy-primary: #8a5cf5;
--gemmy-secondary: #7c52fa;
--gemmy-highlight: #a88bfa;
--gemmy-shadow: #5e3dc4;
--gemmy-eye-bg: #ffffff;
--gemmy-pupil: #1a1a24;
/* Dynamic Logic Vars */
--look-x: 0;
--look-y: 0;
--blink-scale: 1;
--pupil-scale: 1;
position: fixed;
bottom: 30px; /* Above the FAB if present, or just in corner */
right: 20px;
width: var(--gemmy-size);
height: var(--gemmy-size);
animation: floating 6s ease-in-out infinite;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
/* Super bouncy transition for cartoon feel */
transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55),
opacity 0.5s ease;
z-index: 2000;
}
.gemmy-container img {
width: var(--gemmy-size);
height: var(--gemmy-size);
border-radius: var(--gemmy-size);
.mascot-floater {
transform: none;
opacity: 1;
transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55),
opacity 0.5s ease;
}
.gemmy-container[aria-label]::before {
--background-modifier-message: #FFFFCB;
content: attr(aria-label);
color: #202020;
font-size: var(--font-ui-medium);
border-radius: 20px;
padding: var(--size-4-3) var(--size-4-6);
width: 150px;
background: var(--background-modifier-message);
}
.mascot-wrapper.is-hidden .mascot-floater {
transform: scale(0) rotateY(720deg);
opacity: 0;
pointer-events: none;
animation: none;
}
.mascot-wrapper.is-hidden:hover {
/* No hover effect when completely hidden */
pointer-events: auto;
}
.obsidian-mascot {
width: 100%;
height: 100%;
/* Add a brighter drop shadow to simulate backlighting/bioluminescence */
filter: drop-shadow(0 0 15px rgba(138, 92, 245, 0.4));
transition: filter 0.3s ease,
transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: visible; /* Let the eyes go outside the bounds if needed */
}
/* --- 1.5 MASCOT BODY (Look Movement) --- */
.mascot-body {
width: 100%;
height: 100%;
/* Move the body slightly in the direction of the look */
transform: translate(
calc(var(--look-x) * -10px),
calc(var(--look-y) * -10px)
);
/* Removed transition to prevent conflict with JS physics loop */
will-change: transform;
}
/* Update the Face Group to respond to the Look Variables */
.face-container {
/* Removed transition to prevent conflict with JS physics loop */
/* The face moves 12px, while pupils move internally.
This creates the illusion that the face is on the 'front' of the crystal. */
transform: translate(calc(var(--look-x) * 3px), calc(var(--look-y) * 2px));
will-change: transform;
}
/* Interaction: Active Click - Squash! */
.mascot-wrapper:active .obsidian-mascot {
transform: scale(0.9, 0.8);
}
/* --- SPEECH BUBBLE --- */
.speech-bubble {
position: absolute;
bottom: 100%;
/* Anchor to the right to prevent overflow off-screen */
right: 0;
left: auto;
transform: translateY(10px);
background: white;
color: #1a103c;
padding: 12px 16px;
border-radius: 18px;
font-weight: bold;
font-size: 14px;
line-height: 1.4;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
/* Text wrapping logic */
white-space: normal;
min-width: var(
--gemmy-size
); /* Ensure it covers the mascot width so arrow stays aligned */
max-width: 250px; /* Constrain width */
width: max-content; /* Shrink to fit */
text-align: center;
z-index: -1;
margin-bottom: 15px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.speech-bubble::after {
content: "";
position: absolute;
top: 100%;
/* Position arrow relative to the mascot center (120px / 2 = 60px) */
right: calc(var(--gemmy-size) / 2 - 8px); /* 60px - 8px border */
left: auto;
margin-left: 0;
border-width: 8px;
border-style: solid;
border-color: white transparent transparent transparent;
}
.speech-bubble.visible {
opacity: 1;
transform: translateY(0);
}
/* --- 4. Breathing Animation --- */
.facet-body-top,
.facet-left-wing,
.facet-base,
.facet-right-side {
transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-box: fill-box;
}
.facet-body-top {
animation: breatheTop 5s ease-in-out infinite;
}
.facet-left-wing {
animation: breatheLeft 5s ease-in-out infinite;
}
.facet-base {
animation: breatheBase 5s ease-in-out infinite;
}
.facet-right-side {
animation: breatheRight 5s ease-in-out infinite;
}
@keyframes breatheTop {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(0, -0.3px);
}
}
@keyframes breatheLeft {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(-0.3px, 0.15px);
}
}
@keyframes breatheRight {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(0.3px, -0.1px);
}
}
@keyframes breatheBase {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(0, 0.3px);
}
}
/* --- 2. CRYSTAL BODY (Base) --- */
.facet {
transition: fill 0.3s ease, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: center;
stroke: rgba(255, 255, 255, 0.1);
stroke-width: 0.5px;
}
/* Hover effects on individual facets for "tactile" feel */
.facet:hover {
filter: brightness(1.2);
transform: scale(1.05);
}
/* --- 3. THE EYES (The Soul) --- */
.eye-socket {
transform-box: fill-box;
transform-origin: center;
transition: transform 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* Sclera - The White Part */
.sclera {
fill: var(--gemmy-eye-bg);
transform-box: fill-box;
transform-origin: center;
transform: scaleY(var(--blink-scale));
transition: transform 0.15s cubic-bezier(0.175, 0.885, 0.32, 1.275),
d 0.3s ease;
}
/* Pupil Group - Includes Glint */
.pupil-group {
transform-box: fill-box;
transform-origin: center;
/* REMOVED transition to allow JS physics to snap */
/* Logic: Look Coordinates ONLY */
transform: translate(
calc(var(--look-x) * 3.5px),
calc(var(--look-y) * 2.5px)
);
}
.pupil {
fill: var(--gemmy-pupil);
transform-box: fill-box;
transform-origin: center;
transform: scaleY(var(--blink-scale));
transition: r 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 0.15s cubic-bezier(0.175, 0.885, 0.32, 1.275);
r: 1.6; /* Base size */
}
.glint {
fill: #fff;
opacity: 0.9;
transform-box: fill-box;
transform-origin: center;
transform: scaleY(var(--blink-scale));
transition: opacity 0.3s ease,
transform 0.15s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
/* --- 4. EYEBROWS (The Attitude) --- */
.eyebrow {
fill: none;
stroke: #1a1a24;
stroke-width: 1.2;
stroke-linecap: round;
filter: drop-shadow(0 0 0.1px #fff);
/* Smooth transitions for morphing expressions */
transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55),
d 0.3s ease, opacity 0.3s ease;
transform-box: fill-box;
transform-origin: center;
}
/* =========================================
EMOTIONAL STATE ENGINE (CSS CLASSES)
========================================= */
/* HAPPY: Cheerful, big eyes, high brows */
.mascot-wrapper.is-happy .sclera {
transform: scaleY(var(--blink-scale));
} /* Normal open */
.mascot-wrapper.is-happy .pupil {
r: 1.9;
} /* Dilated pupils (excitement) */
.mascot-wrapper.is-happy .eyebrow {
transform: translateY(-3px);
} /* High brows */
.mascot-wrapper.is-happy {
animation: floating-happy 3s ease-in-out infinite;
} /* Faster float */
/* ANGRY: Furrowed brows, small pupils, intense, shaking */
.mascot-wrapper.is-angry .eyebrow-left {
transform: rotate(25deg) translateY(1px);
}
.mascot-wrapper.is-angry .eyebrow-right {
transform: rotate(-25deg) translateY(1px);
}
.mascot-wrapper.is-angry .sclera {
transform: scaleY(0.8) scaleX(0.95);
} /* Slight squint */
.mascot-wrapper.is-angry .pupil {
r: 1.2;
} /* Pinprick pupils */
.mascot-wrapper.is-angry .obsidian-mascot {
filter: drop-shadow(0 0 20px rgba(255, 50, 50, 0.3));
animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) infinite both;
} /* Reddish glow & Shake */
/* SUSPICIOUS: Squinting, flat brows, lean */
.mascot-wrapper.is-suspicious .sclera {
transform: scaleY(0.35) scaleX(1.1);
}
.mascot-wrapper.is-suspicious .eyebrow {
transform: translateY(2.5px) scaleX(1.1);
}
.mascot-wrapper.is-suspicious .pupil-group {
transform: translate(calc(var(--look-x) * 2px), 0)
scaleY(var(--blink-scale));
} /* Restrict Y looking */
.mascot-wrapper.is-suspicious .obsidian-mascot {
transform: rotate(-5deg);
}
/* SAD: Inverted V brows, drooping eyes, low float */
.mascot-wrapper.is-sad .eyebrow-left {
transform: rotate(-15deg) translateY(-1px);
}
.mascot-wrapper.is-sad .eyebrow-right {
transform: rotate(15deg) translateY(-1px);
}
.mascot-wrapper.is-sad .sclera {
transform: scaleY(0.9);
}
.mascot-wrapper.is-sad .pupil {
r: 1.8;
} /* Watery eyes */
.mascot-wrapper.is-sad .pupil-group {
transform: translate(calc(var(--look-x) * 1px), 2px)
scaleY(var(--blink-scale));
} /* Looks down */
.mascot-wrapper.is-sad {
animation: floating-sad 8s ease-in-out infinite;
}
/* CONFUSED: Asymmetric, tilt */
.mascot-wrapper.is-confused .eyebrow-left {
transform: translateY(-3px) rotate(-10deg);
} /* High left */
.mascot-wrapper.is-confused .eyebrow-right {
transform: translateY(1px) rotate(5deg);
} /* Low right */
.mascot-wrapper.is-confused .eye-socket:nth-child(2) {
transform: scale(0.9);
} /* Slight squint one eye */
.mascot-wrapper.is-confused .obsidian-mascot {
transform: rotate(10deg);
}
/* SHY: Looks away from mouse, shrinks */
.mascot-wrapper.is-shy .eyebrow {
transform: translateY(1px);
opacity: 0.7;
}
.mascot-wrapper.is-shy .sclera {
transform: scale(0.9);
}
.mascot-wrapper.is-shy .obsidian-mascot {
transform: scale(0.9);
}
/* SLEEPING */
.mascot-wrapper.is-sleeping .sclera {
transform: scaleY(0.05);
}
.mascot-wrapper.is-sleeping .pupil-group {
opacity: 0;
}
.mascot-wrapper.is-sleeping .eyebrow {
transform: translateY(2px);
opacity: 0.5;
}
.mascot-wrapper.is-sleeping .obsidian-mascot {
filter: grayscale(0.8) opacity(0.7);
}
.mascot-wrapper.is-sleeping {
animation: floating-sleep 8s ease-in-out infinite;
}
/* SURPRISED: The "Pop" - Huge eyes, tiny pupils, brows shot up */
.mascot-wrapper.is-surprised .sclera {
transform: scaleY(1.2) scaleX(1.1); /* Stretch vertically */
transition: transform 0.1s cubic-bezier(0.175, 0.885, 0.32, 1.5); /* Snap open */
}
.mascot-wrapper.is-surprised .pupil {
r: 0.8; /* Tiny pupils (shock) */
transition: r 0.1s ease;
}
.mascot-wrapper.is-surprised .eyebrow {
transform: translateY(-8px) scaleX(1.2); /* Shot way up high */
transition: transform 0.1s cubic-bezier(0.175, 0.885, 0.32, 1.5);
}
.mascot-wrapper.is-surprised .obsidian-mascot {
/* A slight jump up */
transform: translateY(-10px) scale(1.1);
transition: transform 0.1s cubic-bezier(0.175, 0.885, 0.32, 1.5);
}
/* POKED: Squash effect */
.mascot-wrapper.is-poked .obsidian-mascot {
transform: scale(0.9, 0.8);
transition: transform 0.1s;
}
/* EXCITED: Vibrating, huge pupils, wide eyes */
.mascot-wrapper.is-excited .sclera {
transform: scaleY(1.1) scaleX(1.05);
}
.mascot-wrapper.is-excited .pupil {
r: 2.2; /* Huge pupils */
}
.mascot-wrapper.is-excited .eyebrow {
transform: translateY(-5px); /* Very high brows */
}
.mascot-wrapper.is-excited .obsidian-mascot {
animation: shake 0.2s ease-in-out infinite; /* Fast vibration */
}
.mascot-wrapper.is-excited {
animation: floating-happy 1s ease-in-out infinite; /* Very fast float */
}
/* BORED: Droopy eyes, flat brows, slow float */
.mascot-wrapper.is-bored .sclera {
transform: scaleY(0.4); /* Half closed */
}
.mascot-wrapper.is-bored .pupil {
r: 1.4; /* Normal/Smallish */
transform: translateY(1px);
}
.mascot-wrapper.is-bored .eyebrow {
transform: translateY(0px) rotate(0deg); /* Flat */
}
.mascot-wrapper.is-bored .pupil-group {
transform: translate(calc(var(--look-x) * 1px), 2px); /* Lazy looking */
}
.mascot-wrapper.is-bored {
animation: floating-sleep 10s ease-in-out infinite; /* Very slow float */
}
/* LOVE: Pink glow, dilated pupils, soft float */
.mascot-wrapper.is-love .obsidian-mascot {
filter: drop-shadow(0 0 20px rgba(255, 105, 180, 0.6)); /* Pink glow */
}
.mascot-wrapper.is-love .pupil {
r: 2.5; /* Huge pupils */
fill: #ff69b4; /* Pink pupils */
}
.mascot-wrapper.is-love .eyebrow {
transform: translateY(-4px); /* High brows */
stroke: #ff69b4; /* Pink brows */
}
.mascot-wrapper.is-love {
animation: floating-happy 4s ease-in-out infinite;
}
/* SCARED: Shaking, tiny pupils, pale */
.mascot-wrapper.is-scared .sclera {
transform: scale(1.1); /* Wide open eyes */
}
.mascot-wrapper.is-scared .pupil {
r: 0.5; /* Tiny pupils */
}
.mascot-wrapper.is-scared .eyebrow {
transform: translateY(-6px) rotate(10deg); /* High and worried */
}
.mascot-wrapper.is-scared .obsidian-mascot {
animation: shake 0.1s ease-in-out infinite; /* Fast shake */
filter: grayscale(0.5) brightness(1.2); /* Pale */
}
/* Breathing Variations */
.mascot-wrapper.is-happy .facet {
animation-duration: 2.5s;
}
.mascot-wrapper.is-angry .facet {
animation-duration: 0.3s;
}
.mascot-wrapper.is-sleeping .facet {
animation-duration: 8s;
}
.mascot-wrapper.is-sad .facet {
animation-duration: 6s;
}
/* --- ANIMATIONS --- */
@keyframes floating {
0%,
100% {
transform: translateY(0) rotate(0deg) scale(1, 1);
}
50% {
transform: translateY(-15px) rotate(2deg) scale(1.02, 0.98);
}
}
@keyframes floating-happy {
0%,
100% {
transform: translateY(0) rotate(0deg) scale(1, 1);
}
50% {
transform: translateY(-20px) rotate(3deg) scale(1.05, 0.95);
}
}
@keyframes floating-sad {
0%,
100% {
transform: translateY(10px) rotate(0deg) scale(1.05, 0.95);
}
50% {
transform: translateY(15px) rotate(-1deg) scale(1.02, 0.98);
}
}
@keyframes floating-sleep {
0%,
100% {
transform: translateY(5px) rotate(0deg) scale(1.1, 0.9);
}
50% {
transform: translateY(8px) rotate(0.5deg) scale(1.08, 0.92);
}
}
@keyframes shake {
10%,
90% {
transform: translate3d(-2px, 0, 0) rotate(-1deg);
}
20%,
80% {
transform: translate3d(4px, 0, 0) rotate(2deg);
}
30%,
50%,
70% {
transform: translate3d(-6px, 0, 0) rotate(-3deg);
}
40%,
60% {
transform: translate3d(6px, 0, 0) rotate(3deg);
}
}