mirror of
https://github.com/ericaxu/gemmy.git
synced 2026-07-22 07:30:31 +00:00
Merge d7879317b6 into c6916db84b
This commit is contained in:
commit
0df20120e9
6 changed files with 2576 additions and 515 deletions
|
|
@ -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
313
main.ts
|
|
@ -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.",
|
||||
"Can’t decide which note to work on? Try the Random Note core plugin!",
|
||||
"Are you sure you don’t 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? Don’t let the ideas get cold!",
|
||||
"What’s this? Writer’s block already?",
|
||||
"You’ve got this! Every keystroke is a step closer to brilliance.",
|
||||
"You’re 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 didn’t 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
824
package-lock.json
generated
File diff suppressed because it is too large
Load diff
217
src/main.ts
Normal file
217
src/main.ts
Normal 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
1149
src/mascot.ts
Normal file
File diff suppressed because it is too large
Load diff
574
styles.css
574
styles.css
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue