From e94d44d87f68020d086c1e4f4496d70f337c6f30 Mon Sep 17 00:00:00 2001 From: Kiya Rose Date: Sat, 23 May 2026 05:07:04 -0400 Subject: [PATCH 01/14] Start rough draft for Char Setup --- AGENTS.md | 2 +- VERSION | 2 +- asset/char/charprod.svg | 664 ++++++++++++++++++ asset/char/headshot.svg | 616 ++++++++++++++++ package.json | 2 +- public/asset/char/charprod.svg | 664 ++++++++++++++++++ public/asset/char/headshot.svg | 616 ++++++++++++++++ scripts/generate-char-manifest.mjs | 504 +++++++++++++ scripts/sync-assets.mjs | 25 + src/App.tsx | 48 +- src/db/schema.ts | 17 + src/features/character/CharacterCorner.tsx | 21 + src/features/character/CharacterLabPage.tsx | 164 +++++ src/features/character/CharacterNavIcon.tsx | 29 + .../character/DeskCharacterLayout.tsx | 14 + src/features/character/MentellCharacter.tsx | 86 +++ .../character/applyCharacterAppearance.ts | 60 ++ src/features/character/charLabControls.ts | 36 + .../character/charManifest.generated.ts | 247 +++++++ src/features/character/charManifest.ts | 1 + src/features/character/characterAppearance.ts | 14 + .../character/characterAppearanceService.ts | 87 +++ src/features/character/characterBlink.ts | 69 ++ src/features/character/characterPoses.ts | 40 ++ src/features/character/lab/LabSwitch.tsx | 30 + src/features/character/lab/OptionDial.tsx | 44 ++ src/features/character/useArmPoseAnimation.ts | 88 +++ .../character/useCharacterAppearance.ts | 59 ++ src/features/debug/DebugPanel.tsx | 1 + src/features/settings/SettingsPage.tsx | 3 + src/shared/account/accountDataService.ts | 2 + src/vite-env.d.ts | 5 + 32 files changed, 4246 insertions(+), 14 deletions(-) create mode 100644 asset/char/charprod.svg create mode 100644 asset/char/headshot.svg create mode 100644 public/asset/char/charprod.svg create mode 100644 public/asset/char/headshot.svg create mode 100644 scripts/generate-char-manifest.mjs create mode 100644 src/features/character/CharacterCorner.tsx create mode 100644 src/features/character/CharacterLabPage.tsx create mode 100644 src/features/character/CharacterNavIcon.tsx create mode 100644 src/features/character/DeskCharacterLayout.tsx create mode 100644 src/features/character/MentellCharacter.tsx create mode 100644 src/features/character/applyCharacterAppearance.ts create mode 100644 src/features/character/charLabControls.ts create mode 100644 src/features/character/charManifest.generated.ts create mode 100644 src/features/character/charManifest.ts create mode 100644 src/features/character/characterAppearance.ts create mode 100644 src/features/character/characterAppearanceService.ts create mode 100644 src/features/character/characterBlink.ts create mode 100644 src/features/character/characterPoses.ts create mode 100644 src/features/character/lab/LabSwitch.tsx create mode 100644 src/features/character/lab/OptionDial.tsx create mode 100644 src/features/character/useArmPoseAnimation.ts create mode 100644 src/features/character/useCharacterAppearance.ts diff --git a/AGENTS.md b/AGENTS.md index 66ca602..4ef7970 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ An **optional** Cloudflare Worker (`worker/`) provides weekly AI summaries via W **GitHub Pages base path:** Production URL is `https://projects.sillylittle.tech/mentell/`. CI sets `VITE_BASE=/mentell/` in `.github/workflows/gh-pages.yml`. Local dev uses `base: /`. Use `publicUrl()` for static assets under `public/`. -**UI assets:** Edit PNGs in [`asset/`](asset/) (source), then run `npm run sync:assets` to copy into `public/asset/`. Production builds run sync automatically. Reference assets in React via `publicUrl('/asset/…')`. +**UI assets:** Edit PNGs in [`asset/`](asset/) (source), then run `npm run sync:assets` to copy into `public/asset/`. Character SVGs live in [`asset/char/`](asset/char/) (also synced to `public/asset/char/`); `sync:assets` regenerates [`src/features/character/charManifest.generated.ts`](src/features/character/charManifest.generated.ts) from `charprod.svg` inkscape labels (DNI / III / TOGGLE). Test customization at `/character-lab`. Production builds run sync automatically. Reference PNGs in React via `publicUrl('/asset/…')`. ### Development commands diff --git a/VERSION b/VERSION index 0a182f2..b9268da 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.2 \ No newline at end of file +1.8.1 \ No newline at end of file diff --git a/asset/char/charprod.svg b/asset/char/charprod.svg new file mode 100644 index 0000000..bc8813e --- /dev/null +++ b/asset/char/charprod.svg @@ -0,0 +1,664 @@ + + + + diff --git a/asset/char/headshot.svg b/asset/char/headshot.svg new file mode 100644 index 0000000..0028468 --- /dev/null +++ b/asset/char/headshot.svg @@ -0,0 +1,616 @@ + + + + diff --git a/package.json b/package.json index 43071e4..8a185a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mentell", "private": true, - "version": "1.7.2", + "version": "1.8.1", "type": "module", "scripts": { "sync:assets": "node scripts/sync-assets.mjs", diff --git a/public/asset/char/charprod.svg b/public/asset/char/charprod.svg new file mode 100644 index 0000000..bc8813e --- /dev/null +++ b/public/asset/char/charprod.svg @@ -0,0 +1,664 @@ + + + + diff --git a/public/asset/char/headshot.svg b/public/asset/char/headshot.svg new file mode 100644 index 0000000..0028468 --- /dev/null +++ b/public/asset/char/headshot.svg @@ -0,0 +1,616 @@ + + + + diff --git a/scripts/generate-char-manifest.mjs b/scripts/generate-char-manifest.mjs new file mode 100644 index 0000000..6f64362 --- /dev/null +++ b/scripts/generate-char-manifest.mjs @@ -0,0 +1,504 @@ +/** + * Parses asset/char/charprod.svg inkscape:label conventions into a TS manifest. + * DNI = do not customize, III = fill color, TOGGLE = mutually exclusive siblings. + * toggle*_III parents = global fill across all style variants. + */ +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const svgPath = path.join(root, 'asset', 'char', 'charprod.svg') +const headshotPath = path.join(root, 'asset', 'char', 'headshot.svg') +const outPath = path.join(root, 'src', 'features', 'character', 'charManifest.generated.ts') + +const ID_RE = /\bid="([^"]+)"/ +const STYLE_RE = /style="([^"]*)"/ + +function classifyLabel(label) { + const u = label.toUpperCase() + if (u.endsWith('_DNI')) return 'dni' + if (u.endsWith('_TOGGLE_III') || u.endsWith('_TOGGLE')) return 'toggle' + if (label.toLowerCase().endsWith('_toggle')) return 'toggle' + if (/toggle.*_III$/i.test(label)) return 'globalFill' + if (u.endsWith('_III')) return 'iii' + return null +} + +function humanize(label) { + return label + .replace(/_(TOGGLE|Toggle|III|DNI).*$/i, '') + .replace(/_/g, ' ') + .replace(/\btoggle\b/gi, '') + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()) || label +} + +function globalFillKey(label) { + if (/shirt/i.test(label)) return 'shirt' + if (/sleeve/i.test(label)) return 'sleeves' + return humanize(label).toLowerCase().replace(/\s+/g, '_') +} + +function parseFill(style) { + if (!style) return null + const m = style.match(/(?:^|;)\s*fill:(#[0-9a-fA-F]{3,8})/) + if (m) return m[1] + return null +} + +function isVisibleOpening(tag) { + const style = tag.match(STYLE_RE)?.[1] ?? '' + if (!style.includes('display:')) return true + return /display:\s*inline/.test(style) +} + +function pathStartX(pathTag) { + const d = pathTag.match(/\bd="([^"]*)"/)?.[1] + if (!d) return null + const m = d.match(/-?\d*\.?\d+/) + return m ? Number.parseFloat(m[0]) : null +} + +/** Direct-child toggle options only (one nesting level under parent). */ +function directToggleChildren(parentInner) { + const children = [] + let depth = 0 + for (let i = 0; i < parentInner.length; i++) { + if (parentInner.startsWith('') + const tag = slice.slice(0, tagEnd + 1) + if (label && id && classifyLabel(label) === 'toggle') { + children.push({ id, label, visible: isVisibleOpening(tag) }) + } + } + depth++ + i = parentInner.indexOf('>', i) + continue + } + if (parentInner.startsWith('', i)) { + depth = Math.max(0, depth - 1) + i += 3 + continue + } + if (depth === 0 && parentInner.startsWith('') + const tag = slice.slice(0, tagEnd + 1) + if (label && id && classifyLabel(label) === 'toggle') { + children.push({ id, label, visible: isVisibleOpening(tag) }) + } + } + } + return children +} + +function extractBalancedGroup(xml, startIndex) { + const open = xml.indexOf('>', startIndex) + if (open === -1) return null + let depth = 1 + let i = open + 1 + while (i < xml.length && depth > 0) { + const nextOpen = xml.indexOf('', i) + if (nextClose === -1) break + if (nextOpen !== -1 && nextOpen < nextClose) { + depth++ + i = nextOpen + 2 + } else { + depth-- + i = nextClose + 4 + } + } + return xml.slice(startIndex, i) +} + +function collectSolidFillPathIds(block) { + const ids = [] + const tagRe = /<(path|ellipse)\b[^>]*>/g + let m + while ((m = tagRe.exec(block)) !== null) { + const tag = m[0] + const id = tag.match(ID_RE)?.[1] + const style = tag.match(STYLE_RE)?.[1] + if (id && parseFill(style)) ids.push(id) + } + return [...new Set(ids)] +} + +function collectSleeveIdsBySide(xml, sleeveParentId) { + const parentMatch = new RegExp( + `]*id="${sleeveParentId}"[^>]*inkscape:label="[^"]*"[^>]*>`, + 'i', + ).exec(xml) + if (!parentMatch) return { left: [], right: [] } + + const block = extractBalancedGroup(xml, parentMatch.index) + if (!block) return { left: [], right: [] } + + const midX = -485 + const left = [] + const right = [] + const pathRe = /]*>/g + let pm + while ((pm = pathRe.exec(block)) !== null) { + const tag = pm[0] + const id = tag.match(ID_RE)?.[1] + if (!id) continue + const x = pathStartX(tag) + if (x == null) continue + if (x < midX) left.push(id) + else right.push(id) + } + return { left, right } +} + +/** armL / armR paths (gradient fills) tinted with skin. */ +function collectArmSkinPathIds(xml) { + const ids = [] + const re = /]*inkscape:label="arm[LR]"[^>]*>/gi + let m + while ((m = re.exec(xml)) !== null) { + const id = m[0].match(ID_RE)?.[1] + if (id) ids.push(id) + } + return [...new Set(ids)] +} + +/** All path/ellipse under togglehair (layer16) with a solid hex fill. */ +function collectHairFillIds(xml) { + const parentMatch = /]*id="layer16"[^>]*inkscape:label="[^"]*"[^>]*>/i.exec(xml) + if (!parentMatch) return [] + + const block = extractBalancedGroup(xml, parentMatch.index) + if (!block) return [] + + const ids = [] + const tagRe = /<(path|ellipse)\b[^>]*>/g + let m + while ((m = tagRe.exec(block)) !== null) { + const tag = m[0] + const id = tag.match(ID_RE)?.[1] + const style = tag.match(STYLE_RE)?.[1] ?? '' + if (!id || !parseFill(style)) continue + ids.push(id) + } + return [...new Set(ids)] +} + +/** Canonical look from headshot.svg (toggles + solid fills). */ +function extractAppearanceFromSvg(xml) { + const fills = {} + const toggles = {} + + const tagRe = /<(path|g)\b[^>]*inkscape:label="([^"]+)"[^>]*>/g + let tm + while ((tm = tagRe.exec(xml)) !== null) { + const label = tm[2] + if (classifyLabel(label) !== 'iii') continue + if (label.toUpperCase().includes('TOGGLE')) continue + const tag = tm[0] + const id = tag.match(ID_RE)?.[1] + if (!id) continue + const color = parseFill(tag.match(STYLE_RE)?.[1]) + if (color) fills[id] = color + } + + const hairFillIds = collectHairFillIds(xml) + if (hairFillIds.length) { + const block = + extractBalancedGroup(xml, xml.search(/]*id="layer16"/i)) ?? '' + const hairColor = hairFillIds + .map((id) => { + const m = block.match( + new RegExp(`<(?:path|ellipse)\\b[^>]*id="${id}"[^>]*style="([^"]*)"`, 'i'), + ) + const fill = m ? parseFill(m[1]) : null + return fill && fill.toLowerCase() !== '#ffffff' ? fill : null + }) + .find(Boolean) + if (hairColor) fills.hair_fill = hairColor + } + + const parentRe = /]*inkscape:label="([^"]*)"[^>]*>/gi + while ((tm = parentRe.exec(xml)) !== null) { + const parentLabel = tm[1] + if (!/toggle/i.test(parentLabel)) continue + if (!/inkscape:groupmode="layer"/.test(tm[0])) continue + + const parentId = tm[0].match(ID_RE)?.[1] ?? parentLabel + const block = extractBalancedGroup(xml, tm.index) + if (!block) continue + const inner = block.slice(block.indexOf('>') + 1, block.lastIndexOf('')) + + if (classifyLabel(parentLabel) === 'globalFill') { + const key = globalFillKey(parentLabel) + const targetIds = collectSolidFillPathIds(block) + const color = targetIds + .map((id) => { + const m = block.match( + new RegExp(`<(?:path|ellipse)\\b[^>]*id="${id}"[^>]*style="([^"]*)"`, 'i'), + ) + return m ? parseFill(m[1]) : null + }) + .find(Boolean) + if (color) fills[key] = color + } + + const children = directToggleChildren(inner) + if (children.length < 2) continue + toggles[parentId] = children.find((c) => c.visible)?.id ?? children[0].id + } + + const blushMatch = xml.match( + /]*id="(g102)"[^>]*inkscape:label="blush_TOGGLE"[^>]*style="([^"]*)"/, + ) + if (blushMatch) { + toggles.blush = isVisibleOpening(blushMatch[0]) ? 'on' : 'off' + } + + return { fills, toggles } +} + +/** Open-eye layers (*_BLK) vs closed-eye overlay (label exactly BLK). */ +function collectBlinkLayers(xml) { + const openLayerIds = [] + let closedLayerId = null + const re = /]*inkscape:label="([^"]*)"[^>]*>/gi + let m + while ((m = re.exec(xml)) !== null) { + const label = m[1] + const id = m[0].match(ID_RE)?.[1] + if (!id) continue + if (label === 'BLK') closedLayerId = id + else if (label.endsWith('_BLK')) openLayerIds.push(id) + } + if (!openLayerIds.length || !closedLayerId) return null + return { + openLayerIds, + closedLayerId, + closedDurationMs: 120, + minIntervalMs: 2000, + maxIntervalMs: 4500, + } +} + +function applyHeadshotDefaults(structure, headshotDefaults) { + for (const f of structure.fillables) { + const fromHeadshot = headshotDefaults.fills[f.key] + if (fromHeadshot) f.defaultFill = fromHeadshot + } + for (const g of structure.globalFillGroups) { + const fromHeadshot = headshotDefaults.fills[g.key] + if (fromHeadshot) g.defaultFill = fromHeadshot + } + for (const group of structure.toggleGroups) { + const fromHeadshot = headshotDefaults.toggles[group.key] + if (fromHeadshot) group.defaultOption = fromHeadshot + } + + const appearanceDefaults = { + fills: Object.fromEntries( + structure.fillables.map((f) => [f.key, f.defaultFill]).concat( + structure.globalFillGroups.map((g) => [g.key, g.defaultFill]), + ), + ), + toggles: Object.fromEntries( + structure.toggleGroups.map((g) => [g.key, g.defaultOption]), + ), + } + + return appearanceDefaults +} + +async function main() { + const xml = await readFile(svgPath, 'utf8') + const headshotXml = await readFile(headshotPath, 'utf8') + + const fillables = [] + const globalFillGroups = [] + const toggleGroups = [] + const globalFillParentIds = new Set() + + const viewBoxMatch = xml.match(/viewBox="([^"]+)"/) + const viewBox = viewBoxMatch?.[1] ?? '0 0 296 374' + + const arms = { + armL: { jointId: 'armL_joint', pivotX: 0, pivotY: 0, sleeveIds: [] }, + armR: { jointId: 'armR_joint', pivotX: 0, pivotY: 0, sleeveIds: [] }, + } + + const sleeveSides = collectSleeveIdsBySide(xml, 'layer19') + arms.armL.sleeveIds = sleeveSides.left + arms.armR.sleeveIds = sleeveSides.right + + const tagRe = /<(path|g)\b[^>]*inkscape:label="([^"]+)"[^>]*>/g + let tm + while ((tm = tagRe.exec(xml)) !== null) { + const label = tm[2] + if (classifyLabel(label) !== 'iii') continue + if (label.toUpperCase().includes('TOGGLE')) continue + const tag = tm[0] + const id = tag.match(ID_RE)?.[1] + if (!id) continue + const style = tag.match(STYLE_RE)?.[1] + fillables.push({ + id, + key: id, + label: humanize(label), + defaultFill: parseFill(style) ?? '#ddae67', + }) + } + + const armSkinIds = collectArmSkinPathIds(xml) + const skinFillable = fillables.find((f) => f.id === 'path45') + if (skinFillable && armSkinIds.length) { + skinFillable.targetIds = [skinFillable.id, ...armSkinIds] + } + + const hairFillIds = collectHairFillIds(xml) + if (hairFillIds.length) { + const block = extractBalancedGroup( + xml, + xml.search(/]*id="layer16"/i), + ) ?? '' + const defaultFill = + hairFillIds + .map((id) => { + const m = block.match( + new RegExp(`<(?:path|ellipse)\\b[^>]*id="${id}"[^>]*style="([^"]*)"`, 'i'), + ) + const fill = m ? parseFill(m[1]) : null + return fill && fill.toLowerCase() !== '#ffffff' ? fill : null + }) + .find(Boolean) ?? '#311e00' + + fillables.push({ + id: 'hair_fill', + key: 'hair_fill', + label: 'Hair', + defaultFill, + targetIds: hairFillIds, + }) + } + + const parentRe = /]*inkscape:label="([^"]*)"[^>]*>/gi + while ((tm = parentRe.exec(xml)) !== null) { + const parentLabel = tm[1] + if (!/toggle/i.test(parentLabel)) continue + if (!/inkscape:groupmode="layer"/.test(tm[0])) continue + + const parentId = tm[0].match(ID_RE)?.[1] ?? parentLabel + const block = extractBalancedGroup(xml, tm.index) + if (!block) continue + const inner = block.slice(block.indexOf('>') + 1, block.lastIndexOf('')) + + if (classifyLabel(parentLabel) === 'globalFill') { + globalFillParentIds.add(parentId) + const targetIds = collectSolidFillPathIds(block) + const defaultFill = + targetIds + .map((id) => { + const m = block.match( + new RegExp(`<(?:path|ellipse)\\b[^>]*id="${id}"[^>]*style="([^"]*)"`, 'i'), + ) + return m ? parseFill(m[1]) : null + }) + .find(Boolean) ?? '#261b4f' + + globalFillGroups.push({ + key: globalFillKey(parentLabel), + label: humanize(parentLabel.replace(/_III$/i, '')), + parentId, + defaultFill, + targetIds, + }) + } + + const children = directToggleChildren(inner) + if (children.length < 2) continue + const defaultOption = children.find((c) => c.visible)?.id ?? children[0].id + toggleGroups.push({ + key: parentId, + label: humanize(parentLabel.replace(/_III$/i, '')), + parentId, + defaultOption, + options: children.map((c) => ({ + id: c.id, + label: humanize(c.label), + })), + }) + } + + const blushMatch = xml.match( + /]*id="(g102)"[^>]*inkscape:label="blush_TOGGLE"[^>]*style="([^"]*)"/, + ) + if (blushMatch) { + toggleGroups.push({ + key: 'blush', + label: 'Blush', + parentId: 'g102', + defaultOption: isVisibleOpening(blushMatch[0]) ? 'on' : 'off', + options: [ + { id: 'on', label: 'On' }, + { id: 'off', label: 'Off' }, + ], + elementId: blushMatch[1], + }) + } + + const headshotViewBoxMatch = headshotXml.match(/viewBox="([^"]+)"/) + const headshotViewBox = headshotViewBoxMatch?.[1] ?? '0 0 100 100' + + const headshotDefaults = extractAppearanceFromSvg(headshotXml) + const appearanceDefaults = applyHeadshotDefaults( + { fillables, globalFillGroups, toggleGroups }, + headshotDefaults, + ) + + const blink = collectBlinkLayers(xml) + + const manifest = { + viewBox, + arms, + fillables, + globalFillGroups, + toggleGroups, + appearanceDefaults, + ...(blink ? { blink } : {}), + headshotViewBox, + assets: { + character: 'char/charprod.svg', + headshot: 'char/headshot.svg', + }, + } + + await mkdir(path.dirname(outPath), { recursive: true }) + const ts = `// Generated by scripts/generate-char-manifest.mjs — do not edit by hand. +export const charManifest = ${JSON.stringify(manifest, null, 2)} as const + +export type CharManifest = typeof charManifest +export type CharacterPoseId = + | 'idle' + | 'present' + | 'think' + | 'write' + | 'shop' + | 'wave' +` + await writeFile(outPath, ts) + console.log( + `Wrote char manifest (${fillables.length} fills, ${globalFillGroups.length} global, ${toggleGroups.length} toggles) → ${path.relative(root, outPath)}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/sync-assets.mjs b/scripts/sync-assets.mjs index 8f6bbd9..71b35dc 100644 --- a/scripts/sync-assets.mjs +++ b/scripts/sync-assets.mjs @@ -1,10 +1,13 @@ import { copyFile, mkdir, readdir } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const srcDir = path.join(root, 'asset') const destDir = path.join(root, 'public', 'asset') +const charSrcDir = path.join(srcDir, 'char') +const charDestDir = path.join(destDir, 'char') const MENTELL_ICON_SRC = 'mentellicon-Default-1024x1024@1x.png' const MENTELL_ICON_DEST = 'mentell-icon.png' @@ -36,6 +39,28 @@ async function main() { } console.log(`Synced ${copied} PNG(s) from asset/ → public/asset/`) + + try { + const charFiles = await readdir(charSrcDir) + await mkdir(charDestDir, { recursive: true }) + let svgCopied = 0 + for (const file of charFiles.filter((f) => f.toLowerCase().endsWith('.svg'))) { + await copyFile(path.join(charSrcDir, file), path.join(charDestDir, file)) + svgCopied++ + } + if (svgCopied) console.log(`Synced ${svgCopied} SVG(s) from asset/char/ → public/asset/char/`) + } catch { + console.warn('Warning: asset/char/ not found; skipping character SVG sync') + } + + await new Promise((resolve, reject) => { + const child = spawn('node', ['scripts/generate-char-manifest.mjs'], { + cwd: root, + stdio: 'inherit', + }) + child.on('error', reject) + child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`manifest exit ${code}`)))) + }) } main() diff --git a/src/App.tsx b/src/App.tsx index 4629a86..84812db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,9 @@ import { ShareDashboardPage } from './features/share/ShareDashboardPage' import { SyncOnboardingBanner } from './features/settings/SyncOnboardingBanner' import { AppLegalFooter } from './components/AppLegalFooter' import { PrivacyPolicyPage } from './features/legal/PrivacyPolicyPage' +import { CharacterLabPage } from './features/character/CharacterLabPage' +import { CharacterNavIcon } from './features/character/CharacterNavIcon' +import { DeskCharacterLayout } from './features/character/DeskCharacterLayout' import { isFirebaseSyncEnabled, isShareLinksEnabled } from './shared/features/featureFlags' import { useAuthOptional } from './shared/firebase/AuthProvider' @@ -126,6 +129,7 @@ function App() { element={} /> } /> + } /> } /> {isShareLinksEnabled() ? ( } /> @@ -222,6 +226,7 @@ function TopBar({ + @@ -292,6 +297,13 @@ function TopBar({ onNavigate={() => setMobileMenuOpen(false)} className="w-full" /> + setMobileMenuOpen(false)} + className="w-full" + />
Appearance = { Notepad: '/asset/notepad.png', Shoppe: '/asset/shoppe.png', Settings: '/asset/setting.png', + } function navIconFor(label: string) { @@ -371,6 +384,7 @@ function DeskLink({ className?: string }) { const icon = navIconFor(label) + const isCharacter = label === 'Character' return (
- {icon ? ( + {isCharacter ? ( + + ) : icon ? ( ) : null}
@@ -434,6 +450,7 @@ function HomePlaceholder({ title="Draft today’s letter" subtitle="Draft it like stationery — then review and submit." > +
+ ) } function WeekPlaceholder() { - return + return ( + + + + ) } function NotesPlaceholder() { return ( -
- - -
+ +
+ + +
+
) } @@ -497,11 +521,13 @@ function ShopPlaceholder({ onScoreChange: (delta: number, hint: string | null) => void }) { return ( - { - onScoreChange(delta, hint) - }} - /> + + { + onScoreChange(delta, hint) + }} + /> + ) } diff --git a/src/db/schema.ts b/src/db/schema.ts index 36890ac..104659b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -56,11 +56,20 @@ export type PackageRow = { openedScoreDelta?: number } +/** Singleton row (`id` is always `default`) for desk character customization. */ +export type CharacterAppearanceRow = { + id: 'default' + updatedAt: number + fills: Record + toggles: Record +} + export class MentellDB extends Dexie { entries!: Table notes!: Table stickies!: Table packages!: Table + characterAppearance!: Table constructor(name: string) { super(name) @@ -123,6 +132,14 @@ export class MentellDB extends Dexie { if (!row.coordSpace) row.coordSpace = 'board' }) }) + + this.version(5).stores({ + entries: '&id, dateKey, createdAt, updatedAt, sentiment, warningLevel', + notes: '&id, createdAt, updatedAt, tag', + stickies: '&id, createdAt, updatedAt, zIndex', + packages: '&id, kind, periodKey, createdAt, updatedAt, openedAt', + characterAppearance: '&id, updatedAt', + }) } } diff --git a/src/features/character/CharacterCorner.tsx b/src/features/character/CharacterCorner.tsx new file mode 100644 index 0000000..b77fb62 --- /dev/null +++ b/src/features/character/CharacterCorner.tsx @@ -0,0 +1,21 @@ +import { useLocation } from 'react-router-dom' +import { MentellCharacter } from './MentellCharacter' +import { poseForPathname } from './characterPoses' +import { useCharacterAppearance } from './useCharacterAppearance' + +/** Small decorative character for desk screens. */ +export function CharacterCorner({ className }: { className?: string }) { + const { pathname } = useLocation() + const pose = poseForPathname(pathname) + const { appearance, ready } = useCharacterAppearance() + + if (!ready) return null + + return ( + + ) +} diff --git a/src/features/character/CharacterLabPage.tsx b/src/features/character/CharacterLabPage.tsx new file mode 100644 index 0000000..6d2dfc0 --- /dev/null +++ b/src/features/character/CharacterLabPage.tsx @@ -0,0 +1,164 @@ +import { useMemo, useState } from 'react' +import { MentellCharacter } from './MentellCharacter' +import { charManifest } from './charManifest' +import { POSE_LABELS } from './characterPoses' +import type { CharacterPoseId } from './charManifest' +import { + LAB_COLOR_ORDER, + LAB_POSE_ORDER, + LAB_TOGGLE_CONFIG, +} from './charLabControls' +import { OptionDial } from './lab/OptionDial' +import { LabSwitch } from './lab/LabSwitch' +import { useCharacterAppearance } from './useCharacterAppearance' + +export function CharacterLabPage() { + const { appearance, setAppearance, resetAppearance, ready } = useCharacterAppearance() + const [pose, setPose] = useState('wave') + + const toggleByKey = useMemo(() => { + const map = new Map(charManifest.toggleGroups.map((g) => [g.key, g])) + return map + }, []) + + function setFill(key: string, value: string) { + setAppearance((prev) => ({ + ...prev, + fills: { ...prev.fills, [key]: value }, + })) + } + + function setToggle(groupKey: string, optionId: string) { + setAppearance((prev) => ({ + ...prev, + toggles: { ...prev.toggles, [groupKey]: optionId }, + })) + } + + function colorValue(key: string, fallback: string) { + return appearance.fills[key] ?? fallback + } + + function defaultForColorKey(key: string) { + const fillable = charManifest.fillables.find((f) => f.key === key) + if (fillable) return fillable.defaultFill + const global = charManifest.globalFillGroups.find((g) => g.key === key) + return global?.defaultFill ?? '#000000' + } + + return ( +
+
Character lab
+
+ Preview looks and poses. Changes save locally and update the desk mascot and Character + tab icon. +
+ +
+
+
+ {ready ? ( + + ) : ( +
Loading saved look…
+ )} +
+
+ +
+
+ Look +
+ {LAB_COLOR_ORDER.map(({ key, label }) => ( + + ))} +
+
+ {LAB_TOGGLE_CONFIG.filter((c) => c.kind === 'dial').map((cfg) => { + const group = toggleByKey.get(cfg.groupKey) + if (!group) return null + const activeId = appearance.toggles[cfg.groupKey] ?? group.defaultOption + const valueIndex = Math.max( + 0, + group.options.findIndex((o) => o.id === activeId), + ) + return ( + setToggle(cfg.groupKey, id)} + /> + ) + })} +
+
+ +
+ Face + {LAB_TOGGLE_CONFIG.filter((c) => c.kind === 'switch').map((cfg) => { + const group = toggleByKey.get(cfg.groupKey) + if (!group || !('elementId' in group)) return null + const on = (appearance.toggles[cfg.groupKey] ?? group.defaultOption) === 'on' + return ( + setToggle(cfg.groupKey, checked ? 'on' : 'off')} + /> + ) + })} +
+ +
+ Pose +
+ {LAB_POSE_ORDER.map((id) => ( + + ))} +
+

Pose preview applies to the full character only.

+
+ + +
+
+
+ ) +} diff --git a/src/features/character/CharacterNavIcon.tsx b/src/features/character/CharacterNavIcon.tsx new file mode 100644 index 0000000..e7f0c67 --- /dev/null +++ b/src/features/character/CharacterNavIcon.tsx @@ -0,0 +1,29 @@ +import { publicUrl } from '../../shared/publicUrl' +import { MentellCharacter } from './MentellCharacter' +import { useCharacterAppearance } from './useCharacterAppearance' + +/** Desk nav icon — live headshot using saved character appearance. */ +export function CharacterNavIcon({ className }: { className?: string }) { + const { appearance, ready } = useCharacterAppearance() + + if (!ready) { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/features/character/DeskCharacterLayout.tsx b/src/features/character/DeskCharacterLayout.tsx new file mode 100644 index 0000000..9dc48d9 --- /dev/null +++ b/src/features/character/DeskCharacterLayout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' +import { CharacterCorner } from './CharacterCorner' + +/** Wraps desk page content with a bottom-right character mascot. */ +export function DeskCharacterLayout({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ +
+
+ ) +} diff --git a/src/features/character/MentellCharacter.tsx b/src/features/character/MentellCharacter.tsx new file mode 100644 index 0000000..4447f1b --- /dev/null +++ b/src/features/character/MentellCharacter.tsx @@ -0,0 +1,86 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import charSvg from '../../../asset/char/charprod.svg?raw' +import headshotSvg from '../../../asset/char/headshot.svg?raw' +import { applyCharacterAppearance } from './applyCharacterAppearance' +import { charManifest } from './charManifest' +import { + defaultCharacterAppearance, + type CharacterAppearance, +} from './characterAppearance' +import type { CharacterPoseId } from './charManifest' +import { CHARACTER_POSES } from './characterPoses' +import { useCharacterBlink } from './characterBlink' +import { useArmPoseAnimation } from './useArmPoseAnimation' +import { useCharacterAppearance } from './useCharacterAppearance' + +const DEFAULT_APPEARANCE = defaultCharacterAppearance() + +const SVG_SOURCE = { + character: charSvg, + headshot: headshotSvg, +} as const + +export type CharacterAsset = keyof typeof SVG_SOURCE + +export type MentellCharacterProps = { + pose: CharacterPoseId + asset?: CharacterAsset + appearance?: CharacterAppearance + className?: string + title?: string +} + +export function MentellCharacter({ + pose, + asset = 'character', + appearance: appearanceProp, + className, + title, +}: MentellCharacterProps) { + const svgRef = useRef(null) + const hostRef = useRef(null) + const [svgGeneration, setSvgGeneration] = useState(0) + const { appearance: storedAppearance } = useCharacterAppearance() + const appearance = appearanceProp ?? storedAppearance ?? DEFAULT_APPEARANCE + const appearanceKey = JSON.stringify(appearance) + const armPose = CHARACTER_POSES[pose] + const isBody = asset === 'character' + + useLayoutEffect(() => { + const host = hostRef.current + if (!host) return + host.innerHTML = SVG_SOURCE[asset] + const svg = host.querySelector('svg') + if (!svg) return + svgRef.current = svg + svg.setAttribute('width', '100%') + svg.setAttribute('height', '100%') + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') + svg.style.display = 'block' + svg.style.overflow = 'visible' + applyCharacterAppearance(svg, JSON.parse(appearanceKey) as CharacterAppearance) + setSvgGeneration((g) => g + 1) + }, [appearanceKey, asset]) + + useArmPoseAnimation(svgRef, isBody ? armPose : { armL: 0, armR: 0 }, svgGeneration) + useCharacterBlink(svgRef, isBody ? svgGeneration : -1) + + const viewBox = + asset === 'headshot' ? charManifest.headshotViewBox : charManifest.viewBox + const [, , vbW, vbH] = viewBox.split(/\s+/).map(Number) + + return ( +
+
+
+ ) +} diff --git a/src/features/character/applyCharacterAppearance.ts b/src/features/character/applyCharacterAppearance.ts new file mode 100644 index 0000000..2cf337e --- /dev/null +++ b/src/features/character/applyCharacterAppearance.ts @@ -0,0 +1,60 @@ +import { applyBlinkOpenState } from './characterBlink' +import { charManifest } from './charManifest' +import type { CharacterAppearance } from './characterAppearance' + +function setToggleOptionVisible(el: SVGElement, show: boolean) { + el.style.display = show ? 'inline' : 'none' + if (!show || !(el instanceof SVGGElement)) return + for (const child of el.querySelectorAll('path,ellipse,g')) { + const attr = child.getAttribute('style') ?? '' + if (attr.includes('display:none') && !attr.includes('display:inline')) continue + child.style.display = 'inline' + } +} + +export function applyCharacterAppearance( + svg: SVGSVGElement, + appearance: CharacterAppearance, +) { + for (const fillable of charManifest.fillables) { + const color = appearance.fills[fillable.key] ?? fillable.defaultFill + const targetIds = + 'targetIds' in fillable && fillable.targetIds + ? [...fillable.targetIds] + : [fillable.id] + for (const id of targetIds) { + const el = svg.getElementById(id) + if (!(el instanceof SVGElement)) continue + el.style.fill = color + } + } + + for (const group of charManifest.globalFillGroups) { + const color = appearance.fills[group.key] ?? group.defaultFill + for (const id of group.targetIds) { + const el = svg.getElementById(id) + if (!(el instanceof SVGElement)) continue + el.style.fill = color + } + } + + for (const group of charManifest.toggleGroups) { + const active = appearance.toggles[group.key] ?? group.defaultOption + + if (group.key === 'blush' && 'elementId' in group) { + const blush = svg.getElementById(group.elementId) + if (blush instanceof SVGElement) { + blush.style.display = active === 'on' ? 'inline' : 'none' + } + continue + } + + for (const opt of group.options) { + const el = svg.getElementById(opt.id) + if (!(el instanceof SVGElement)) continue + setToggleOptionVisible(el, opt.id === active) + } + } + + applyBlinkOpenState(svg) +} diff --git a/src/features/character/charLabControls.ts b/src/features/character/charLabControls.ts new file mode 100644 index 0000000..f4f51bd --- /dev/null +++ b/src/features/character/charLabControls.ts @@ -0,0 +1,36 @@ +import type { CharacterPoseId } from './charManifest' + +export type LabControlKind = 'dial' | 'switch' | 'color' | 'pose' + +export type LabToggleConfig = { + groupKey: string + label: string + kind: 'dial' | 'switch' + dialLabels?: string[] +} + +export const LAB_TOGGLE_CONFIG: LabToggleConfig[] = [ + { groupKey: 'layer16', label: 'Hair', kind: 'dial', dialLabels: ['1', '2', '3'] }, + { groupKey: 'layer15', label: 'Shirt', kind: 'dial', dialLabels: ['1', '2', '3'] }, + { groupKey: 'layer19', label: 'Sleeves', kind: 'dial', dialLabels: ['1', '2'] }, + { groupKey: 'layer18', label: 'Eyes', kind: 'dial', dialLabels: ['1', '2', '3'] }, + { groupKey: 'layer17', label: 'Expression', kind: 'dial', dialLabels: ['1', '2', '3'] }, + { groupKey: 'blush', label: 'Blush', kind: 'switch' }, +] + +export const LAB_COLOR_ORDER: { key: string; label: string }[] = [ + { key: 'path45', label: 'Skin' }, + { key: 'path65', label: 'Pants' }, + { key: 'hair_fill', label: 'Hair' }, + { key: 'shirt', label: 'Shirt' }, + { key: 'sleeves', label: 'Sleeves' }, +] + +export const LAB_POSE_ORDER: CharacterPoseId[] = [ + 'idle', + 'present', + 'think', + 'write', + 'shop', + 'wave', +] diff --git a/src/features/character/charManifest.generated.ts b/src/features/character/charManifest.generated.ts new file mode 100644 index 0000000..9f0eda2 --- /dev/null +++ b/src/features/character/charManifest.generated.ts @@ -0,0 +1,247 @@ +// Generated by scripts/generate-char-manifest.mjs — do not edit by hand. +export const charManifest = { + "viewBox": "0 0 296 374", + "arms": { + "armL": { + "jointId": "armL_joint", + "pivotX": 0, + "pivotY": 0, + "sleeveIds": [ + "path124", + "path126" + ] + }, + "armR": { + "jointId": "armR_joint", + "pivotX": 0, + "pivotY": 0, + "sleeveIds": [ + "path125", + "path127" + ] + } + }, + "fillables": [ + { + "id": "path45", + "key": "path45", + "label": "Skin", + "defaultFill": "#ddae67", + "targetIds": [ + "path45", + "path1", + "path1-3" + ] + }, + { + "id": "path65", + "key": "path65", + "label": "Pants", + "defaultFill": "#5042ae" + }, + { + "id": "hair_fill", + "key": "hair_fill", + "label": "Hair", + "defaultFill": "#311e00", + "targetIds": [ + "path85-7", + "path85", + "path87-4", + "path87-4-4", + "path90", + "path91", + "path93", + "path94", + "path95", + "path96", + "path99", + "path97", + "path100" + ] + } + ], + "globalFillGroups": [ + { + "key": "sleeves", + "label": "Togglesleeve", + "parentId": "layer19", + "defaultFill": "#261b4f", + "targetIds": [ + "path124", + "path125", + "path126", + "path127" + ] + }, + { + "key": "shirt", + "label": "Toggleshirt", + "parentId": "layer15", + "defaultFill": "#261b4f", + "targetIds": [ + "path67", + "path118", + "path119", + "path120" + ] + } + ], + "toggleGroups": [ + { + "key": "layer19", + "label": "Togglesleeve", + "parentId": "layer19", + "defaultOption": "g127", + "options": [ + { + "id": "g125", + "label": "Longshirtsleeve" + }, + { + "id": "g127", + "label": "Shortsleeve" + } + ] + }, + { + "key": "layer15", + "label": "Toggleshirt", + "parentId": "layer15", + "defaultOption": "path67", + "options": [ + { + "id": "path67", + "label": "Tshirt" + }, + { + "id": "path118", + "label": "Longshirt" + }, + { + "id": "g120", + "label": "Crop" + } + ] + }, + { + "key": "layer16", + "label": "Togglehair", + "parentId": "layer16", + "defaultOption": "g96", + "options": [ + { + "id": "g89", + "label": "Long" + }, + { + "id": "g93", + "label": "Short" + }, + { + "id": "g96", + "label": "Pony" + } + ] + }, + { + "key": "layer17", + "label": "Togglemouth", + "parentId": "layer17", + "defaultOption": "g103", + "options": [ + { + "id": "path51-8", + "label": "Positive" + }, + { + "id": "g103", + "label": "NEU" + }, + { + "id": "path51-8-5", + "label": "Negative" + } + ] + }, + { + "key": "layer18", + "label": "Toggleeyecolour BLK", + "parentId": "layer18", + "defaultOption": "g105", + "options": [ + { + "id": "g104", + "label": "Default" + }, + { + "id": "g105", + "label": "Brown" + }, + { + "id": "g107", + "label": "Blue" + } + ] + }, + { + "key": "blush", + "label": "Blush", + "parentId": "g102", + "defaultOption": "on", + "options": [ + { + "id": "on", + "label": "On" + }, + { + "id": "off", + "label": "Off" + } + ], + "elementId": "g102" + } + ], + "appearanceDefaults": { + "fills": { + "path45": "#ddae67", + "path65": "#5042ae", + "hair_fill": "#311e00", + "sleeves": "#261b4f", + "shirt": "#261b4f" + }, + "toggles": { + "layer19": "g127", + "layer15": "path67", + "layer16": "g96", + "layer17": "g103", + "layer18": "g105", + "blush": "on" + } + }, + "blink": { + "openLayerIds": [ + "layer14", + "layer18", + "g100" + ], + "closedLayerId": "g11", + "closedDurationMs": 120, + "minIntervalMs": 2000, + "maxIntervalMs": 4500 + }, + "headshotViewBox": "0 0 100 100", + "assets": { + "character": "char/charprod.svg", + "headshot": "char/headshot.svg" + } +} as const + +export type CharManifest = typeof charManifest +export type CharacterPoseId = + | 'idle' + | 'present' + | 'think' + | 'write' + | 'shop' + | 'wave' diff --git a/src/features/character/charManifest.ts b/src/features/character/charManifest.ts new file mode 100644 index 0000000..0c1c002 --- /dev/null +++ b/src/features/character/charManifest.ts @@ -0,0 +1 @@ +export { charManifest, type CharManifest, type CharacterPoseId } from './charManifest.generated' diff --git a/src/features/character/characterAppearance.ts b/src/features/character/characterAppearance.ts new file mode 100644 index 0000000..cf629a7 --- /dev/null +++ b/src/features/character/characterAppearance.ts @@ -0,0 +1,14 @@ +import { charManifest } from './charManifest' + +export type CharacterAppearance = { + fills: Record + toggles: Record +} + +export function defaultCharacterAppearance(): CharacterAppearance { + const defaults = charManifest.appearanceDefaults + return { + fills: { ...defaults.fills }, + toggles: { ...defaults.toggles }, + } +} diff --git a/src/features/character/characterAppearanceService.ts b/src/features/character/characterAppearanceService.ts new file mode 100644 index 0000000..2e2cd30 --- /dev/null +++ b/src/features/character/characterAppearanceService.ts @@ -0,0 +1,87 @@ +import { getDb } from '../../db/schema' +import { + defaultCharacterAppearance, + type CharacterAppearance, +} from './characterAppearance' + +export const CHARACTER_APPEARANCE_ROW_ID = 'default' as const +export const CHARACTER_APPEARANCE_CHANGED_EVENT = 'mentell.character.appearance.changed' + +let cache: CharacterAppearance | null = null +let loadPromise: Promise | null = null +let saveTimer: ReturnType | undefined + +function mergeWithDefaults(stored: CharacterAppearance): CharacterAppearance { + const defaults = defaultCharacterAppearance() + return { + fills: { ...defaults.fills, ...stored.fills }, + toggles: { ...defaults.toggles, ...stored.toggles }, + } +} + +function notifyAppearanceChanged() { + window.dispatchEvent(new CustomEvent(CHARACTER_APPEARANCE_CHANGED_EVENT)) +} + +export function getCachedCharacterAppearance(): CharacterAppearance | null { + return cache +} + +export async function loadCharacterAppearance(): Promise { + if (cache) return cache + if (loadPromise) return loadPromise + + loadPromise = (async () => { + const row = await getDb().characterAppearance.get(CHARACTER_APPEARANCE_ROW_ID) + if (row) { + cache = mergeWithDefaults({ fills: row.fills, toggles: row.toggles }) + } else { + cache = defaultCharacterAppearance() + } + return cache + })() + + return loadPromise +} + +export async function saveCharacterAppearance(appearance: CharacterAppearance) { + cache = appearance + const row = { + id: CHARACTER_APPEARANCE_ROW_ID, + updatedAt: Date.now(), + fills: appearance.fills, + toggles: appearance.toggles, + } + await getDb().characterAppearance.put(row) + notifyAppearanceChanged() +} + +export function scheduleSaveCharacterAppearance(appearance: CharacterAppearance) { + cache = appearance + if (saveTimer !== undefined) clearTimeout(saveTimer) + saveTimer = setTimeout(() => { + saveTimer = undefined + void saveCharacterAppearance(appearance) + }, 400) +} + +export async function resetCharacterAppearance(): Promise { + const next = defaultCharacterAppearance() + if (saveTimer !== undefined) { + clearTimeout(saveTimer) + saveTimer = undefined + } + await saveCharacterAppearance(next) + return next +} + +export async function clearCharacterAppearance() { + if (saveTimer !== undefined) { + clearTimeout(saveTimer) + saveTimer = undefined + } + cache = null + loadPromise = null + await getDb().characterAppearance.clear() + notifyAppearanceChanged() +} diff --git a/src/features/character/characterBlink.ts b/src/features/character/characterBlink.ts new file mode 100644 index 0000000..7e24ef5 --- /dev/null +++ b/src/features/character/characterBlink.ts @@ -0,0 +1,69 @@ +import { useEffect } from 'react' +import { charManifest } from './charManifest' +import { shouldReduceMotion } from '../../shared/motion/useMotionPrefs' + +function setLayerVisible(svg: SVGSVGElement, id: string, show: boolean) { + const el = svg.getElementById(id) + if (el instanceof SVGElement) { + el.style.display = show ? 'inline' : 'none' + } +} + +/** Default: open-eye *_BLK layers on, closed-eye BLK layer off. */ +export function applyBlinkOpenState(svg: SVGSVGElement) { + const blink = charManifest.blink + if (!blink) return + for (const id of blink.openLayerIds) setLayerVisible(svg, id, true) + setLayerVisible(svg, blink.closedLayerId, false) +} + +/** Blink frame: hide *_BLK, show BLK overlay. */ +export function applyBlinkClosedState(svg: SVGSVGElement) { + const blink = charManifest.blink + if (!blink) return + for (const id of blink.openLayerIds) setLayerVisible(svg, id, false) + setLayerVisible(svg, blink.closedLayerId, true) +} + +export function useCharacterBlink( + svgRef: React.RefObject, + svgGeneration = 0, +) { + useEffect(() => { + if (svgGeneration < 0) return + const blink = charManifest.blink + if (!blink || shouldReduceMotion()) return + + const svg = svgRef.current + if (!svg) return + + let cancelled = false + let intervalTimer: ReturnType | undefined + let closeTimer: ReturnType | undefined + + const scheduleNext = () => { + const wait = + blink.minIntervalMs + + Math.random() * (blink.maxIntervalMs - blink.minIntervalMs) + intervalTimer = setTimeout(() => { + if (cancelled) return + applyBlinkClosedState(svg) + closeTimer = setTimeout(() => { + if (cancelled) return + applyBlinkOpenState(svg) + scheduleNext() + }, blink.closedDurationMs) + }, wait) + } + + applyBlinkOpenState(svg) + scheduleNext() + + return () => { + cancelled = true + if (intervalTimer !== undefined) clearTimeout(intervalTimer) + if (closeTimer !== undefined) clearTimeout(closeTimer) + applyBlinkOpenState(svg) + } + }, [svgRef, svgGeneration]) +} diff --git a/src/features/character/characterPoses.ts b/src/features/character/characterPoses.ts new file mode 100644 index 0000000..7fe5448 --- /dev/null +++ b/src/features/character/characterPoses.ts @@ -0,0 +1,40 @@ +import type { CharacterPoseId } from './charManifest' + +export type ArmPose = { armL: number; armR: number } + +export const CHARACTER_POSES: Record = { + idle: { armL: 0, armR: 0 }, + present: { armL: -35, armR: 35 }, + think: { armL: -52, armR: 18 }, + write: { armL: -15, armR: 32 }, + shop: { armL: 10, armR: -28 }, + wave: { armL: -58, armR: 6 }, +} + +export const POSE_LABELS: Record = { + idle: 'Idle', + present: 'Present', + think: 'Think', + write: 'Write', + shop: 'Shop', + wave: 'Wave', +} + +export const ALL_POSE_IDS = Object.keys(CHARACTER_POSES) as CharacterPoseId[] + +export function poseForPathname(pathname: string): CharacterPoseId { + switch (pathname) { + case '/': + return 'present' + case '/week': + return 'think' + case '/notes': + return 'write' + case '/shop': + return 'shop' + case '/settings': + return 'idle' + default: + return 'idle' + } +} diff --git a/src/features/character/lab/LabSwitch.tsx b/src/features/character/lab/LabSwitch.tsx new file mode 100644 index 0000000..1c86b7b --- /dev/null +++ b/src/features/character/lab/LabSwitch.tsx @@ -0,0 +1,30 @@ +export function LabSwitch({ + label, + checked, + onChange, +}: { + label: string + checked: boolean + onChange: (on: boolean) => void +}) { + return ( + + ) +} diff --git a/src/features/character/lab/OptionDial.tsx b/src/features/character/lab/OptionDial.tsx new file mode 100644 index 0000000..bd39865 --- /dev/null +++ b/src/features/character/lab/OptionDial.tsx @@ -0,0 +1,44 @@ +export function OptionDial({ + label, + options, + valueIndex, + onChange, + segmentLabels, +}: { + label: string + options: { id: string; label: string }[] + valueIndex: number + onChange: (optionId: string) => void + segmentLabels?: string[] +}) { + return ( +
+
{label}
+
+ {options.map((opt, i) => { + const pressed = i === valueIndex + const seg = segmentLabels?.[i] ?? String(i + 1) + return ( + + ) + })} +
+
+ ) +} diff --git a/src/features/character/useArmPoseAnimation.ts b/src/features/character/useArmPoseAnimation.ts new file mode 100644 index 0000000..1de8044 --- /dev/null +++ b/src/features/character/useArmPoseAnimation.ts @@ -0,0 +1,88 @@ +import { animate } from 'framer-motion' +import { useEffect, useRef } from 'react' +import { charManifest } from './charManifest' +import type { ArmPose } from './characterPoses' +import { motionDuration } from '../../shared/motion/useMotionPrefs' + +function shoulderPivot(el: SVGGElement): { cx: number; cy: number } { + const box = el.getBBox() + return { cx: box.x + box.width / 2, cy: box.y } +} + +function setRotate(el: SVGElement, deg: number, cx: number, cy: number) { + el.setAttribute('transform', `rotate(${deg} ${cx} ${cy})`) +} + +export function useArmPoseAnimation( + svgRef: React.RefObject, + pose: ArmPose, + svgGeneration = 0, +) { + const prevPose = useRef(pose) + + useEffect(() => { + prevPose.current = { armL: 0, armR: 0 } + }, [svgGeneration]) + + useEffect(() => { + const svg = svgRef.current + if (!svg) return + + const armL = svg.getElementById(charManifest.arms.armL.jointId) as SVGGElement | null + const armR = svg.getElementById(charManifest.arms.armR.jointId) as SVGGElement | null + if (!armL || !armR) return + + const pivotL = shoulderPivot(armL) + const pivotR = shoulderPivot(armR) + + const leftSleeves = charManifest.arms.armL.sleeveIds + .map((id) => svg.getElementById(id)) + .filter((el): el is SVGElement => el instanceof SVGElement) + const rightSleeves = charManifest.arms.armR.sleeveIds + .map((id) => svg.getElementById(id)) + .filter((el): el is SVGElement => el instanceof SVGElement) + + const apply = (degL: number, degR: number) => { + setRotate(armL, degL, pivotL.cx, pivotL.cy) + setRotate(armR, degR, pivotR.cx, pivotR.cy) + for (const el of leftSleeves) setRotate(el, degL, pivotL.cx, pivotL.cy) + for (const el of rightSleeves) setRotate(el, degR, pivotR.cx, pivotR.cy) + } + + const duration = motionDuration(0.4) + + if (duration === 0) { + apply(pose.armL, pose.armR) + prevPose.current = pose + return + } + + const fromL = prevPose.current.armL + const fromR = prevPose.current.armR + let currentL = fromL + let currentR = fromR + + const controlsL = animate(fromL, pose.armL, { + duration, + ease: [0.4, 0, 0.2, 1], + onUpdate: (v) => { + currentL = v + apply(currentL, currentR) + }, + }) + const controlsR = animate(fromR, pose.armR, { + duration, + ease: [0.4, 0, 0.2, 1], + onUpdate: (v) => { + currentR = v + apply(currentL, currentR) + }, + }) + + prevPose.current = pose + return () => { + controlsL.stop() + controlsR.stop() + } + }, [svgRef, pose.armL, pose.armR, pose, svgGeneration]) +} diff --git a/src/features/character/useCharacterAppearance.ts b/src/features/character/useCharacterAppearance.ts new file mode 100644 index 0000000..e8e4ca0 --- /dev/null +++ b/src/features/character/useCharacterAppearance.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react' +import { + defaultCharacterAppearance, + type CharacterAppearance, +} from './characterAppearance' +import { + CHARACTER_APPEARANCE_CHANGED_EVENT, + getCachedCharacterAppearance, + loadCharacterAppearance, + resetCharacterAppearance, + scheduleSaveCharacterAppearance, +} from './characterAppearanceService' + +export function useCharacterAppearance() { + const [appearance, setAppearanceState] = useState( + () => getCachedCharacterAppearance() ?? defaultCharacterAppearance(), + ) + const [ready, setReady] = useState(() => getCachedCharacterAppearance() !== null) + + useEffect(() => { + let cancelled = false + void loadCharacterAppearance().then((loaded) => { + if (cancelled) return + setAppearanceState(loaded) + setReady(true) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + const onExternalChange = () => { + const cached = getCachedCharacterAppearance() + if (cached) setAppearanceState(cached) + } + window.addEventListener(CHARACTER_APPEARANCE_CHANGED_EVENT, onExternalChange) + return () => + window.removeEventListener(CHARACTER_APPEARANCE_CHANGED_EVENT, onExternalChange) + }, []) + + const setAppearance = useCallback( + (next: CharacterAppearance | ((prev: CharacterAppearance) => CharacterAppearance)) => { + setAppearanceState((prev) => { + const resolved = typeof next === 'function' ? next(prev) : next + scheduleSaveCharacterAppearance(resolved) + return resolved + }) + }, + [], + ) + + const resetAppearance = useCallback(async () => { + const next = await resetCharacterAppearance() + setAppearanceState(next) + }, []) + + return { appearance, setAppearance, resetAppearance, ready } +} diff --git a/src/features/debug/DebugPanel.tsx b/src/features/debug/DebugPanel.tsx index 3fc6583..9987e97 100644 --- a/src/features/debug/DebugPanel.tsx +++ b/src/features/debug/DebugPanel.tsx @@ -678,6 +678,7 @@ export function DebugPanel() { await database.notes.clear() await database.stickies.clear() await database.packages.clear() + await database.characterAppearance.clear() localStorage.removeItem(scopedStorageKey('mentell.score.total')) localStorage.removeItem(scopedStorageKey('mentell.score.streak')) localStorage.removeItem(scopedStorageKey('mentell.score.lastDay')) diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 22b894d..456fd2d 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -7,6 +7,7 @@ import { browserTimezone } from '../../shared/settings/appSettings' import { AccountSyncSection } from './AccountSyncSection' import { SettingsAccountFeatures } from './SettingsAccountFeatures' import { SettingsDebugCloudSection } from './SettingsDebugCloudSection' +import { DeskCharacterLayout } from '../character/DeskCharacterLayout' const WEEKDAY_OPTIONS = [ { value: 0, label: 'Sunday' }, @@ -48,6 +49,7 @@ export function SettingsPage() { }, [settings.globalName, settings.globalNameManuallySet]) return ( +
Settings
@@ -192,5 +194,6 @@ export function SettingsPage() {
+
) } diff --git a/src/shared/account/accountDataService.ts b/src/shared/account/accountDataService.ts index c7691cb..0b1ee64 100644 --- a/src/shared/account/accountDataService.ts +++ b/src/shared/account/accountDataService.ts @@ -1,6 +1,7 @@ import { deleteUser } from 'firebase/auth' import { collection, deleteDoc, doc, getDocs } from 'firebase/firestore' import { getDb } from '../../db/schema' +import { clearCharacterAppearance } from '../../features/character/characterAppearanceService' import { formatShareCode } from '../../features/share/shareLinkUrl' import { SCORE_CHANGED_EVENT } from '../../features/score/scoreEvents' import { getFirebaseAuth, getFirebaseFirestore } from '../firebase/firebaseApp' @@ -32,6 +33,7 @@ export async function clearLocalJournalData() { getDb().stickies.clear(), getDb().packages.clear(), ]) + await clearCharacterAppearance() for (const key of SCORE_KEYS) localStorage.removeItem(key) window.dispatchEvent(new CustomEvent(SCORE_CHANGED_EVENT)) } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 2ba57b3..4b9e1eb 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,4 +1,9 @@ /// + +declare module '*.svg?raw' { + const content: string + export default content +} /// interface ImportMetaEnv { From 8e875cdff715270fd1154b45a90a0f69173a2718 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 09:31:17 +0000 Subject: [PATCH 02/14] Add shop cosmetic catalog foundation Co-authored-by: Kiya Rose Ren-Miyakari --- asset/shop/cursor.svg | 32 +++ asset/shop/shoppe-items.json | 174 +++++++++++++++ asset/shop/stamp.svg | 44 ++++ .../character/characterAppearanceService.ts | 10 + src/features/shop/shopCatalog.ts | 204 ++++++++++++++++++ src/features/shop/shopCosmetics.tsx | 193 +++++++++++++++++ src/features/shop/shopInventory.ts | 126 +++++++++++ 7 files changed, 783 insertions(+) create mode 100644 asset/shop/cursor.svg create mode 100644 asset/shop/shoppe-items.json create mode 100644 asset/shop/stamp.svg create mode 100644 src/features/shop/shopCatalog.ts create mode 100644 src/features/shop/shopCosmetics.tsx create mode 100644 src/features/shop/shopInventory.ts diff --git a/asset/shop/cursor.svg b/asset/shop/cursor.svg new file mode 100644 index 0000000..3af240a --- /dev/null +++ b/asset/shop/cursor.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/asset/shop/shoppe-items.json b/asset/shop/shoppe-items.json new file mode 100644 index 0000000..7543fdb --- /dev/null +++ b/asset/shop/shoppe-items.json @@ -0,0 +1,174 @@ +{ + "version": 1, + "items": [ + { + "id": "theme-sunrise-paper", + "type": "theme", + "name": "Sunrise Paper", + "description": "Soft amber desk tones with warm paper for daylight journaling.", + "cost": 280, + "preview": "/asset/light.png", + "theme": { + "light": { + "deskBg": "#d6b08a", + "paperBg": "#fff2db", + "paperBorder": "rgba(130, 78, 35, 0.2)", + "accent": "#cf552a", + "overlay": "radial-gradient(circle at 18% 12%, rgba(255, 229, 168, 0.32), transparent 58%)" + }, + "dark": { + "deskBg": "#1c1620", + "paperBg": "#2a202b", + "paperBorder": "rgba(240, 168, 108, 0.24)", + "accent": "#ff8d4d", + "overlay": "radial-gradient(circle at 15% 20%, rgba(255, 165, 98, 0.16), transparent 62%)" + } + } + }, + { + "id": "theme-aurora-night", + "type": "theme", + "name": "Aurora Night", + "description": "A cooler desk tint for late-night writing sessions.", + "cost": 320, + "preview": "/asset/dark.png", + "theme": { + "light": { + "deskBg": "#9fb2c7", + "paperBg": "#eef7ff", + "paperBorder": "rgba(46, 88, 132, 0.22)", + "accent": "#2a6db5", + "overlay": "radial-gradient(circle at 82% 10%, rgba(183, 237, 255, 0.34), transparent 56%)" + }, + "dark": { + "deskBg": "#0f1728", + "paperBg": "#17243a", + "paperBorder": "rgba(147, 212, 255, 0.24)", + "accent": "#5fa4ff", + "overlay": "radial-gradient(circle at 80% 12%, rgba(95, 164, 255, 0.2), transparent 62%)" + } + } + }, + { + "id": "theme-rose-noir", + "type": "theme", + "name": "Rose Noir", + "description": "Muted burgundy ink vibes with softer high-contrast accents.", + "cost": 360, + "preview": "/asset/mentell-icon.png", + "theme": { + "light": { + "deskBg": "#c9a0a7", + "paperBg": "#fff0f4", + "paperBorder": "rgba(118, 47, 66, 0.24)", + "accent": "#b1345a", + "overlay": "radial-gradient(circle at 10% 88%, rgba(255, 180, 204, 0.34), transparent 56%)" + }, + "dark": { + "deskBg": "#1c1219", + "paperBg": "#2a1722", + "paperBorder": "rgba(235, 140, 176, 0.24)", + "accent": "#f072a3", + "overlay": "radial-gradient(circle at 15% 84%, rgba(240, 114, 163, 0.19), transparent 62%)" + } + } + }, + { + "id": "stamp-gotcha", + "type": "stamp", + "name": "Gotcha Stamp", + "description": "Bold red stamp inspired by your sketched GOTCHA look.", + "cost": 210, + "preview": "/asset/stamp.png", + "stamp": { + "text": "Gotcha", + "ink": "#cf2040", + "outline": "#9d1730", + "textColor": "#9d1730", + "tiltDeg": -14, + "opacity": 0.24 + } + }, + { + "id": "stamp-airmail", + "type": "stamp", + "name": "Airmail Stamp", + "description": "Postal blue variant for outgoing reflections.", + "cost": 170, + "preview": "/asset/stamp.png", + "stamp": { + "text": "Airmail", + "ink": "#266ac9", + "outline": "#1d4e94", + "textColor": "#1d4e94", + "tiltDeg": -12, + "opacity": 0.25 + } + }, + { + "id": "stamp-secret", + "type": "stamp", + "name": "Secret Stamp", + "description": "Stealthy violet stamp with softer letterpress text.", + "cost": 190, + "preview": "/asset/stamp.png", + "stamp": { + "text": "Secret", + "ink": "#6f4eb3", + "outline": "#4b347a", + "textColor": "#4b347a", + "tiltDeg": -10, + "opacity": 0.24 + } + }, + { + "id": "cursor-comet", + "type": "cursor", + "name": "Comet Cursor", + "description": "Crisp sky-blue cursor set for default, pointer, and text.", + "cost": 160, + "preview": "/asset/projector.png", + "cursor": { + "primary": "#78c5ff", + "secondary": "#f0f8ff", + "outline": "#1a4e83", + "textPrimary": "#5ab8ff", + "hotspot": { + "default": [3, 3], + "pointer": [4, 2], + "text": [8, 14] + } + } + }, + { + "id": "cursor-ember", + "type": "cursor", + "name": "Ember Cursor", + "description": "Warm amber cursor set with high contrast outlines.", + "cost": 160, + "preview": "/asset/envelope.png", + "cursor": { + "primary": "#ff8f4a", + "secondary": "#fff3dc", + "outline": "#7f3b1b", + "textPrimary": "#ffb45b", + "hotspot": { + "default": [3, 3], + "pointer": [4, 2], + "text": [8, 14] + } + } + }, + { + "id": "image-gift-print", + "type": "image", + "name": "Gift Print", + "description": "A collectible image item example for future inventory drops.", + "cost": 120, + "preview": "/asset/gift_small.png", + "image": { + "url": "/asset/gift_large.png" + } + } + ] +} diff --git a/asset/shop/stamp.svg b/asset/shop/stamp.svg new file mode 100644 index 0000000..04ad21c --- /dev/null +++ b/asset/shop/stamp.svg @@ -0,0 +1,44 @@ + + + + + + Stamp + + + diff --git a/src/features/character/characterAppearanceService.ts b/src/features/character/characterAppearanceService.ts index 2e2cd30..5687f8b 100644 --- a/src/features/character/characterAppearanceService.ts +++ b/src/features/character/characterAppearanceService.ts @@ -1,4 +1,5 @@ import { getDb } from '../../db/schema' +import { notifyLocalDataChanged } from '../../shared/sync/localDataEvents' import { defaultCharacterAppearance, type CharacterAppearance, @@ -46,6 +47,7 @@ export async function loadCharacterAppearance(): Promise { export async function saveCharacterAppearance(appearance: CharacterAppearance) { cache = appearance + loadPromise = Promise.resolve(cache) const row = { id: CHARACTER_APPEARANCE_ROW_ID, updatedAt: Date.now(), @@ -54,6 +56,7 @@ export async function saveCharacterAppearance(appearance: CharacterAppearance) { } await getDb().characterAppearance.put(row) notifyAppearanceChanged() + notifyLocalDataChanged() } export function scheduleSaveCharacterAppearance(appearance: CharacterAppearance) { @@ -84,4 +87,11 @@ export async function clearCharacterAppearance() { loadPromise = null await getDb().characterAppearance.clear() notifyAppearanceChanged() + notifyLocalDataChanged() +} + +export function applyCharacterAppearanceFromCloud(appearance: CharacterAppearance) { + cache = mergeWithDefaults(appearance) + loadPromise = Promise.resolve(cache) + notifyAppearanceChanged() } diff --git a/src/features/shop/shopCatalog.ts b/src/features/shop/shopCatalog.ts new file mode 100644 index 0000000..9871d60 --- /dev/null +++ b/src/features/shop/shopCatalog.ts @@ -0,0 +1,204 @@ +import catalogJson from '../../../asset/shop/shoppe-items.json?raw' + +export type ShopItemType = 'image' | 'theme' | 'stamp' | 'cursor' + +type ShopItemBase = { + id: string + type: ShopItemType + name: string + description: string + cost: number + preview?: string +} + +export type ThemePalette = { + deskBg: string + paperBg?: string + paperBorder?: string + accent?: string + overlay?: string +} + +export type ThemeItem = ShopItemBase & { + type: 'theme' + theme: { + light: ThemePalette + dark: ThemePalette + } +} + +export type StampItem = ShopItemBase & { + type: 'stamp' + stamp: { + text: string + ink: string + outline: string + textColor?: string + tiltDeg?: number + opacity?: number + } +} + +export type CursorItem = ShopItemBase & { + type: 'cursor' + cursor: { + primary: string + secondary: string + outline: string + textPrimary?: string + hotspot?: { + default?: [number, number] + pointer?: [number, number] + text?: [number, number] + } + } +} + +export type ImageItem = ShopItemBase & { + type: 'image' + image: { + url: string + } +} + +export type ShopCatalogItem = ThemeItem | StampItem | CursorItem | ImageItem + +export type ShopCatalog = { + version: number + items: ShopCatalogItem[] +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function asString(value: unknown, fallback = '') { + return typeof value === 'string' ? value : fallback +} + +function asNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function asTuple(value: unknown): [number, number] | undefined { + if (!Array.isArray(value) || value.length !== 2) return undefined + const first = Number(value[0]) + const second = Number(value[1]) + if (!Number.isFinite(first) || !Number.isFinite(second)) return undefined + return [Math.trunc(first), Math.trunc(second)] +} + +function parseThemePalette(value: unknown): ThemePalette { + const row = asRecord(value) + if (!row) return { deskBg: '' } + return { + deskBg: asString(row.deskBg), + paperBg: asString(row.paperBg) || undefined, + paperBorder: asString(row.paperBorder) || undefined, + accent: asString(row.accent) || undefined, + overlay: asString(row.overlay) || undefined, + } +} + +function parseItem(value: unknown): ShopCatalogItem | null { + const row = asRecord(value) + if (!row) return null + const type = asString(row.type) as ShopItemType + const base: ShopItemBase = { + id: asString(row.id), + type, + name: asString(row.name), + description: asString(row.description), + cost: Math.max(0, Math.trunc(asNumber(row.cost))), + preview: asString(row.preview) || undefined, + } + if (!base.id || !base.name || !base.description) return null + if (type === 'theme') { + const theme = asRecord(row.theme) + if (!theme) return null + const parsed: ThemeItem = { + ...base, + type: 'theme', + theme: { + light: parseThemePalette(theme.light), + dark: parseThemePalette(theme.dark), + }, + } + if (!parsed.theme.light.deskBg || !parsed.theme.dark.deskBg) return null + return parsed + } + if (type === 'stamp') { + const stamp = asRecord(row.stamp) + if (!stamp) return null + const parsed: StampItem = { + ...base, + type: 'stamp', + stamp: { + text: asString(stamp.text), + ink: asString(stamp.ink), + outline: asString(stamp.outline), + textColor: asString(stamp.textColor) || undefined, + tiltDeg: asNumber(stamp.tiltDeg), + opacity: asNumber(stamp.opacity), + }, + } + if (!parsed.stamp.text || !parsed.stamp.ink || !parsed.stamp.outline) return null + return parsed + } + if (type === 'cursor') { + const cursor = asRecord(row.cursor) + if (!cursor) return null + const hotspot = asRecord(cursor.hotspot) + const parsed: CursorItem = { + ...base, + type: 'cursor', + cursor: { + primary: asString(cursor.primary), + secondary: asString(cursor.secondary), + outline: asString(cursor.outline), + textPrimary: asString(cursor.textPrimary) || undefined, + hotspot: { + default: asTuple(hotspot?.default), + pointer: asTuple(hotspot?.pointer), + text: asTuple(hotspot?.text), + }, + }, + } + if (!parsed.cursor.primary || !parsed.cursor.secondary || !parsed.cursor.outline) return null + return parsed + } + if (type === 'image') { + const image = asRecord(row.image) + if (!image) return null + const parsed: ImageItem = { + ...base, + type: 'image', + image: { url: asString(image.url) }, + } + if (!parsed.image.url) return null + return parsed + } + return null +} + +let catalogCache: ShopCatalog | null = null + +export function loadShopCatalog(): ShopCatalog { + if (catalogCache) return catalogCache + let parsed: unknown = null + try { + parsed = JSON.parse(catalogJson) + } catch { + catalogCache = { version: 1, items: [] } + return catalogCache + } + const root = asRecord(parsed) + const rawItems = Array.isArray(root?.items) ? root.items : [] + const items = rawItems.map(parseItem).filter((row): row is ShopCatalogItem => row !== null) + catalogCache = { + version: Math.max(1, Math.trunc(asNumber(root?.version, 1))), + items, + } + return catalogCache +} diff --git a/src/features/shop/shopCosmetics.tsx b/src/features/shop/shopCosmetics.tsx new file mode 100644 index 0000000..ab1a918 --- /dev/null +++ b/src/features/shop/shopCosmetics.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useState } from 'react' +import cursorTemplateSvg from '../../../asset/shop/cursor.svg?raw' +import stampTemplateSvg from '../../../asset/shop/stamp.svg?raw' +import { publicUrl } from '../../shared/publicUrl' +import { useTheme } from '../../shared/theme/useTheme' +import { + loadShopCatalog, + type CursorItem, + type ShopCatalogItem, + type StampItem, + type ThemeItem, +} from './shopCatalog' +import { + loadShopInventory, + subscribeShopInventory, + type ShopInventory, +} from './shopInventory' + +type CursorContext = 'default' | 'pointer' | 'text' + +const FALLBACK_STAMP = publicUrl('/asset/stamp.png') + +function findEquippedItem( + items: ShopCatalogItem[], + itemType: T['type'], + id: string | null, +): T | null { + if (!id) return null + const item = items.find((entry) => entry.id === id && entry.type === itemType) + return (item as T | undefined) ?? null +} + +function serializeSvgElement(svg: SVGSVGElement) { + const raw = new XMLSerializer().serializeToString(svg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(raw)}` +} + +function setThemeCssVar(name: string, value?: string) { + if (value) document.documentElement.style.setProperty(name, value) + else document.documentElement.style.removeProperty(name) +} + +function applyThemeCosmetics(mode: 'light' | 'dark', themeItem: ThemeItem | null) { + if (!themeItem) { + setThemeCssVar('--desk-bg') + setThemeCssVar('--paper-bg') + setThemeCssVar('--paper-border') + setThemeCssVar('--accent') + setThemeCssVar('--shop-theme-overlay') + return + } + const palette = mode === 'dark' ? themeItem.theme.dark : themeItem.theme.light + setThemeCssVar('--desk-bg', palette.deskBg) + setThemeCssVar('--paper-bg', palette.paperBg) + setThemeCssVar('--paper-border', palette.paperBorder) + setThemeCssVar('--accent', palette.accent) + setThemeCssVar('--shop-theme-overlay', palette.overlay) +} + +function svgElementById(doc: Document, id: string) { + const el = doc.getElementById(id) + return el instanceof SVGElement ? el : null +} + +function renderStampDataUri(item: StampItem): string { + const doc = new DOMParser().parseFromString(stampTemplateSvg, 'image/svg+xml') + const svg = doc.documentElement + if (!(svg instanceof SVGSVGElement)) return FALLBACK_STAMP + const stampRoot = svgElementById(doc, 'stamp-root') + const border = svgElementById(doc, 'stamp-border') + const inner = svgElementById(doc, 'stamp-inner') + const text = svgElementById(doc, 'stamp-text') + + if (stampRoot) { + const tilt = Number.isFinite(item.stamp.tiltDeg) ? item.stamp.tiltDeg : -14 + stampRoot.setAttribute('transform', `rotate(${tilt} 128 128)`) + stampRoot.style.opacity = String( + Number.isFinite(item.stamp.opacity) ? item.stamp.opacity : 0.24, + ) + } + if (border) { + border.setAttribute('stroke', item.stamp.outline) + border.setAttribute('fill', item.stamp.ink) + border.style.opacity = '0.08' + } + if (inner) { + inner.setAttribute('stroke', item.stamp.outline) + } + if (text) { + text.setAttribute('fill', item.stamp.textColor ?? item.stamp.outline) + text.textContent = item.stamp.text + } + return serializeSvgElement(svg) +} + +function renderCursorCssValue(item: CursorItem, context: CursorContext): string | null { + const doc = new DOMParser().parseFromString(cursorTemplateSvg, 'image/svg+xml') + const svg = doc.documentElement + if (!(svg instanceof SVGSVGElement)) return null + + const contextLayers = svg.querySelectorAll('g[data-context]') + contextLayers.forEach((layer) => { + const active = layer.dataset.context === context + layer.style.display = active ? 'inline' : 'none' + }) + + const fills = svg.querySelectorAll('[data-fill]') + fills.forEach((el) => { + const role = el.dataset.fill + if (role === 'primary') el.setAttribute('fill', item.cursor.primary) + if (role === 'secondary') el.setAttribute('fill', item.cursor.secondary) + if (role === 'outline') el.setAttribute('fill', item.cursor.outline) + if (role === 'text') el.setAttribute('fill', item.cursor.textPrimary ?? item.cursor.primary) + }) + + const strokes = svg.querySelectorAll('[data-stroke]') + strokes.forEach((el) => { + const role = el.dataset.stroke + if (role === 'outline') el.setAttribute('stroke', item.cursor.outline) + if (role === 'primary') el.setAttribute('stroke', item.cursor.primary) + }) + + const hotspot = item.cursor.hotspot?.[context] + const defaultHotspot: Record = { + default: [3, 3], + pointer: [4, 2], + text: [8, 14], + } + const [hx, hy] = hotspot ?? defaultHotspot[context] + return `url("${serializeSvgElement(svg)}") ${hx} ${hy}, auto` +} + +function applyCursorCosmetics(cursorItem: CursorItem | null) { + if (!cursorItem) { + document.documentElement.style.removeProperty('--shop-cursor-default') + document.documentElement.style.removeProperty('--shop-cursor-pointer') + document.documentElement.style.removeProperty('--shop-cursor-text') + return + } + const base = renderCursorCssValue(cursorItem, 'default') + const pointer = renderCursorCssValue(cursorItem, 'pointer') + const text = renderCursorCssValue(cursorItem, 'text') + if (base) document.documentElement.style.setProperty('--shop-cursor-default', base) + if (pointer) document.documentElement.style.setProperty('--shop-cursor-pointer', pointer) + if (text) document.documentElement.style.setProperty('--shop-cursor-text', text) +} + +export function useShopInventoryState() { + const [inventory, setInventory] = useState(() => loadShopInventory()) + useEffect(() => subscribeShopInventory((next) => setInventory(next)), []) + return inventory +} + +export function useShopCatalogState() { + return useMemo(() => loadShopCatalog(), []) +} + +export function isOwned(inventory: ShopInventory, itemId: string) { + return inventory.ownedItemIds.includes(itemId) +} + +export function useEquippedStampImage() { + const catalog = useShopCatalogState() + const inventory = useShopInventoryState() + return useMemo(() => { + const stamp = findEquippedItem(catalog.items, 'stamp', inventory.equipped.stampId) + return stamp ? renderStampDataUri(stamp) : FALLBACK_STAMP + }, [catalog.items, inventory.equipped.stampId]) +} + +export function ShopCosmeticEffects() { + const { mode } = useTheme() + const catalog = useShopCatalogState() + const inventory = useShopInventoryState() + const equippedTheme = useMemo( + () => findEquippedItem(catalog.items, 'theme', inventory.equipped.themeId), + [catalog.items, inventory.equipped.themeId], + ) + const equippedCursor = useMemo( + () => findEquippedItem(catalog.items, 'cursor', inventory.equipped.cursorId), + [catalog.items, inventory.equipped.cursorId], + ) + + useEffect(() => { + applyThemeCosmetics(mode, equippedTheme) + }, [mode, equippedTheme]) + + useEffect(() => { + applyCursorCosmetics(equippedCursor) + }, [equippedCursor]) + + return null +} diff --git a/src/features/shop/shopInventory.ts b/src/features/shop/shopInventory.ts new file mode 100644 index 0000000..3ae9c0e --- /dev/null +++ b/src/features/shop/shopInventory.ts @@ -0,0 +1,126 @@ +import { scopedStorageKey } from '../../shared/storage/storageScope' +import { notifyLocalDataChanged } from '../../shared/sync/localDataEvents' + +const SHOP_INVENTORY_KEY = scopedStorageKey('mentell.shop.inventory') +export const SHOP_INVENTORY_CHANGED_EVENT = 'mentell.shop.inventory.changed' + +export type EquippedShopItems = { + themeId: string | null + stampId: string | null + cursorId: string | null +} + +export type ShopInventory = { + ownedItemIds: string[] + equipped: EquippedShopItems + updatedAt: number +} + +type WriteOptions = { + notifySync?: boolean + preserveUpdatedAt?: boolean +} + +const DEFAULT_SHOP_INVENTORY: ShopInventory = { + ownedItemIds: [], + equipped: { + themeId: null, + stampId: null, + cursorId: null, + }, + updatedAt: 0, +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function sanitizeInventory(input: unknown): ShopInventory { + const row = asRecord(input) + const equippedRow = asRecord(row?.equipped) + const owned = Array.isArray(row?.ownedItemIds) ? row?.ownedItemIds : [] + const ownedItemIds = [...new Set(owned.filter((id): id is string => typeof id === 'string'))] + const updatedAtRaw = Number(row?.updatedAt) + return { + ownedItemIds, + equipped: { + themeId: typeof equippedRow?.themeId === 'string' ? equippedRow.themeId : null, + stampId: typeof equippedRow?.stampId === 'string' ? equippedRow.stampId : null, + cursorId: typeof equippedRow?.cursorId === 'string' ? equippedRow.cursorId : null, + }, + updatedAt: Number.isFinite(updatedAtRaw) ? Math.max(0, Math.trunc(updatedAtRaw)) : 0, + } +} + +function emitInventoryChanged(next: ShopInventory) { + window.dispatchEvent(new CustomEvent(SHOP_INVENTORY_CHANGED_EVENT, { detail: next })) +} + +function writeInventory(next: ShopInventory, options?: WriteOptions): ShopInventory { + const notifySync = options?.notifySync ?? true + const preserveUpdatedAt = options?.preserveUpdatedAt ?? false + const normalized: ShopInventory = { + ...next, + updatedAt: preserveUpdatedAt ? next.updatedAt : Date.now(), + } + localStorage.setItem(SHOP_INVENTORY_KEY, JSON.stringify(normalized)) + emitInventoryChanged(normalized) + if (notifySync) notifyLocalDataChanged() + return normalized +} + +export function loadShopInventory(): ShopInventory { + try { + const raw = localStorage.getItem(SHOP_INVENTORY_KEY) + if (!raw) return { ...DEFAULT_SHOP_INVENTORY } + return sanitizeInventory(JSON.parse(raw)) + } catch { + return { ...DEFAULT_SHOP_INVENTORY } + } +} + +export function updateShopInventory( + updater: (current: ShopInventory) => ShopInventory, + options?: WriteOptions, +) { + const current = loadShopInventory() + const next = sanitizeInventory(updater(current)) + return writeInventory(next, options) +} + +export function unlockShopItem(itemId: string) { + const clean = itemId.trim() + if (!clean) return loadShopInventory() + return updateShopInventory((current) => { + if (current.ownedItemIds.includes(clean)) return current + return { ...current, ownedItemIds: [...current.ownedItemIds, clean] } + }) +} + +export function equipShopItem(kind: 'theme' | 'stamp' | 'cursor', itemId: string | null) { + const clean = itemId?.trim() || null + return updateShopInventory((current) => ({ + ...current, + equipped: { + ...current.equipped, + ...(kind === 'theme' ? { themeId: clean } : null), + ...(kind === 'stamp' ? { stampId: clean } : null), + ...(kind === 'cursor' ? { cursorId: clean } : null), + }, + })) +} + +export function applyShopInventoryFromCloud(input: unknown) { + const parsed = sanitizeInventory(input) + return writeInventory(parsed, { notifySync: false, preserveUpdatedAt: true }) +} + +export function subscribeShopInventory(cb: (inventory: ShopInventory) => void) { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + cb(detail ?? loadShopInventory()) + } + window.addEventListener(SHOP_INVENTORY_CHANGED_EVENT, handler) + return () => window.removeEventListener(SHOP_INVENTORY_CHANGED_EVENT, handler) +} From 90211905b97c0e905a433fd97816de621fd11224 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 09:36:39 +0000 Subject: [PATCH 03/14] Wire character sync and shop cosmetics behavior Co-authored-by: Kiya Rose Ren-Miyakari --- asset/shop/README.md | 91 +++++++ src/App.tsx | 26 +- src/features/character/CharacterLabPage.tsx | 4 +- .../character/CharacterTabIconSync.tsx | 43 ++++ .../character/DeskCharacterLayout.tsx | 6 +- src/features/character/useArmPoseAnimation.ts | 52 +++- src/features/compose/SubmitAnimation.tsx | 7 +- src/features/legal/PrivacyPolicyPage.tsx | 11 +- src/features/shop/Shoppe.tsx | 224 +++++++++++++++++- src/features/shop/shopInventory.ts | 6 + src/index.css | 31 ++- src/shared/account/accountDataService.ts | 12 +- src/shared/sync/syncService.ts | 75 ++++++ 13 files changed, 564 insertions(+), 24 deletions(-) create mode 100644 asset/shop/README.md create mode 100644 src/features/character/CharacterTabIconSync.tsx diff --git a/asset/shop/README.md b/asset/shop/README.md new file mode 100644 index 0000000..a12d5ce --- /dev/null +++ b/asset/shop/README.md @@ -0,0 +1,91 @@ +# Shoppe item catalog format + +`shoppe-items.json` defines purchasable items for the in-app Shoppe. + +## Root format + +- `version` (number): schema version. +- `items` (array): list of shop item objects. + +## Shared item fields + +Each item includes: + +- `id` (string, unique) +- `type` (`image` | `theme` | `stamp` | `cursor`) +- `name` (string) +- `description` (string) +- `cost` (number, points) +- `preview` (optional string, usually `/asset/...`) + +## Item-specific fields + +### `theme` + +```json +{ + "type": "theme", + "theme": { + "light": { + "deskBg": "#...", + "paperBg": "#...", + "paperBorder": "rgba(...)", + "accent": "#...", + "overlay": "radial-gradient(...)" + }, + "dark": { + "deskBg": "#...", + "paperBg": "#...", + "paperBorder": "rgba(...)", + "accent": "#...", + "overlay": "radial-gradient(...)" + } + } +} +``` + +### `stamp` + +```json +{ + "type": "stamp", + "stamp": { + "text": "Gotcha", + "ink": "#...", + "outline": "#...", + "textColor": "#...", + "tiltDeg": -14, + "opacity": 0.24 + } +} +``` + +### `cursor` + +```json +{ + "type": "cursor", + "cursor": { + "primary": "#...", + "secondary": "#...", + "outline": "#...", + "textPrimary": "#...", + "hotspot": { + "default": [3, 3], + "pointer": [4, 2], + "text": [8, 14] + } + } +} +``` + +### `image` + +```json +{ + "type": "image", + "image": { + "url": "/asset/..." + } +} +``` diff --git a/src/App.tsx b/src/App.tsx index 84812db..352ad15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { Link, Navigate, Route } from 'react-router-dom' +import { Link, Navigate, Route, useLocation } from 'react-router-dom' import { AnimatedRoutes } from './shared/motion/AnimatedRoutes' import { AnimatePresence } from 'framer-motion' import { useTheme } from './shared/theme/useTheme' @@ -31,8 +31,13 @@ import { PrivacyPolicyPage } from './features/legal/PrivacyPolicyPage' import { CharacterLabPage } from './features/character/CharacterLabPage' import { CharacterNavIcon } from './features/character/CharacterNavIcon' import { DeskCharacterLayout } from './features/character/DeskCharacterLayout' +import { MentellCharacter } from './features/character/MentellCharacter' +import { poseForPathname } from './features/character/characterPoses' +import { useCharacterAppearance } from './features/character/useCharacterAppearance' +import { CharacterTabIconSync } from './features/character/CharacterTabIconSync' import { isFirebaseSyncEnabled, isShareLinksEnabled } from './shared/features/featureFlags' import { useAuthOptional } from './shared/firebase/AuthProvider' +import { ShopCosmeticEffects } from './features/shop/shopCosmetics' type ScoreChangeOptions = { deferOverlay?: boolean } @@ -109,6 +114,8 @@ function App() { return (
+ +
@@ -163,6 +170,9 @@ function TopBar({ score: ReturnType incomingHint: string | null }) { + const { pathname } = useLocation() + const menuPose = poseForPathname(pathname) + const { appearance: menuAppearance, ready: menuCharacterReady } = useCharacterAppearance() const { mode, toggle } = useTheme() const { settings } = useAppSettings() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) @@ -261,6 +271,20 @@ function TopBar({
+ {menuCharacterReady ? ( +
+
+
Desk companion
+
Current character
+
+ +
+ ) : null} +
Character lab
- Preview looks and poses. Changes save locally and update the desk mascot and Character - tab icon. + Preview looks and poses. Changes save locally, sync to cloud backup (when enabled), and + update both the desk mascot and browser tab icon.
diff --git a/src/features/character/CharacterTabIconSync.tsx b/src/features/character/CharacterTabIconSync.tsx new file mode 100644 index 0000000..49f6d2e --- /dev/null +++ b/src/features/character/CharacterTabIconSync.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react' +import headshotSvg from '../../../asset/char/headshot.svg?raw' +import { applyCharacterAppearance } from './applyCharacterAppearance' +import { defaultCharacterAppearance } from './characterAppearance' +import { useCharacterAppearance } from './useCharacterAppearance' + +function buildCharacterFaviconDataUrl(appearance: ReturnType) { + const parsed = new DOMParser().parseFromString(headshotSvg, 'image/svg+xml') + const svg = parsed.querySelector('svg') + if (!(svg instanceof SVGSVGElement)) return null + svg.setAttribute('width', '96') + svg.setAttribute('height', '96') + svg.setAttribute('viewBox', '0 0 100 100') + applyCharacterAppearance(svg, appearance) + const serialized = new XMLSerializer().serializeToString(svg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}` +} + +function ensureFaviconLink(): HTMLLinkElement { + const existing = document.querySelector( + 'link[rel="icon"], link[rel="shortcut icon"]', + ) + if (existing) return existing + const created = document.createElement('link') + created.rel = 'icon' + document.head.appendChild(created) + return created +} + +export function CharacterTabIconSync() { + const { appearance, ready } = useCharacterAppearance() + + useEffect(() => { + if (!ready) return + const favicon = ensureFaviconLink() + const dataUrl = buildCharacterFaviconDataUrl(appearance) + if (!dataUrl) return + favicon.type = 'image/svg+xml' + favicon.href = dataUrl + }, [appearance, ready]) + + return null +} diff --git a/src/features/character/DeskCharacterLayout.tsx b/src/features/character/DeskCharacterLayout.tsx index 9dc48d9..5f8d8d1 100644 --- a/src/features/character/DeskCharacterLayout.tsx +++ b/src/features/character/DeskCharacterLayout.tsx @@ -1,12 +1,12 @@ import type { ReactNode } from 'react' import { CharacterCorner } from './CharacterCorner' -/** Wraps desk page content with a bottom-right character mascot. */ +/** Wraps desk page content and places mascot in spare page space on larger screens. */ export function DeskCharacterLayout({ children }: { children: ReactNode }) { return ( -
+
{children} -
+
diff --git a/src/features/character/useArmPoseAnimation.ts b/src/features/character/useArmPoseAnimation.ts index 1de8044..11124fd 100644 --- a/src/features/character/useArmPoseAnimation.ts +++ b/src/features/character/useArmPoseAnimation.ts @@ -9,6 +9,38 @@ function shoulderPivot(el: SVGGElement): { cx: number; cy: number } { return { cx: box.x + box.width / 2, cy: box.y } } +function elementPointToSvg( + el: SVGElement, + local: { cx: number; cy: number }, +): { cx: number; cy: number } { + const svg = el.ownerSVGElement + const elementMatrix = el.getScreenCTM() + const svgMatrix = svg?.getScreenCTM() + if (!svg || !elementMatrix || !svgMatrix) return local + const pt = svg.createSVGPoint() + pt.x = local.cx + pt.y = local.cy + const asScreen = pt.matrixTransform(elementMatrix) + const asSvg = asScreen.matrixTransform(svgMatrix.inverse()) + return { cx: asSvg.x, cy: asSvg.y } +} + +function svgPointToElement( + el: SVGElement, + svgPoint: { cx: number; cy: number }, +): { cx: number; cy: number } { + const svg = el.ownerSVGElement + const elementMatrix = el.getScreenCTM() + const svgMatrix = svg?.getScreenCTM() + if (!svg || !elementMatrix || !svgMatrix) return svgPoint + const pt = svg.createSVGPoint() + pt.x = svgPoint.cx + pt.y = svgPoint.cy + const asScreen = pt.matrixTransform(svgMatrix) + const asElement = asScreen.matrixTransform(elementMatrix.inverse()) + return { cx: asElement.x, cy: asElement.y } +} + function setRotate(el: SVGElement, deg: number, cx: number, cy: number) { el.setAttribute('transform', `rotate(${deg} ${cx} ${cy})`) } @@ -32,8 +64,10 @@ export function useArmPoseAnimation( const armR = svg.getElementById(charManifest.arms.armR.jointId) as SVGGElement | null if (!armL || !armR) return - const pivotL = shoulderPivot(armL) - const pivotR = shoulderPivot(armR) + const pivotLLocal = shoulderPivot(armL) + const pivotRLocal = shoulderPivot(armR) + const pivotL = elementPointToSvg(armL, pivotLLocal) + const pivotR = elementPointToSvg(armR, pivotRLocal) const leftSleeves = charManifest.arms.armL.sleeveIds .map((id) => svg.getElementById(id)) @@ -43,10 +77,16 @@ export function useArmPoseAnimation( .filter((el): el is SVGElement => el instanceof SVGElement) const apply = (degL: number, degR: number) => { - setRotate(armL, degL, pivotL.cx, pivotL.cy) - setRotate(armR, degR, pivotR.cx, pivotR.cy) - for (const el of leftSleeves) setRotate(el, degL, pivotL.cx, pivotL.cy) - for (const el of rightSleeves) setRotate(el, degR, pivotR.cx, pivotR.cy) + setRotate(armL, degL, pivotLLocal.cx, pivotLLocal.cy) + setRotate(armR, degR, pivotRLocal.cx, pivotRLocal.cy) + for (const el of leftSleeves) { + const localPivot = svgPointToElement(el, pivotL) + setRotate(el, degL, localPivot.cx, localPivot.cy) + } + for (const el of rightSleeves) { + const localPivot = svgPointToElement(el, pivotR) + setRotate(el, degR, localPivot.cx, localPivot.cy) + } } const duration = motionDuration(0.4) diff --git a/src/features/compose/SubmitAnimation.tsx b/src/features/compose/SubmitAnimation.tsx index 44f846d..483d66a 100644 --- a/src/features/compose/SubmitAnimation.tsx +++ b/src/features/compose/SubmitAnimation.tsx @@ -1,8 +1,8 @@ import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useState } from 'react' import { motionDuration, shouldReduceMotion } from '../../shared/motion/useMotionPrefs' -import { publicUrl } from '../../shared/publicUrl' import { RopeWrap } from './RopeWrap' +import { useEquippedStampImage } from '../shop/shopCosmetics' type Phase = 'stamp' | 'rope' | 'mailbox' @@ -16,6 +16,7 @@ export function SubmitAnimation({ const [phase, setPhase] = useState('stamp') const [stampLanded, setStampLanded] = useState(false) const reduced = shouldReduceMotion() + const stampSrc = useEquippedStampImage() useEffect(() => { if (!open) return @@ -92,7 +93,7 @@ export function SubmitAnimation({ {stampLanded ? (

Local-first by default

- Your journal entries, notes, score, and most settings are stored on your device (IndexedDB - and browser storage). They are not sent to a server unless you turn on optional cloud - features below. + Your journal entries, notes, score, character look, and most settings are stored on your + device (IndexedDB and browser storage). They are not sent to a server unless you turn on + optional cloud features below.

@@ -99,8 +99,9 @@ export function PrivacyPolicyPage() {

With sync turned on, journal data and related settings you choose to back up are stored in Cloud Firestore under your account, keyed to your Firebase user id. - You can sign out, disable sync, delete local data, or delete your cloud account from - Settings. Cloud data is not intended for provider-managed or multi-patient use. + This includes character customization and shop cosmetics you unlock/equip. You can sign + out, disable sync, delete local data, or delete your cloud account from Settings. Cloud + data is not intended for provider-managed or multi-patient use.

) : (

diff --git a/src/features/shop/Shoppe.tsx b/src/features/shop/Shoppe.tsx index 34d81eb..a959f47 100644 --- a/src/features/shop/Shoppe.tsx +++ b/src/features/shop/Shoppe.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { format } from 'date-fns' import { getScoreSnapshot, spendScore } from '../score/scoreService' import { @@ -8,10 +8,21 @@ import { } from './catCollection' import { WeekTimelineCard } from './WeekTimelineCard' import { useAppSettings } from '../../shared/settings/useAppSettings' +import { publicUrl } from '../../shared/publicUrl' +import { SCORE_CHANGED_EVENT } from '../score/scoreEvents' +import { + equipShopItem, + loadShopInventory, + subscribeShopInventory, + unlockShopItem, + type ShopInventory, +} from './shopInventory' +import { loadShopCatalog, type ShopCatalogItem } from './shopCatalog' const CAT_COST = 250 type CatApiRow = { id?: string; url?: string } +type EquippableType = 'theme' | 'stamp' | 'cursor' export function Shoppe({ onScoreChange, @@ -21,20 +32,74 @@ export function Shoppe({ const { settings } = useAppSettings() const pointsOn = !settings.disablePoints const [busy, setBusy] = useState(false) + const [busyItemId, setBusyItemId] = useState(null) const [error, setError] = useState(null) + const [status, setStatus] = useState(null) const [catUrl, setCatUrl] = useState(null) const [catId, setCatId] = useState(null) + const catalog = useMemo(() => loadShopCatalog(), []) + const [inventory, setInventory] = useState(() => loadShopInventory()) const [collection, setCollection] = useState(() => loadCatCollection()) const [selectedCat, setSelectedCat] = useState(null) const [balance, setBalance] = useState(() => getScoreSnapshot().total) useEffect(() => { setBalance(getScoreSnapshot().total) - }, [collection]) + }, [collection, inventory]) + + useEffect(() => { + return subscribeShopInventory((next) => setInventory(next)) + }, []) + + useEffect(() => { + const refreshScore = () => setBalance(getScoreSnapshot().total) + window.addEventListener(SCORE_CHANGED_EVENT, refreshScore) + return () => window.removeEventListener(SCORE_CHANGED_EVENT, refreshScore) + }, []) + + function itemPreview(item: ShopCatalogItem) { + if (!item.preview) return null + if (item.preview.startsWith('/')) return publicUrl(item.preview) + return item.preview + } + + function itemOwned(itemId: string) { + return inventory.ownedItemIds.includes(itemId) + } + + function equippedId(type: EquippableType) { + if (type === 'theme') return inventory.equipped.themeId + if (type === 'stamp') return inventory.equipped.stampId + return inventory.equipped.cursorId + } + + function itemTypeLabel(item: ShopCatalogItem) { + if (item.type === 'theme') return 'Theme' + if (item.type === 'stamp') return 'Stamp' + if (item.type === 'cursor') return 'Cursor set' + return 'Image' + } + + function catalogHint(item: ShopCatalogItem) { + if (item.type === 'theme') return 'Applies different desk/paper styling in light + dark mode.' + if (item.type === 'stamp') return 'Updates the default submit stamp artwork in the send animation.' + if (item.type === 'cursor') return 'Applies custom default, pointer, and text cursors.' + return 'Collectible image item for future gallery drops.' + } + + function paletteForTheme(item: ShopCatalogItem) { + if (item.type !== 'theme') return null + return item.theme + } + + function canEquip(item: ShopCatalogItem): item is Extract { + return item.type === 'theme' || item.type === 'stamp' || item.type === 'cursor' + } async function buyCatPhoto() { if (busy || !pointsOn) return setBusy(true) + setStatus(null) setError(null) try { const current = getScoreSnapshot().total @@ -73,6 +138,7 @@ export function Shoppe({ setCollection(addCollectedCat({ id: first.id, url: first.url })) setBalance(getScoreSnapshot().total) onScoreChange(-CAT_COST, 'Cat photo collected') + setStatus('Mystery cat collected.') } catch { setError('Failed to reach cat photo service.') } finally { @@ -80,6 +146,51 @@ export function Shoppe({ } } + async function buyShopItem(item: ShopCatalogItem) { + if (busy || busyItemId || !pointsOn) return + setError(null) + setStatus(null) + setBusyItemId(item.id) + try { + if (itemOwned(item.id)) { + setStatus(`${item.name} is already unlocked.`) + return + } + const current = getScoreSnapshot().total + if (current < item.cost) { + setError(`You need ${item.cost - current} more points for ${item.name}.`) + return + } + const spend = spendScore(item.cost) + if (!spend.ok) { + setError( + spend.reason === 'insufficient' + ? 'Not enough points to complete this purchase.' + : 'Cannot complete purchase due to invalid score state.', + ) + return + } + unlockShopItem(item.id) + if (canEquip(item)) { + equipShopItem(item.type, item.id) + } + setBalance(getScoreSnapshot().total) + onScoreChange(-item.cost, `${item.name} unlocked`) + setStatus(canEquip(item) ? `${item.name} unlocked and equipped.` : `${item.name} unlocked.`) + } finally { + setBusyItemId(null) + } + } + + function toggleEquip(item: Extract) { + if (!itemOwned(item.id)) return + const current = equippedId(item.type) + const next = current === item.id ? null : item.id + equipShopItem(item.type, next) + setStatus(next ? `${item.name} equipped.` : `${itemTypeLabel(item)} unequipped.`) + setError(null) + } + return (

@@ -93,6 +204,10 @@ export function Shoppe({
collected
{collection.length}
+
+
shop unlocks
+
{inventory.ownedItemIds.length}
+
{pointsOn ? (
balance
@@ -105,6 +220,106 @@ export function Shoppe({ +
+
Customization shelves
+
+ Unlock themes, stamp variants, and cursor sets from a JSON catalog. +
+ {!pointsOn ? ( +
+ Points are turned off in Settings — enable the points system to unlock shop items. +
+ ) : null} +
+ {catalog.items.map((item) => { + const owned = itemOwned(item.id) + const equippable = canEquip(item) + const equipped = equippable && equippedId(item.type) === item.id + const preview = itemPreview(item) + const palette = paletteForTheme(item) + return ( +
+
+
+
{item.name}
+
{itemTypeLabel(item)}
+
+
{item.cost} pts
+
+
{item.description}
+
{catalogHint(item)}
+ {palette ? ( +
+
+ Light: + + +
+
+ Dark: + + +
+
+ ) : null} + {preview ? ( + + ) : null} +
+ {!owned ? ( + + ) : equippable ? ( + + ) : ( + + Owned + + )} + {owned ? ( + + {equipped ? 'Equipped' : 'Unlocked'} + + ) : null} +
+
+ ) + })} +
+
+
@@ -136,6 +351,11 @@ export function Shoppe({ {error}
) : null} + {status ? ( +
+ {status} +
+ ) : null}
diff --git a/src/features/shop/shopInventory.ts b/src/features/shop/shopInventory.ts index 3ae9c0e..bf56698 100644 --- a/src/features/shop/shopInventory.ts +++ b/src/features/shop/shopInventory.ts @@ -116,6 +116,12 @@ export function applyShopInventoryFromCloud(input: unknown) { return writeInventory(parsed, { notifySync: false, preserveUpdatedAt: true }) } +export function clearShopInventory() { + localStorage.removeItem(SHOP_INVENTORY_KEY) + const cleared = { ...DEFAULT_SHOP_INVENTORY } + emitInventoryChanged(cleared) +} + export function subscribeShopInventory(cb: (inventory: ShopInventory) => void) { const handler = (event: Event) => { const detail = (event as CustomEvent).detail diff --git a/src/index.css b/src/index.css index 6e3bfa8..3d82877 100644 --- a/src/index.css +++ b/src/index.css @@ -20,6 +20,10 @@ --pill-neg-bg: rgba(198, 29, 29, 0.12); --pill-neg-border: rgba(198, 29, 29, 0.45); --pill-neg-ink: #8b1818; + --shop-theme-overlay: none; + --shop-cursor-default: auto; + --shop-cursor-pointer: pointer; + --shop-cursor-text: text; color-scheme: light; background: var(--desk-bg); @@ -54,6 +58,7 @@ body { margin: 0; min-height: 100svh; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + cursor: var(--shop-cursor-default); } #root { @@ -62,7 +67,31 @@ body { .desk { min-height: 100svh; - background: var(--desk-bg); + background: + var(--shop-theme-overlay), + var(--desk-bg); + background-attachment: fixed; +} + +button, +[role='button'], +a, +summary, +input[type='checkbox'], +input[type='radio'], +input[type='range'], +select { + cursor: var(--shop-cursor-pointer); +} + +input[type='text'], +input[type='email'], +input[type='search'], +input[type='password'], +input[type='url'], +input[type='number'], +textarea { + cursor: var(--shop-cursor-text); } .paper { diff --git a/src/shared/account/accountDataService.ts b/src/shared/account/accountDataService.ts index 0b1ee64..c59c6dc 100644 --- a/src/shared/account/accountDataService.ts +++ b/src/shared/account/accountDataService.ts @@ -2,6 +2,7 @@ import { deleteUser } from 'firebase/auth' import { collection, deleteDoc, doc, getDocs } from 'firebase/firestore' import { getDb } from '../../db/schema' import { clearCharacterAppearance } from '../../features/character/characterAppearanceService' +import { clearShopInventory } from '../../features/shop/shopInventory' import { formatShareCode } from '../../features/share/shareLinkUrl' import { SCORE_CHANGED_EVENT } from '../../features/score/scoreEvents' import { getFirebaseAuth, getFirebaseFirestore } from '../firebase/firebaseApp' @@ -34,6 +35,7 @@ export async function clearLocalJournalData() { getDb().packages.clear(), ]) await clearCharacterAppearance() + clearShopInventory() for (const key of SCORE_KEYS) localStorage.removeItem(key) window.dispatchEvent(new CustomEvent(SCORE_CHANGED_EVENT)) } @@ -56,7 +58,14 @@ export async function deleteCloudAccount(uid: string) { deleteSubcollection(uid, 'shareLinks'), ]) - const metaIds = ['score', 'settings', 'aiProfile', 'shopCats'] as const + const metaIds = [ + 'score', + 'settings', + 'aiProfile', + 'shopCats', + 'shopInventory', + 'characterAppearance', + ] as const await Promise.all( metaIds.map((id) => deleteDoc(doc(fs(), 'users', uid, 'meta', id)).catch(() => {})), ) @@ -67,6 +76,7 @@ export async function deleteAccount(uid: string) { await clearLocalJournalData() localStorage.removeItem(scopedStorageKey('mentell.ai.profile')) localStorage.removeItem(scopedStorageKey('mentell.shop.cats')) + localStorage.removeItem(scopedStorageKey('mentell.shop.inventory')) disableSync() saveSyncState({ enabled: false, lastSyncedAt: null, lastError: null }) diff --git a/src/shared/sync/syncService.ts b/src/shared/sync/syncService.ts index 71f0cd7..3d60709 100644 --- a/src/shared/sync/syncService.ts +++ b/src/shared/sync/syncService.ts @@ -25,6 +25,11 @@ import { loadAppSettings, type AppSettings } from '../settings/appSettings' import { getScoreSnapshot } from '../../features/score/scoreService' import { loadAiProfile, type AiProfile } from '../../features/compilation/aiProfile' import { loadCatCollection } from '../../features/shop/catCollection' +import { + CHARACTER_APPEARANCE_ROW_ID, + applyCharacterAppearanceFromCloud, +} from '../../features/character/characterAppearanceService' +import { loadShopInventory, applyShopInventoryFromCloud } from '../../features/shop/shopInventory' import { scheduleSharePayloadRefresh } from '../../features/share/shareRefresh' import { LOCAL_DATA_CHANGED_EVENT } from './localDataEvents' @@ -136,6 +141,57 @@ async function pullMeta(uid: string) { localStorage.setItem(scopedStorageKey('mentell.shop.cats'), JSON.stringify(data.cats)) } } + + const characterSnap = await getDoc(userRef(uid, 'meta', 'characterAppearance')) + if (characterSnap.exists()) { + const data = characterSnap.data() as { + appearance?: { fills?: Record; toggles?: Record } + updatedAt?: number + } + const remoteUpdatedAt = + typeof data.updatedAt === 'number' && Number.isFinite(data.updatedAt) + ? Math.trunc(data.updatedAt) + : 0 + const local = await getDb().characterAppearance.get(CHARACTER_APPEARANCE_ROW_ID) + const localUpdatedAt = local?.updatedAt ?? 0 + if ( + data.appearance && + typeof data.appearance === 'object' && + remoteUpdatedAt >= localUpdatedAt + ) { + const fills = + data.appearance.fills && typeof data.appearance.fills === 'object' + ? data.appearance.fills + : {} + const toggles = + data.appearance.toggles && typeof data.appearance.toggles === 'object' + ? data.appearance.toggles + : {} + await getDb().characterAppearance.put({ + id: CHARACTER_APPEARANCE_ROW_ID, + updatedAt: remoteUpdatedAt, + fills, + toggles, + }) + applyCharacterAppearanceFromCloud({ fills, toggles }) + } + } + + const inventorySnap = await getDoc(userRef(uid, 'meta', 'shopInventory')) + if (inventorySnap.exists()) { + const data = inventorySnap.data() as { inventory?: unknown; updatedAt?: number } + const remoteUpdatedAt = + typeof data.updatedAt === 'number' && Number.isFinite(data.updatedAt) + ? Math.trunc(data.updatedAt) + : 0 + const localInventory = loadShopInventory() + if (data.inventory && remoteUpdatedAt >= localInventory.updatedAt) { + applyShopInventoryFromCloud({ + ...(data.inventory as object), + updatedAt: remoteUpdatedAt, + }) + } + } } async function pushCollection( @@ -162,6 +218,8 @@ async function pushCollection Date: Sat, 23 May 2026 11:03:20 +0000 Subject: [PATCH 04/14] Complete character UX and cosmetic runtime wiring Co-authored-by: Kiya Rose Ren-Miyakari --- asset/shop/shoppe-items.json | 18 +-- src/features/character/CharacterLabPage.tsx | 4 +- src/features/character/useArmPoseAnimation.ts | 8 +- src/features/compose/SubmitAnimation.tsx | 114 ++++++++++++------ src/features/shop/Shoppe.tsx | 6 +- src/features/shop/shopCatalog.ts | 2 +- src/features/shop/shopCosmetics.tsx | 58 +-------- src/features/shop/shopStampAsset.ts | 102 ++++++++++++++++ src/shared/account/accountDataService.ts | 1 + src/vite-env.d.ts | 8 ++ 10 files changed, 212 insertions(+), 109 deletions(-) create mode 100644 src/features/shop/shopStampAsset.ts diff --git a/asset/shop/shoppe-items.json b/asset/shop/shoppe-items.json index 7543fdb..fa995b4 100644 --- a/asset/shop/shoppe-items.json +++ b/asset/shop/shoppe-items.json @@ -6,7 +6,7 @@ "type": "theme", "name": "Sunrise Paper", "description": "Soft amber desk tones with warm paper for daylight journaling.", - "cost": 280, + "cost": 60, "preview": "/asset/light.png", "theme": { "light": { @@ -30,7 +30,7 @@ "type": "theme", "name": "Aurora Night", "description": "A cooler desk tint for late-night writing sessions.", - "cost": 320, + "cost": 70, "preview": "/asset/dark.png", "theme": { "light": { @@ -54,7 +54,7 @@ "type": "theme", "name": "Rose Noir", "description": "Muted burgundy ink vibes with softer high-contrast accents.", - "cost": 360, + "cost": 80, "preview": "/asset/mentell-icon.png", "theme": { "light": { @@ -78,7 +78,7 @@ "type": "stamp", "name": "Gotcha Stamp", "description": "Bold red stamp inspired by your sketched GOTCHA look.", - "cost": 210, + "cost": 45, "preview": "/asset/stamp.png", "stamp": { "text": "Gotcha", @@ -94,7 +94,7 @@ "type": "stamp", "name": "Airmail Stamp", "description": "Postal blue variant for outgoing reflections.", - "cost": 170, + "cost": 35, "preview": "/asset/stamp.png", "stamp": { "text": "Airmail", @@ -110,7 +110,7 @@ "type": "stamp", "name": "Secret Stamp", "description": "Stealthy violet stamp with softer letterpress text.", - "cost": 190, + "cost": 40, "preview": "/asset/stamp.png", "stamp": { "text": "Secret", @@ -126,7 +126,7 @@ "type": "cursor", "name": "Comet Cursor", "description": "Crisp sky-blue cursor set for default, pointer, and text.", - "cost": 160, + "cost": 30, "preview": "/asset/projector.png", "cursor": { "primary": "#78c5ff", @@ -145,7 +145,7 @@ "type": "cursor", "name": "Ember Cursor", "description": "Warm amber cursor set with high contrast outlines.", - "cost": 160, + "cost": 30, "preview": "/asset/envelope.png", "cursor": { "primary": "#ff8f4a", @@ -164,7 +164,7 @@ "type": "image", "name": "Gift Print", "description": "A collectible image item example for future inventory drops.", - "cost": 120, + "cost": 20, "preview": "/asset/gift_small.png", "image": { "url": "/asset/gift_large.png" diff --git a/src/features/character/CharacterLabPage.tsx b/src/features/character/CharacterLabPage.tsx index cc9437a..e9910dd 100644 --- a/src/features/character/CharacterLabPage.tsx +++ b/src/features/character/CharacterLabPage.tsx @@ -17,7 +17,9 @@ export function CharacterLabPage() { const [pose, setPose] = useState('wave') const toggleByKey = useMemo(() => { - const map = new Map(charManifest.toggleGroups.map((g) => [g.key, g])) + const map = new Map( + charManifest.toggleGroups.map((g) => [g.key, g]), + ) return map }, []) diff --git a/src/features/character/useArmPoseAnimation.ts b/src/features/character/useArmPoseAnimation.ts index 11124fd..4f38408 100644 --- a/src/features/character/useArmPoseAnimation.ts +++ b/src/features/character/useArmPoseAnimation.ts @@ -10,7 +10,7 @@ function shoulderPivot(el: SVGGElement): { cx: number; cy: number } { } function elementPointToSvg( - el: SVGElement, + el: SVGGraphicsElement, local: { cx: number; cy: number }, ): { cx: number; cy: number } { const svg = el.ownerSVGElement @@ -26,7 +26,7 @@ function elementPointToSvg( } function svgPointToElement( - el: SVGElement, + el: SVGGraphicsElement, svgPoint: { cx: number; cy: number }, ): { cx: number; cy: number } { const svg = el.ownerSVGElement @@ -71,10 +71,10 @@ export function useArmPoseAnimation( const leftSleeves = charManifest.arms.armL.sleeveIds .map((id) => svg.getElementById(id)) - .filter((el): el is SVGElement => el instanceof SVGElement) + .filter((el): el is SVGGraphicsElement => el instanceof SVGGraphicsElement) const rightSleeves = charManifest.arms.armR.sleeveIds .map((id) => svg.getElementById(id)) - .filter((el): el is SVGElement => el instanceof SVGElement) + .filter((el): el is SVGGraphicsElement => el instanceof SVGGraphicsElement) const apply = (degL: number, degR: number) => { setRotate(armL, degL, pivotLLocal.cx, pivotLLocal.cy) diff --git a/src/features/compose/SubmitAnimation.tsx b/src/features/compose/SubmitAnimation.tsx index 483d66a..ce91540 100644 --- a/src/features/compose/SubmitAnimation.tsx +++ b/src/features/compose/SubmitAnimation.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useState } from 'react' import { motionDuration, shouldReduceMotion } from '../../shared/motion/useMotionPrefs' import { RopeWrap } from './RopeWrap' -import { useEquippedStampImage } from '../shop/shopCosmetics' +import { useEquippedStampAsset } from '../shop/shopStampAsset' type Phase = 'stamp' | 'rope' | 'mailbox' @@ -16,12 +16,10 @@ export function SubmitAnimation({ const [phase, setPhase] = useState('stamp') const [stampLanded, setStampLanded] = useState(false) const reduced = shouldReduceMotion() - const stampSrc = useEquippedStampImage() + const stamp = useEquippedStampAsset() useEffect(() => { if (!open) return - setPhase('stamp') - setStampLanded(false) const d = (ms: number) => motionDuration(ms) || (reduced ? 50 : ms) @@ -91,13 +89,31 @@ export function SubmitAnimation({ {stampLanded ? ( - + stamp.isCustom ? ( +
+ + {stamp.text} + +
+ ) : ( + + ) ) : null} @@ -124,29 +140,59 @@ export function SubmitAnimation({ if (!reduced) setStampLanded(true) }} > - + {stamp.isCustom ? ( + + + {stamp.text} + + + ) : ( + + )} ) : null} diff --git a/src/features/shop/Shoppe.tsx b/src/features/shop/Shoppe.tsx index a959f47..d737325 100644 --- a/src/features/shop/Shoppe.tsx +++ b/src/features/shop/Shoppe.tsx @@ -18,6 +18,7 @@ import { type ShopInventory, } from './shopInventory' import { loadShopCatalog, type ShopCatalogItem } from './shopCatalog' +import { renderStampPreviewForItem } from './shopStampAsset' const CAT_COST = 250 @@ -43,10 +44,6 @@ export function Shoppe({ const [selectedCat, setSelectedCat] = useState(null) const [balance, setBalance] = useState(() => getScoreSnapshot().total) - useEffect(() => { - setBalance(getScoreSnapshot().total) - }, [collection, inventory]) - useEffect(() => { return subscribeShopInventory((next) => setInventory(next)) }, []) @@ -58,6 +55,7 @@ export function Shoppe({ }, []) function itemPreview(item: ShopCatalogItem) { + if (item.type === 'stamp') return renderStampPreviewForItem(item) if (!item.preview) return null if (item.preview.startsWith('/')) return publicUrl(item.preview) return item.preview diff --git a/src/features/shop/shopCatalog.ts b/src/features/shop/shopCatalog.ts index 9871d60..e157cfa 100644 --- a/src/features/shop/shopCatalog.ts +++ b/src/features/shop/shopCatalog.ts @@ -186,7 +186,7 @@ let catalogCache: ShopCatalog | null = null export function loadShopCatalog(): ShopCatalog { if (catalogCache) return catalogCache - let parsed: unknown = null + let parsed: unknown try { parsed = JSON.parse(catalogJson) } catch { diff --git a/src/features/shop/shopCosmetics.tsx b/src/features/shop/shopCosmetics.tsx index ab1a918..3cddec6 100644 --- a/src/features/shop/shopCosmetics.tsx +++ b/src/features/shop/shopCosmetics.tsx @@ -1,13 +1,10 @@ import { useEffect, useMemo, useState } from 'react' import cursorTemplateSvg from '../../../asset/shop/cursor.svg?raw' -import stampTemplateSvg from '../../../asset/shop/stamp.svg?raw' -import { publicUrl } from '../../shared/publicUrl' import { useTheme } from '../../shared/theme/useTheme' import { loadShopCatalog, type CursorItem, type ShopCatalogItem, - type StampItem, type ThemeItem, } from './shopCatalog' import { @@ -18,8 +15,6 @@ import { type CursorContext = 'default' | 'pointer' | 'text' -const FALLBACK_STAMP = publicUrl('/asset/stamp.png') - function findEquippedItem( items: ShopCatalogItem[], itemType: T['type'], @@ -57,42 +52,6 @@ function applyThemeCosmetics(mode: 'light' | 'dark', themeItem: ThemeItem | null setThemeCssVar('--shop-theme-overlay', palette.overlay) } -function svgElementById(doc: Document, id: string) { - const el = doc.getElementById(id) - return el instanceof SVGElement ? el : null -} - -function renderStampDataUri(item: StampItem): string { - const doc = new DOMParser().parseFromString(stampTemplateSvg, 'image/svg+xml') - const svg = doc.documentElement - if (!(svg instanceof SVGSVGElement)) return FALLBACK_STAMP - const stampRoot = svgElementById(doc, 'stamp-root') - const border = svgElementById(doc, 'stamp-border') - const inner = svgElementById(doc, 'stamp-inner') - const text = svgElementById(doc, 'stamp-text') - - if (stampRoot) { - const tilt = Number.isFinite(item.stamp.tiltDeg) ? item.stamp.tiltDeg : -14 - stampRoot.setAttribute('transform', `rotate(${tilt} 128 128)`) - stampRoot.style.opacity = String( - Number.isFinite(item.stamp.opacity) ? item.stamp.opacity : 0.24, - ) - } - if (border) { - border.setAttribute('stroke', item.stamp.outline) - border.setAttribute('fill', item.stamp.ink) - border.style.opacity = '0.08' - } - if (inner) { - inner.setAttribute('stroke', item.stamp.outline) - } - if (text) { - text.setAttribute('fill', item.stamp.textColor ?? item.stamp.outline) - text.textContent = item.stamp.text - } - return serializeSvgElement(svg) -} - function renderCursorCssValue(item: CursorItem, context: CursorContext): string | null { const doc = new DOMParser().parseFromString(cursorTemplateSvg, 'image/svg+xml') const svg = doc.documentElement @@ -145,29 +104,16 @@ function applyCursorCosmetics(cursorItem: CursorItem | null) { if (text) document.documentElement.style.setProperty('--shop-cursor-text', text) } -export function useShopInventoryState() { +function useShopInventoryState() { const [inventory, setInventory] = useState(() => loadShopInventory()) useEffect(() => subscribeShopInventory((next) => setInventory(next)), []) return inventory } -export function useShopCatalogState() { +function useShopCatalogState() { return useMemo(() => loadShopCatalog(), []) } -export function isOwned(inventory: ShopInventory, itemId: string) { - return inventory.ownedItemIds.includes(itemId) -} - -export function useEquippedStampImage() { - const catalog = useShopCatalogState() - const inventory = useShopInventoryState() - return useMemo(() => { - const stamp = findEquippedItem(catalog.items, 'stamp', inventory.equipped.stampId) - return stamp ? renderStampDataUri(stamp) : FALLBACK_STAMP - }, [catalog.items, inventory.equipped.stampId]) -} - export function ShopCosmeticEffects() { const { mode } = useTheme() const catalog = useShopCatalogState() diff --git a/src/features/shop/shopStampAsset.ts b/src/features/shop/shopStampAsset.ts new file mode 100644 index 0000000..a837720 --- /dev/null +++ b/src/features/shop/shopStampAsset.ts @@ -0,0 +1,102 @@ +import { useEffect, useMemo, useState } from 'react' +import stampTemplateSvg from '../../../asset/shop/stamp.svg?raw' +import { publicUrl } from '../../shared/publicUrl' +import { loadShopCatalog, type StampItem } from './shopCatalog' +import { loadShopInventory, subscribeShopInventory, type ShopInventory } from './shopInventory' + +const FALLBACK_STAMP = publicUrl('/asset/stamp.png') +const DEFAULT_STAMP_TEXT = 'STAMP' +const DEFAULT_STAMP_INK = '#c61d1d' +const DEFAULT_STAMP_OUTLINE = '#9e1717' +const DEFAULT_STAMP_TEXT_COLOR = '#9e1717' + +export type EquippedStampAsset = { + src: string + isCustom: boolean + text: string + ink: string + outline: string + textColor: string +} + +function serializeSvgElement(svg: SVGSVGElement) { + const raw = new XMLSerializer().serializeToString(svg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(raw)}` +} + +function svgElementById(doc: Document, id: string) { + const el = doc.getElementById(id) + return el instanceof SVGElement ? el : null +} + +function renderStampDataUri(item: StampItem): string { + const doc = new DOMParser().parseFromString(stampTemplateSvg, 'image/svg+xml') + const svg = doc.documentElement + if (!(svg instanceof SVGSVGElement)) return FALLBACK_STAMP + const stampRoot = svgElementById(doc, 'stamp-root') + const border = svgElementById(doc, 'stamp-border') + const inner = svgElementById(doc, 'stamp-inner') + const text = svgElementById(doc, 'stamp-text') + + if (stampRoot) { + const tilt = Number.isFinite(item.stamp.tiltDeg) ? item.stamp.tiltDeg : -14 + stampRoot.setAttribute('transform', `rotate(${tilt} 128 128)`) + stampRoot.style.opacity = String( + Number.isFinite(item.stamp.opacity) ? item.stamp.opacity : 0.9, + ) + } + if (border) { + border.setAttribute('stroke', item.stamp.outline) + border.setAttribute('fill', item.stamp.ink) + border.style.opacity = '0.34' + } + if (inner) { + inner.setAttribute('stroke', item.stamp.outline) + inner.setAttribute('fill', item.stamp.ink) + inner.style.opacity = '0.2' + } + if (text) { + text.setAttribute('fill', item.stamp.textColor ?? item.stamp.outline) + text.setAttribute('font-size', '74') + text.setAttribute('letter-spacing', '2.2') + text.textContent = item.stamp.text.toUpperCase() + } + return serializeSvgElement(svg) +} + +function findEquippedStamp(inventory: ShopInventory): StampItem | null { + const stampId = inventory.equipped.stampId + if (!stampId) return null + const item = loadShopCatalog().items.find((entry) => entry.id === stampId && entry.type === 'stamp') + return (item as StampItem | undefined) ?? null +} + +export function renderStampPreviewForItem(item: StampItem) { + return renderStampDataUri(item) +} + +export function useEquippedStampAsset(): EquippedStampAsset { + const [inventory, setInventory] = useState(() => loadShopInventory()) + useEffect(() => subscribeShopInventory((next) => setInventory(next)), []) + return useMemo(() => { + const equipped = findEquippedStamp(inventory) + if (!equipped) { + return { + src: FALLBACK_STAMP, + isCustom: false, + text: DEFAULT_STAMP_TEXT, + ink: DEFAULT_STAMP_INK, + outline: DEFAULT_STAMP_OUTLINE, + textColor: DEFAULT_STAMP_TEXT_COLOR, + } + } + return { + src: renderStampDataUri(equipped), + isCustom: true, + text: equipped.stamp.text.toUpperCase(), + ink: equipped.stamp.ink, + outline: equipped.stamp.outline, + textColor: equipped.stamp.textColor ?? equipped.stamp.outline, + } + }, [inventory]) +} diff --git a/src/shared/account/accountDataService.ts b/src/shared/account/accountDataService.ts index c59c6dc..7738d2c 100644 --- a/src/shared/account/accountDataService.ts +++ b/src/shared/account/accountDataService.ts @@ -92,6 +92,7 @@ export async function deleteAccount(uid: string) { if (code === 'auth/requires-recent-login') { throw new Error( 'Google needs a fresh sign-in to delete your account. Sign out, sign in again, then retry.', + { cause: e }, ) } throw e diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4b9e1eb..86d2fcb 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,14 @@ declare module '*.svg?raw' { } /// +declare module 'virtual:pwa-register' { + export function registerSW(options?: { + immediate?: boolean + onNeedRefresh?: () => void + onOfflineReady?: () => void + }): (reloadPage?: boolean) => Promise +} + interface ImportMetaEnv { readonly VITE_APP_VERSION: string readonly VITE_ENABLE_WEEKLY_AI_SUMMARY?: string From 3049f22b7d1723e0fe9350feed8cf6122368ad0b Mon Sep 17 00:00:00 2001 From: Kiya Rose Date: Sat, 23 May 2026 14:22:38 -0400 Subject: [PATCH 05/14] Update character SVG: adjust viewbox settings, transform centers, and restore togglesleeve layers for improved design consistency. --- asset/char/charprod.svg | 87 +++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 42 deletions(-) diff --git a/asset/char/charprod.svg b/asset/char/charprod.svg index bc8813e..ca52ad9 100644 --- a/asset/char/charprod.svg +++ b/asset/char/charprod.svg @@ -24,15 +24,15 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" inkscape:document-units="mm" - inkscape:zoom="0.3957155" - inkscape:cx="485.19707" - inkscape:cy="755.59335" + inkscape:zoom="0.49076868" + inkscape:cx="535.89402" + inkscape:cy="1024.9228" inkscape:window-width="1472" inkscape:window-height="771" inkscape:window-x="0" inkscape:window-y="33" inkscape:window-maximized="1" - inkscape:current-layer="g11" /> + ry="4.8335576" /> From c6405495db0c15f872d221b6acdd891679fb016b Mon Sep 17 00:00:00 2001 From: Kiya Rose Date: Sat, 23 May 2026 14:45:08 -0400 Subject: [PATCH 06/14] Icon patch --- asset/shop/cursor.svg | 32 --- asset/shop/pointer.svg | 59 +++++ asset/shop/stamp.svg | 491 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 506 insertions(+), 76 deletions(-) delete mode 100644 asset/shop/cursor.svg create mode 100644 asset/shop/pointer.svg diff --git a/asset/shop/cursor.svg b/asset/shop/cursor.svg deleted file mode 100644 index 3af240a..0000000 --- a/asset/shop/cursor.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/asset/shop/pointer.svg b/asset/shop/pointer.svg new file mode 100644 index 0000000..f9b9d5b --- /dev/null +++ b/asset/shop/pointer.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + diff --git a/asset/shop/stamp.svg b/asset/shop/stamp.svg index 04ad21c..9737ad9 100644 --- a/asset/shop/stamp.svg +++ b/asset/shop/stamp.svg @@ -1,44 +1,447 @@ - - - - - - Stamp - - - + + + + From f858f863bbc7b65a54218de8acea676d91507297 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 18:54:18 +0000 Subject: [PATCH 07/14] Polish shop cosmetics and character animation rendering Co-authored-by: Kiya Rose Ren-Miyakari --- asset/shop/README.md | 39 ++++- asset/shop/shoppe-items.json | 16 -- docs/CHARACTER_CUSTOMIZATION.md | 50 ++++++ src/App.tsx | 2 +- .../character/CharacterTabIconSync.tsx | 11 +- src/features/character/MentellCharacter.tsx | 20 +++ src/features/character/characterPoses.ts | 10 +- src/features/character/useArmPoseAnimation.ts | 8 +- src/features/compose/SubmitAnimation.tsx | 112 +++++--------- src/features/shop/Shoppe.tsx | 120 ++++++++++----- src/features/shop/shopCosmetics.tsx | 144 ++++++++++++++---- src/features/shop/shopStampAsset.ts | 84 +++++++--- 12 files changed, 416 insertions(+), 200 deletions(-) create mode 100644 docs/CHARACTER_CUSTOMIZATION.md diff --git a/asset/shop/README.md b/asset/shop/README.md index a12d5ce..82ba8bd 100644 --- a/asset/shop/README.md +++ b/asset/shop/README.md @@ -2,6 +2,13 @@ `shoppe-items.json` defines purchasable items for the in-app Shoppe. +Default cosmetics are **not** stored in this catalog: + +- Default stamp artwork comes from `asset/shop/stamp.svg`. +- Default cursor artwork comes from `asset/shop/pointer.svg`. + +Keep those defaults out of `shoppe-items.json` so unlockable items stay additive. + ## Root format - `version` (number): schema version. @@ -16,7 +23,7 @@ Each item includes: - `name` (string) - `description` (string) - `cost` (number, points) -- `preview` (optional string, usually `/asset/...`) +- `preview` (optional string, usually `/asset/...`, currently used by `image` items) ## Item-specific fields @@ -44,13 +51,15 @@ Each item includes: } ``` +Theme preview cards are generated from the four desk/paper colors (`light` + `dark`) and do not depend on `preview` images. + ### `stamp` ```json { "type": "stamp", "stamp": { - "text": "Gotcha", + "text": "Airmail", "ink": "#...", "outline": "#...", "textColor": "#...", @@ -60,6 +69,8 @@ Each item includes: } ``` +Stamp previews and submit-animation stamps are generated from these fields. Keep `text` short (recommended <= 12 chars) for best fit. + ### `cursor` ```json @@ -79,6 +90,8 @@ Each item includes: } ``` +Cursor preview cards render a live hover box by generating `default`, `pointer`, and `text` cursors from `asset/shop/pointer.svg`. + ### `image` ```json @@ -89,3 +102,25 @@ Each item includes: } } ``` + +## Asset contracts + +### `asset/shop/pointer.svg` + +- Preferred: groups with `data-context="default|pointer|text"`. +- Supported fallback: per-shape `inkscape:label` hints containing `point` / `pointer` / `text`. +- Optional color hooks: + - `data-fill="primary|secondary|outline|text"` + - `data-stroke="primary|outline"` + +### `asset/shop/stamp.svg` + +- Used directly as the default non-shop stamp artwork. +- Unlockable stamp items are generated from catalog values (`stamp.text`, `ink`, `outline`, etc.). + +## Iteration checklist + +1. Edit `shoppe-items.json` (add/remove/update items). +2. If changing default art, edit `asset/shop/stamp.svg` and/or `asset/shop/pointer.svg`. +3. Run `npm run build:check` to verify parsing/rendering. +4. Verify `/shop` preview cards and submit animation manually in the browser. diff --git a/asset/shop/shoppe-items.json b/asset/shop/shoppe-items.json index fa995b4..67ac3cb 100644 --- a/asset/shop/shoppe-items.json +++ b/asset/shop/shoppe-items.json @@ -73,22 +73,6 @@ } } }, - { - "id": "stamp-gotcha", - "type": "stamp", - "name": "Gotcha Stamp", - "description": "Bold red stamp inspired by your sketched GOTCHA look.", - "cost": 45, - "preview": "/asset/stamp.png", - "stamp": { - "text": "Gotcha", - "ink": "#cf2040", - "outline": "#9d1730", - "textColor": "#9d1730", - "tiltDeg": -14, - "opacity": 0.24 - } - }, { "id": "stamp-airmail", "type": "stamp", diff --git a/docs/CHARACTER_CUSTOMIZATION.md b/docs/CHARACTER_CUSTOMIZATION.md new file mode 100644 index 0000000..5f0dcbd --- /dev/null +++ b/docs/CHARACTER_CUSTOMIZATION.md @@ -0,0 +1,50 @@ +# Character customization and animation iteration + +This guide covers how to iterate on the desk character system quickly. + +## Source files + +- Body SVG source: `asset/char/charprod.svg` +- Headshot / icon SVG source: `asset/char/headshot.svg` +- Generated manifest: `src/features/character/charManifest.generated.ts` +- Manifest generator: `scripts/generate-char-manifest.mjs` + +## Adding clothing / visual options + +Character options are discovered from Inkscape labels in `charprod.svg`. + +- Fill targets: label with `_III` +- Toggle groups: label with `_TOGGLE` +- Global fill groups: toggle-layer labels that also include `_III` +- Excluded shapes: label with `_DNI` + +After SVG edits, regenerate metadata: + +1. `node scripts/generate-char-manifest.mjs` +2. Confirm `charManifest.generated.ts` changed as expected +3. Verify Character Lab controls in `/character-lab` + +The control order/labels for Character Lab live in `src/features/character/charLabControls.ts`. + +## Arm animation tuning + +- Pose targets: `src/features/character/characterPoses.ts` +- Motion interpolation and pivots: `src/features/character/useArmPoseAnimation.ts` +- Runtime SVG composition / layer promotion: `src/features/character/MentellCharacter.tsx` + +If arm layers ever slip behind torso/hair after SVG reorder changes, keep `promoteAnimatedArmLayers()` in sync with your new arm/sleeve IDs. + +## Icon framing + +- Browser tab icon sync: `src/features/character/CharacterTabIconSync.tsx` + +`FAVICON_HEAD_VIEWBOX` controls icon crop. Update it whenever `headshot.svg` layout changes, then verify hair toggles do not shift icon centering. + +## Recommended validation flow + +1. `npm run build:check` +2. `npm run lint` +3. Manual browser checks: + - `/character-lab` hair/clothing toggles and pose animation + - top-nav Character icon alignment + - browser tab icon updates while changing hair diff --git a/src/App.tsx b/src/App.tsx index 352ad15..644fc07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -417,7 +417,7 @@ function DeskLink({ >
{isCharacter ? ( - + ) : icon ? ( ) : null} diff --git a/src/features/character/CharacterTabIconSync.tsx b/src/features/character/CharacterTabIconSync.tsx index 49f6d2e..e7c7f9f 100644 --- a/src/features/character/CharacterTabIconSync.tsx +++ b/src/features/character/CharacterTabIconSync.tsx @@ -4,13 +4,18 @@ import { applyCharacterAppearance } from './applyCharacterAppearance' import { defaultCharacterAppearance } from './characterAppearance' import { useCharacterAppearance } from './useCharacterAppearance' +const FAVICON_SIZE = '96' +// Crops the generated headshot so hair toggles do not shift icon framing. +const FAVICON_HEAD_VIEWBOX = '16 4 68 74' + function buildCharacterFaviconDataUrl(appearance: ReturnType) { const parsed = new DOMParser().parseFromString(headshotSvg, 'image/svg+xml') const svg = parsed.querySelector('svg') if (!(svg instanceof SVGSVGElement)) return null - svg.setAttribute('width', '96') - svg.setAttribute('height', '96') - svg.setAttribute('viewBox', '0 0 100 100') + svg.setAttribute('width', FAVICON_SIZE) + svg.setAttribute('height', FAVICON_SIZE) + svg.setAttribute('viewBox', FAVICON_HEAD_VIEWBOX) + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') applyCharacterAppearance(svg, appearance) const serialized = new XMLSerializer().serializeToString(svg) return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}` diff --git a/src/features/character/MentellCharacter.tsx b/src/features/character/MentellCharacter.tsx index 4447f1b..e413ccb 100644 --- a/src/features/character/MentellCharacter.tsx +++ b/src/features/character/MentellCharacter.tsx @@ -30,6 +30,22 @@ export type MentellCharacterProps = { title?: string } +function bringElementToFront(svg: SVGSVGElement, id: string | undefined) { + if (!id) return + const el = svg.getElementById(id) + const parent = el?.parentElement + if (!el || !parent) return + parent.appendChild(el) +} + +function promoteAnimatedArmLayers(svg: SVGSVGElement) { + const sleeveParentId = + charManifest.globalFillGroups.find((group) => group.key === 'sleeves')?.parentId ?? 'layer19' + bringElementToFront(svg, sleeveParentId) + bringElementToFront(svg, charManifest.arms.armL.jointId) + bringElementToFront(svg, charManifest.arms.armR.jointId) +} + export function MentellCharacter({ pose, asset = 'character', @@ -58,6 +74,10 @@ export function MentellCharacter({ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') svg.style.display = 'block' svg.style.overflow = 'visible' + if (asset === 'character') { + // Keep animated arms/sleeves painted above torso across SVG reorder tweaks. + promoteAnimatedArmLayers(svg) + } applyCharacterAppearance(svg, JSON.parse(appearanceKey) as CharacterAppearance) setSvgGeneration((g) => g + 1) }, [appearanceKey, asset]) diff --git a/src/features/character/characterPoses.ts b/src/features/character/characterPoses.ts index 7fe5448..45aed8b 100644 --- a/src/features/character/characterPoses.ts +++ b/src/features/character/characterPoses.ts @@ -4,11 +4,11 @@ export type ArmPose = { armL: number; armR: number } export const CHARACTER_POSES: Record = { idle: { armL: 0, armR: 0 }, - present: { armL: -35, armR: 35 }, - think: { armL: -52, armR: 18 }, - write: { armL: -15, armR: 32 }, - shop: { armL: 10, armR: -28 }, - wave: { armL: -58, armR: 6 }, + present: { armL: -48, armR: 42 }, + think: { armL: -66, armR: 22 }, + write: { armL: -24, armR: 48 }, + shop: { armL: 24, armR: -44 }, + wave: { armL: -82, armR: 16 }, } export const POSE_LABELS: Record = { diff --git a/src/features/character/useArmPoseAnimation.ts b/src/features/character/useArmPoseAnimation.ts index 4f38408..3af5093 100644 --- a/src/features/character/useArmPoseAnimation.ts +++ b/src/features/character/useArmPoseAnimation.ts @@ -89,7 +89,7 @@ export function useArmPoseAnimation( } } - const duration = motionDuration(0.4) + const duration = motionDuration(0.55) if (duration === 0) { apply(pose.armL, pose.armR) @@ -103,16 +103,18 @@ export function useArmPoseAnimation( let currentR = fromR const controlsL = animate(fromL, pose.armL, { + type: 'spring', duration, - ease: [0.4, 0, 0.2, 1], + bounce: 0.22, onUpdate: (v) => { currentL = v apply(currentL, currentR) }, }) const controlsR = animate(fromR, pose.armR, { + type: 'spring', duration, - ease: [0.4, 0, 0.2, 1], + bounce: 0.22, onUpdate: (v) => { currentR = v apply(currentL, currentR) diff --git a/src/features/compose/SubmitAnimation.tsx b/src/features/compose/SubmitAnimation.tsx index ce91540..68695a6 100644 --- a/src/features/compose/SubmitAnimation.tsx +++ b/src/features/compose/SubmitAnimation.tsx @@ -89,31 +89,17 @@ export function SubmitAnimation({ {stampLanded ? ( - stamp.isCustom ? ( -
- - {stamp.text} - -
- ) : ( - - ) + ) : null} @@ -140,59 +126,29 @@ export function SubmitAnimation({ if (!reduced) setStampLanded(true) }} > - {stamp.isCustom ? ( - - - {stamp.text} - - - ) : ( - - )} + ) : null} diff --git a/src/features/shop/Shoppe.tsx b/src/features/shop/Shoppe.tsx index d737325..755dad7 100644 --- a/src/features/shop/Shoppe.tsx +++ b/src/features/shop/Shoppe.tsx @@ -17,14 +17,90 @@ import { unlockShopItem, type ShopInventory, } from './shopInventory' -import { loadShopCatalog, type ShopCatalogItem } from './shopCatalog' +import { + loadShopCatalog, + type CursorItem, + type ShopCatalogItem, + type ThemeItem, +} from './shopCatalog' import { renderStampPreviewForItem } from './shopStampAsset' +import { renderCursorCssValue } from './shopCosmetics' const CAT_COST = 250 type CatApiRow = { id?: string; url?: string } type EquippableType = 'theme' | 'stamp' | 'cursor' +function ThemePreview({ item }: { item: ThemeItem }) { + return ( +
+
+
+ Light desk + + Dark desk + +
+
+ ) +} + +function CursorHoverPreview({ item }: { item: CursorItem }) { + const defaultCursor = renderCursorCssValue(item, 'default') + const pointerCursor = renderCursorCssValue(item, 'pointer') ?? defaultCursor + const textCursor = renderCursorCssValue(item, 'text') ?? pointerCursor + return ( +
+
+ hover box + + +
+
+ ) +} + +function ShopItemPreview({ item, preview }: { item: ShopCatalogItem; preview: string | null }) { + if (item.type === 'theme') return + if (item.type === 'cursor') return + if (!preview) return null + return ( + + ) +} + export function Shoppe({ onScoreChange, }: { @@ -56,6 +132,7 @@ export function Shoppe({ function itemPreview(item: ShopCatalogItem) { if (item.type === 'stamp') return renderStampPreviewForItem(item) + if (item.type === 'theme' || item.type === 'cursor') return null if (!item.preview) return null if (item.preview.startsWith('/')) return publicUrl(item.preview) return item.preview @@ -85,11 +162,6 @@ export function Shoppe({ return 'Collectible image item for future gallery drops.' } - function paletteForTheme(item: ShopCatalogItem) { - if (item.type !== 'theme') return null - return item.theme - } - function canEquip(item: ShopCatalogItem): item is Extract { return item.type === 'theme' || item.type === 'stamp' || item.type === 'cursor' } @@ -234,7 +306,6 @@ export function Shoppe({ const equippable = canEquip(item) const equipped = equippable && equippedId(item.type) === item.id const preview = itemPreview(item) - const palette = paletteForTheme(item) return (
{item.description}
{catalogHint(item)}
- {palette ? ( -
-
- Light: - - -
-
- Dark: - - -
-
- ) : null} - {preview ? ( - - ) : null} +
{!owned ? (
- {menuCharacterReady ? ( -
-
-
Desk companion
-
Current character
-
- -
- ) : null} -
-
{children}
-
-
- -
-
-
- ) + return
{children}
} diff --git a/src/features/character/LeftDeskMascot.tsx b/src/features/character/LeftDeskMascot.tsx new file mode 100644 index 0000000..0834e50 --- /dev/null +++ b/src/features/character/LeftDeskMascot.tsx @@ -0,0 +1,22 @@ +import { CharacterCorner } from './CharacterCorner' + +/** + * Desktop mascot in the viewport gutter left of the centered max-w-4xl column. + * Does not affect document flow or header layout. + */ +export function LeftDeskMascot() { + return ( +
+ +
+ ) +} diff --git a/src/features/character/MentellCharacter.tsx b/src/features/character/MentellCharacter.tsx index 29fba95..9acdbae 100644 --- a/src/features/character/MentellCharacter.tsx +++ b/src/features/character/MentellCharacter.tsx @@ -2,6 +2,10 @@ import { useLayoutEffect, useRef, useState } from 'react' import charSvg from '../../../asset/char/charprod.svg?raw' import headshotSvg from '../../../asset/char/headshot.svg?raw' import { applyCharacterAppearance } from './applyCharacterAppearance' +import { + fixCharacterPaintOrder, + fixHeadshotPaintOrder, +} from './characterPaintOrder' import { charManifest } from './charManifest' import { defaultCharacterAppearance, @@ -30,22 +34,6 @@ export type MentellCharacterProps = { title?: string } -function bringElementToFront(svg: SVGSVGElement, id: string | undefined) { - if (!id) return - const el = svg.getElementById(id) - const parent = el?.parentElement - if (!el || !parent) return - parent.appendChild(el) -} - -function promoteAnimatedArmLayers(svg: SVGSVGElement) { - const sleeveParentId = - charManifest.globalFillGroups.find((group) => group.key === 'sleeves')?.parentId ?? 'layer19' - bringElementToFront(svg, sleeveParentId) - bringElementToFront(svg, charManifest.arms.armL.jointId) - bringElementToFront(svg, charManifest.arms.armR.jointId) -} - export function MentellCharacter({ pose, asset = 'character', @@ -75,10 +63,11 @@ export function MentellCharacter({ svg.style.display = 'block' svg.style.overflow = asset === 'headshot' ? 'hidden' : 'visible' if (asset === 'character') { - // Keep animated arms/sleeves painted above torso across SVG reorder tweaks. - promoteAnimatedArmLayers(svg) + fixCharacterPaintOrder(svg) + } else { + fixHeadshotPaintOrder(svg) } - applyCharacterAppearance(svg, JSON.parse(appearanceKey) as CharacterAppearance) + applyCharacterAppearance(svg, appearance) setSvgGeneration((g) => g + 1) }, [appearanceKey, asset]) diff --git a/src/features/character/MobileHeaderMascot.tsx b/src/features/character/MobileHeaderMascot.tsx new file mode 100644 index 0000000..a4cc13a --- /dev/null +++ b/src/features/character/MobileHeaderMascot.tsx @@ -0,0 +1,20 @@ +import { useLocation } from 'react-router-dom' +import { MentellCharacter } from './MentellCharacter' +import { poseForPathname } from './characterPoses' +import { useCharacterAppearance } from './useCharacterAppearance' + +/** Small route-aware mascot beside score on mobile (hidden md+ where side column shows). */ +export function MobileHeaderMascot() { + const { pathname } = useLocation() + const { appearance, ready } = useCharacterAppearance() + if (!ready) return null + + return ( + + ) +} diff --git a/src/features/character/applyCharacterAppearance.ts b/src/features/character/applyCharacterAppearance.ts index eaa227f..8601f21 100644 --- a/src/features/character/applyCharacterAppearance.ts +++ b/src/features/character/applyCharacterAppearance.ts @@ -2,6 +2,12 @@ import { applyBlinkOpenState } from './characterBlink' import { charManifest } from './charManifest' import type { CharacterAppearance } from './characterAppearance' +const EYE_TOGGLE_SOLID_FILL: Record = { + g104: '#c8c9cd', + g105: '#986334', + g107: '#4599ba', +} + function setToggleOptionVisible(el: SVGElement, show: boolean) { el.style.display = show ? 'inline' : 'none' } @@ -48,6 +54,19 @@ export function applyCharacterAppearance( if (!(el instanceof SVGElement)) continue setToggleOptionVisible(el, opt.id === active) } + + if (group.key === 'layer18') { + const activeToggle = svg.getElementById(active) + if (activeToggle instanceof SVGGElement) { + const solidFill = EYE_TOGGLE_SOLID_FILL[active] + if (solidFill) { + for (const path of activeToggle.querySelectorAll('path')) { + path.style.fill = solidFill + path.style.opacity = '1' + } + } + } + } } applyBlinkOpenState(svg) diff --git a/src/features/character/charManifest.generated.ts b/src/features/character/charManifest.generated.ts index 9f0eda2..452f012 100644 --- a/src/features/character/charManifest.generated.ts +++ b/src/features/character/charManifest.generated.ts @@ -45,13 +45,10 @@ export const charManifest = { "label": "Hair", "defaultFill": "#311e00", "targetIds": [ - "path85-7", "path85", "path87-4", "path87-4-4", - "path90", "path91", - "path93", "path94", "path95", "path96", @@ -62,18 +59,6 @@ export const charManifest = { } ], "globalFillGroups": [ - { - "key": "sleeves", - "label": "Togglesleeve", - "parentId": "layer19", - "defaultFill": "#261b4f", - "targetIds": [ - "path124", - "path125", - "path126", - "path127" - ] - }, { "key": "shirt", "label": "Toggleshirt", @@ -85,25 +70,21 @@ export const charManifest = { "path119", "path120" ] - } - ], - "toggleGroups": [ + }, { - "key": "layer19", + "key": "sleeves", "label": "Togglesleeve", "parentId": "layer19", - "defaultOption": "g127", - "options": [ - { - "id": "g125", - "label": "Longshirtsleeve" - }, - { - "id": "g127", - "label": "Shortsleeve" - } + "defaultFill": "#261b4f", + "targetIds": [ + "path124", + "path125", + "path126", + "path127" ] - }, + } + ], + "toggleGroups": [ { "key": "layer15", "label": "Toggleshirt", @@ -184,6 +165,22 @@ export const charManifest = { } ] }, + { + "key": "layer19", + "label": "Togglesleeve", + "parentId": "layer19", + "defaultOption": "g127", + "options": [ + { + "id": "g125", + "label": "Longshirtsleeve" + }, + { + "id": "g127", + "label": "Shortsleeve" + } + ] + }, { "key": "blush", "label": "Blush", @@ -207,15 +204,15 @@ export const charManifest = { "path45": "#ddae67", "path65": "#5042ae", "hair_fill": "#311e00", - "sleeves": "#261b4f", - "shirt": "#261b4f" + "shirt": "#261b4f", + "sleeves": "#261b4f" }, "toggles": { - "layer19": "g127", "layer15": "path67", "layer16": "g96", "layer17": "g103", "layer18": "g105", + "layer19": "g127", "blush": "on" } }, diff --git a/src/features/character/characterBlink.ts b/src/features/character/characterBlink.ts index 7e24ef5..d4093ff 100644 --- a/src/features/character/characterBlink.ts +++ b/src/features/character/characterBlink.ts @@ -4,9 +4,8 @@ import { shouldReduceMotion } from '../../shared/motion/useMotionPrefs' function setLayerVisible(svg: SVGSVGElement, id: string, show: boolean) { const el = svg.getElementById(id) - if (el instanceof SVGElement) { - el.style.display = show ? 'inline' : 'none' - } + if (!(el instanceof SVGElement)) return + el.style.display = show ? 'inline' : 'none' } /** Default: open-eye *_BLK layers on, closed-eye BLK layer off. */ diff --git a/src/features/character/characterPaintOrder.ts b/src/features/character/characterPaintOrder.ts new file mode 100644 index 0000000..5ba8435 --- /dev/null +++ b/src/features/character/characterPaintOrder.ts @@ -0,0 +1,69 @@ +import { charManifest } from './charManifest' + +/** Maps charprod art-space paths into the headshot viewBox. */ +export const HEADSHOT_CHAR_MATRIX = + 'matrix(0.44774806,0,0,0.44774806,264.3503,-101.61394)' + +const HEADSHOT_EYE_TOGGLE_IDS = ['g104', 'g105', 'g107'] as const + +const EYE_STACK_IDS = ['layer14', 'layer18', 'g100'] as const + +function raiseAfterAnchor( + svg: SVGSVGElement, + anchorId: string, + ids: readonly string[], +): Element | null { + const anchor = svg.getElementById(anchorId) + const parent = anchor?.parentElement ?? svg + if (!anchor) return null + let insertRef: ChildNode | null = anchor.nextSibling + for (const id of ids) { + const el = svg.getElementById(id) + if (!el) continue + parent.insertBefore(el, insertRef) + insertRef = el.nextSibling + } + return parent +} + +export function normalizeHeadshotEyeToggles(svg: SVGSVGElement) { + for (const id of HEADSHOT_EYE_TOGGLE_IDS) { + const group = svg.getElementById(id) + if (group) group.setAttribute('transform', HEADSHOT_CHAR_MATRIX) + } +} + +/** + * Headshot: hair (layer16) must stay under eye whites; iris + pupils stay on top. + */ +export function fixHeadshotPaintOrder(svg: SVGSVGElement) { + normalizeHeadshotEyeToggles(svg) + + const layer16 = svg.getElementById('layer16') + const layer14 = svg.getElementById('layer14') + const parent = layer16?.parentElement + if (layer14 && parent && layer16) { + parent.insertBefore(layer14, layer16.nextSibling) + } +} + +/** + * Full body: stack whites → iris → pupils above hair/shirt, then arms/sleeves on top. + */ +export function fixCharacterPaintOrder(svg: SVGSVGElement) { + const parent = raiseAfterAnchor(svg, 'layer13', EYE_STACK_IDS) + if (!parent) return + + const sleeveParentId = + charManifest.globalFillGroups.find((group) => group.key === 'sleeves')?.parentId ?? 'layer19' + const frontIds = [ + charManifest.arms.armL.jointId, + charManifest.arms.armR.jointId, + sleeveParentId, + ] + + for (const id of frontIds) { + const el = svg.getElementById(id) + if (el) parent.appendChild(el) + } +} diff --git a/src/features/character/useArmPoseAnimation.ts b/src/features/character/useArmPoseAnimation.ts index 599c76e..1caef8c 100644 --- a/src/features/character/useArmPoseAnimation.ts +++ b/src/features/character/useArmPoseAnimation.ts @@ -4,8 +4,19 @@ import { charManifest } from './charManifest' import type { ArmPose } from './characterPoses' import { motionDuration } from '../../shared/motion/useMotionPrefs' -function shoulderPivot(el: SVGGElement): { cx: number; cy: number } { - const box = el.getBBox() +function shoulderPivot(joint: SVGGElement): { cx: number; cy: number } { + const path = joint.querySelector('path') + if (path) { + try { + const point = path.getPointAtLength(0) + if (Number.isFinite(point.x) && Number.isFinite(point.y)) { + return { cx: point.x, cy: point.y } + } + } catch { + // Fall through to the geometric fallback below. + } + } + const box = joint.getBBox() return { cx: box.x + box.width / 2, cy: box.y } } From 7878abd77ac5431539f45c773c19e1adc7ae2884 Mon Sep 17 00:00:00 2001 From: Kiya Rose Date: Sat, 23 May 2026 18:09:46 -0400 Subject: [PATCH 11/14] Fix --- src/features/character/CharacterLabPage.tsx | 2 +- src/features/character/CharacterNavIcon.tsx | 4 +- .../character/applyCharacterAppearance.ts | 116 ++++++++++++++++-- src/features/character/lab/LabSwitch.tsx | 2 +- src/features/character/lab/OptionDial.tsx | 4 +- 5 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/features/character/CharacterLabPage.tsx b/src/features/character/CharacterLabPage.tsx index e9910dd..4185f59 100644 --- a/src/features/character/CharacterLabPage.tsx +++ b/src/features/character/CharacterLabPage.tsx @@ -140,7 +140,7 @@ export function CharacterLabPage() { type="button" className={`focus-ring rounded-xl border px-3 py-1.5 text-sm ${ pose === id - ? 'border-[var(--ink)] bg-[var(--ink)] text-[var(--paper-bg)]' + ? 'border-[var(--paper-ink)] bg-[var(--paper-ink)] text-[var(--paper-bg)]' : 'border-[var(--paper-border)]' }`} onClick={() => setPose(id)} diff --git a/src/features/character/CharacterNavIcon.tsx b/src/features/character/CharacterNavIcon.tsx index 64f56b8..d1a7dcd 100644 --- a/src/features/character/CharacterNavIcon.tsx +++ b/src/features/character/CharacterNavIcon.tsx @@ -3,9 +3,10 @@ import { useMemo } from 'react' import headshotSvg from '../../../asset/char/headshot.svg?raw' import { applyCharacterAppearance } from './applyCharacterAppearance' import type { CharacterAppearance } from './characterAppearance' +import { fixHeadshotPaintOrder } from './characterPaintOrder' import { useCharacterAppearance } from './useCharacterAppearance' -const NAV_BADGE_VIEWBOX = '16 4 68 74' +const NAV_BADGE_VIEWBOX = '0 0 100 100' function buildBadgeSrc(appearance: CharacterAppearance) { const parsed = new DOMParser().parseFromString(headshotSvg, 'image/svg+xml') @@ -15,6 +16,7 @@ function buildBadgeSrc(appearance: CharacterAppearance) { svg.setAttribute('height', '96') svg.setAttribute('viewBox', NAV_BADGE_VIEWBOX) svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') + fixHeadshotPaintOrder(svg) if (appearance) applyCharacterAppearance(svg, appearance) const serialized = new XMLSerializer().serializeToString(svg) return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}` diff --git a/src/features/character/applyCharacterAppearance.ts b/src/features/character/applyCharacterAppearance.ts index 8601f21..1eca368 100644 --- a/src/features/character/applyCharacterAppearance.ts +++ b/src/features/character/applyCharacterAppearance.ts @@ -2,7 +2,12 @@ import { applyBlinkOpenState } from './characterBlink' import { charManifest } from './charManifest' import type { CharacterAppearance } from './characterAppearance' -const EYE_TOGGLE_SOLID_FILL: Record = { +const SVG_NS = 'http://www.w3.org/2000/svg' +const SVG_INSTANCE_ATTR = 'data-mentell-appearance-instance' + +let nextSvgInstanceId = 0 + +const EYE_TOGGLE_GRADIENT_FILL: Record = { g104: '#c8c9cd', g105: '#986334', g107: '#4599ba', @@ -12,6 +17,101 @@ function setToggleOptionVisible(el: SVGElement, show: boolean) { el.style.display = show ? 'inline' : 'none' } +function parseHexColor(color: string): [number, number, number] | null { + const hex = color.trim().replace(/^#/, '') + if (hex.length === 3) { + return hex.split('').map((part) => parseInt(part + part, 16)) as [number, number, number] + } + if (hex.length === 6 || hex.length === 8) { + return [0, 2, 4].map((start) => parseInt(hex.slice(start, start + 2), 16)) as [ + number, + number, + number, + ] + } + return null +} + +function darkenColor(color: string, amount = 0.42) { + const rgb = parseHexColor(color) + if (!rgb) return color + const [r, g, b] = rgb.map((channel) => Math.max(0, Math.round(channel * (1 - amount)))) + return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, '0')).join('')}` +} + +function hasVisibleStroke(el: SVGElement) { + const stroke = el.style.stroke || el.getAttribute('stroke') || '' + if (!stroke || stroke === 'none') return false + return true +} + +function applyHairFill(el: SVGElement, color: string) { + el.style.fill = color + if (hasVisibleStroke(el)) { + el.style.stroke = darkenColor(color) + el.style.strokeOpacity = '1' + } +} + +function ensureDefs(svg: SVGSVGElement) { + const existing = svg.querySelector('defs') + if (existing instanceof SVGDefsElement) return existing + const defs = document.createElementNS(SVG_NS, 'defs') + svg.insertBefore(defs, svg.firstChild) + return defs +} + +function setStop(stop: SVGStopElement, color: string, opacity: string, offset: string) { + stop.setAttribute('offset', offset) + stop.style.stopColor = color + stop.style.stopOpacity = opacity +} + +function ensureEyeGradient(svg: SVGSVGElement, id: string, color: string) { + const existing = svg.getElementById(id) + if (existing instanceof SVGLinearGradientElement) return existing + + const gradient = document.createElementNS(SVG_NS, 'linearGradient') + gradient.id = id + gradient.setAttribute('x1', '0') + gradient.setAttribute('y1', '0') + gradient.setAttribute('x2', '0') + gradient.setAttribute('y2', '1') + + const shadow = document.createElementNS(SVG_NS, 'stop') + setStop(shadow, '#000000', '1', '0') + gradient.appendChild(shadow) + + const iris = document.createElementNS(SVG_NS, 'stop') + setStop(iris, color, '1', '0.72') + gradient.appendChild(iris) + + ensureDefs(svg).appendChild(gradient) + return gradient +} + +function svgInstanceId(svg: SVGSVGElement) { + const existing = svg.getAttribute(SVG_INSTANCE_ATTR) + if (existing) return existing + nextSvgInstanceId += 1 + const id = String(nextSvgInstanceId) + svg.setAttribute(SVG_INSTANCE_ATTR, id) + return id +} + +function applyEyeGradient(svg: SVGSVGElement, activeToggle: SVGGElement, active: string) { + const color = EYE_TOGGLE_GRADIENT_FILL[active] + if (!color) return + + const instanceId = svgInstanceId(svg) + activeToggle.querySelectorAll('path').forEach((path, index) => { + const gradientId = `mentell-eye-${instanceId}-${active}-${index}` + ensureEyeGradient(svg, gradientId, color) + path.style.fill = `url(#${gradientId})` + path.style.opacity = '1' + }) +} + export function applyCharacterAppearance( svg: SVGSVGElement, appearance: CharacterAppearance, @@ -25,6 +125,10 @@ export function applyCharacterAppearance( for (const id of targetIds) { const el = svg.getElementById(id) if (!(el instanceof SVGElement)) continue + if (fillable.key === 'hair_fill') { + applyHairFill(el, color) + continue + } el.style.fill = color } } @@ -45,6 +149,7 @@ export function applyCharacterAppearance( const blush = svg.getElementById(group.elementId) if (blush instanceof SVGElement) { blush.style.display = active === 'on' ? 'inline' : 'none' + blush.style.opacity = active === 'on' ? '1' : '0' } continue } @@ -58,13 +163,8 @@ export function applyCharacterAppearance( if (group.key === 'layer18') { const activeToggle = svg.getElementById(active) if (activeToggle instanceof SVGGElement) { - const solidFill = EYE_TOGGLE_SOLID_FILL[active] - if (solidFill) { - for (const path of activeToggle.querySelectorAll('path')) { - path.style.fill = solidFill - path.style.opacity = '1' - } - } + activeToggle.style.opacity = '1' + applyEyeGradient(svg, activeToggle, active) } } } diff --git a/src/features/character/lab/LabSwitch.tsx b/src/features/character/lab/LabSwitch.tsx index 1c86b7b..10a3ba5 100644 --- a/src/features/character/lab/LabSwitch.tsx +++ b/src/features/character/lab/LabSwitch.tsx @@ -15,7 +15,7 @@ export function LabSwitch({ role="switch" aria-checked={checked} className={`focus-ring relative h-7 w-12 shrink-0 rounded-full border border-[var(--paper-border)] transition-colors ${ - checked ? 'bg-[var(--ink)]' : 'bg-[var(--paper-bg)]' + checked ? 'bg-[var(--paper-ink)]' : 'bg-[var(--paper-bg)]' }`} onClick={() => onChange(!checked)} > diff --git a/src/features/character/lab/OptionDial.tsx b/src/features/character/lab/OptionDial.tsx index bd39865..aaae6d7 100644 --- a/src/features/character/lab/OptionDial.tsx +++ b/src/features/character/lab/OptionDial.tsx @@ -29,8 +29,8 @@ export function OptionDial({ aria-pressed={pressed} className={`focus-ring min-w-[2.25rem] rounded-lg px-2.5 py-1.5 text-sm font-medium tabular-nums ${ pressed - ? 'bg-[var(--ink)] text-[var(--paper-bg)]' - : 'text-[var(--ink)] opacity-70 hover:opacity-100' + ? 'bg-[var(--paper-ink)] text-[var(--paper-bg)]' + : 'text-[var(--paper-ink)] opacity-70 hover:opacity-100' }`} onClick={() => onChange(opt.id)} > From 88fe4a49e59a0da0b948edcc4ba93051e14fc9dd Mon Sep 17 00:00:00 2001 From: Kiya Rose Date: Sat, 23 May 2026 19:12:07 -0400 Subject: [PATCH 12/14] Some hotfixes --- .gitignore | 1 + asset/char/charprod.svg | 16 +- package.json | 1 + scripts/pose_lab.exs | 706 +++++++++++++++++++++++ src/features/character/characterPoses.ts | 8 +- 5 files changed, 721 insertions(+), 11 deletions(-) create mode 100644 scripts/pose_lab.exs diff --git a/.gitignore b/.gitignore index f5f24a6..051b6ed 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +tmp *.local .env diff --git a/asset/char/charprod.svg b/asset/char/charprod.svg index ca52ad9..1d011ca 100644 --- a/asset/char/charprod.svg +++ b/asset/char/charprod.svg @@ -24,15 +24,15 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" inkscape:document-units="mm" - inkscape:zoom="0.49076868" - inkscape:cx="535.89402" - inkscape:cy="1024.9228" + inkscape:zoom="0.62464089" + inkscape:cx="393.82629" + inkscape:cy="765.23969" inkscape:window-width="1472" inkscape:window-height="771" inkscape:window-x="0" inkscape:window-y="33" inkscape:window-maximized="1" - inkscape:current-layer="layer13" /> help() + ["help"] -> help() + ["list"] -> list_poses() + ["get", pose] -> get_pose(pose) + ["set", pose, arm_l, arm_r] -> set_pose(pose, arm_l, arm_r) + ["preview" | rest] -> preview(rest) + ["app" | rest] -> app(rest) + _ -> abort("Unknown command.\n\n#{usage()}") + end + end + + defp help do + IO.puts(usage()) + end + + defp usage do + """ + Mentell character pose lab + + Commands: + elixir scripts/pose_lab.exs list + elixir scripts/pose_lab.exs get wave + elixir scripts/pose_lab.exs set wave 104 -28 + elixir scripts/pose_lab.exs preview [--pose wave] [--out tmp/character-pose-lab.html] + elixir scripts/pose_lab.exs app [--pose wave] [--port 4057] + + preview writes a standalone HTML tool. + app starts a tiny local desktop-style web app that reloads current assets on refresh + and can save pose numbers back to characterPoses.ts. + """ + end + + defp list_poses do + poses() + |> Enum.each(fn {name, left, right} -> + IO.puts( + String.pad_trailing(name, 10) <> + " armL: #{format_number(left)}\tarmR: #{format_number(right)}" + ) + end) + end + + defp get_pose(pose) do + case Enum.find(poses(), fn {name, _, _} -> name == pose end) do + nil -> abort("Pose not found: #{pose}") + {name, left, right} -> + IO.puts("#{name}: { armL: #{format_number(left)}, armR: #{format_number(right)} }") + end + end + + defp set_pose(pose, arm_l, arm_r) do + left = parse_number!(arm_l, "armL") + right = parse_number!(arm_r, "armR") + write_pose!(pose, left, right) + IO.puts("Updated #{Path.relative_to_cwd(@poses_path)}") + IO.puts("#{pose}: { armL: #{format_number(left)}, armR: #{format_number(right)} }") + end + + defp write_pose!(pose, left, right) do + source = File.read!(@poses_path) + + pattern = + ~r/(#{Regex.escape(pose)}:\s*\{\s*armL:\s*)-?\d+(?:\.\d+)?(,\s*armR:\s*)-?\d+(?:\.\d+)?(\s*\})/ + + unless Regex.match?(pattern, source) do + raise ArgumentError, "Pose not found in #{@poses_path}: #{pose}" + end + + next = + Regex.replace(pattern, source, fn _, before_left, middle, after_right -> + before_left <> format_number(left) <> middle <> format_number(right) <> after_right + end) + + File.write!(@poses_path, next) + end + + defp preview(args) do + {opts, rest, invalid} = + OptionParser.parse(args, + strict: [out: :string, pose: :string], + aliases: [o: :out, p: :pose] + ) + + if rest != [] or invalid != [] do + abort("Invalid preview options.\n\n#{usage()}") + end + + out = opts[:out] || @default_out + pose = opts[:pose] || "wave" + File.mkdir_p!(Path.dirname(out)) + File.write!(out, html(pose, false)) + IO.puts("Wrote #{Path.relative_to_cwd(out)}") + IO.puts("Open it in your browser to preview poses and cosmetics.") + end + + defp app(args) do + {opts, rest, invalid} = + OptionParser.parse(args, + strict: [port: :integer, pose: :string], + aliases: [p: :pose] + ) + + if rest != [] or invalid != [] do + abort("Invalid app options.\n\n#{usage()}") + end + + port = opts[:port] || 4057 + pose = opts[:pose] || "wave" + {:ok, socket} = :gen_tcp.listen(port, [:binary, active: false, packet: :raw, reuseaddr: true]) + url = "http://127.0.0.1:#{port}/" + + IO.puts("Mentell Pose Lab app running at #{url}") + IO.puts("Refresh the page after editing SVG assets or regenerating the manifest.") + IO.puts("Press Ctrl+C to stop.") + + accept_loop(socket, pose) + end + + defp accept_loop(socket, pose) do + {:ok, client} = :gen_tcp.accept(socket) + spawn(fn -> handle_client(client, pose) end) + accept_loop(socket, pose) + end + + defp handle_client(client, pose) do + request = read_request(client, "") + response = route(request, pose) + :ok = :gen_tcp.send(client, response) + :gen_tcp.close(client) + rescue + error -> + body = "Pose lab error: #{Exception.message(error)}" + :gen_tcp.send(client, response(500, "text/plain; charset=utf-8", body)) + :gen_tcp.close(client) + end + + defp read_request(client, acc) do + {:ok, chunk} = :gen_tcp.recv(client, 0, 1000) + next = acc <> chunk + + if String.contains?(next, "\r\n\r\n") do + [head, body] = String.split(next, "\r\n\r\n", parts: 2) + content_length = content_length(head) + + if byte_size(body) >= content_length do + next + else + read_request(client, next) + end + else + read_request(client, next) + end + end + + defp content_length(head) do + head + |> String.split("\r\n") + |> Enum.find_value(0, fn line -> + case String.split(line, ":", parts: 2) do + [name, value] -> + if String.downcase(name) == "content-length" do + value |> String.trim() |> String.to_integer() + end + + _ -> + nil + end + end) + end + + defp route(request, pose) do + [head, body] = String.split(request, "\r\n\r\n", parts: 2) + [request_line | _headers] = String.split(head, "\r\n") + [method, path | _] = String.split(request_line, " ") + + case {method, path} do + {"GET", "/"} -> + response(200, "text/html; charset=utf-8", html(pose, true)) + + {"HEAD", "/"} -> + response(200, "text/html; charset=utf-8", "") + + {"GET", "/health"} -> + response(200, "text/plain; charset=utf-8", "ok") + + {"HEAD", "/health"} -> + response(200, "text/plain; charset=utf-8", "") + + {"POST", "/api/pose"} -> + save_pose_from_body(body) + + _ -> + response(404, "text/plain; charset=utf-8", "not found") + end + end + + defp save_pose_from_body(body) do + params = URI.decode_query(body) + pose = Map.fetch!(params, "pose") + left = parse_number!(Map.fetch!(params, "armL"), "armL") + right = parse_number!(Map.fetch!(params, "armR"), "armR") + write_pose!(pose, left, right) + + response( + 200, + "application/json; charset=utf-8", + ~s({"ok":true,"message":"Saved #{pose}: { armL: #{format_number(left)}, armR: #{format_number(right)} }"}) + ) + rescue + error -> + response(400, "application/json; charset=utf-8", ~s({"ok":false,"message":#{inspect(Exception.message(error))}})) + end + + defp response(status, content_type, body) do + reason = + case status do + 200 -> "OK" + 400 -> "Bad Request" + 404 -> "Not Found" + 500 -> "Internal Server Error" + end + + [ + "HTTP/1.1 #{status} #{reason}\r\n", + "content-type: #{content_type}\r\n", + "cache-control: no-store\r\n", + "content-length: #{byte_size(body)}\r\n", + "connection: close\r\n", + "\r\n", + body + ] + end + + defp poses do + source = File.read!(@poses_path) + regex = ~r/(\w+):\s*\{\s*armL:\s*(-?\d+(?:\.\d+)?),\s*armR:\s*(-?\d+(?:\.\d+)?)\s*\}/ + + Regex.scan(regex, source) + |> Enum.map(fn [_, name, left, right] -> + {name, parse_number!(left, "armL"), parse_number!(right, "armR")} + end) + end + + defp manifest_json do + source = File.read!(@manifest_path) + + case Regex.run(~r/export const charManifest = (\{.*?\}) as const/s, source) do + [_, json] -> json + _ -> abort("Could not parse char manifest from #{@manifest_path}") + end + end + + defp html(initial_pose, server_mode) do + svg = File.read!(@svg_path) + poses_json = poses_to_json(poses()) + + """ + + + + + + Mentell Pose Lab + + + +
+
+

Mentell Pose Lab

+

Tune arm rotations and cosmetics against the current production SVG.

+

Edit assets in asset/char, run sync if labels changed, then refresh this app.

+ + + + + + + +

Cosmetics

+
+
+ +

Output

+ +
+ + + +
+

+
+ +
+
#{svg}
+
+
+ + + + + """ + end + + defp poses_to_json(poses) do + entries = + poses + |> Enum.map(fn {name, left, right} -> + ~s("#{name}":{"armL":#{format_number(left)},"armR":#{format_number(right)}}) + end) + + "{" <> Enum.join(entries, ",") <> "}" + end + + defp parse_number!(value, label) do + case Float.parse(value) do + {number, ""} -> number + _ -> abort("Invalid #{label} number: #{value}") + end + end + + defp format_number(number) when is_float(number) do + if number == Float.round(number) do + Integer.to_string(round(number)) + else + :erlang.float_to_binary(number, decimals: 2) + |> String.trim_trailing("0") + |> String.trim_trailing(".") + end + end + + defp abort(message) do + IO.puts(:stderr, message) + System.halt(1) + end +end + +MentellPoseLab.main(System.argv()) diff --git a/src/features/character/characterPoses.ts b/src/features/character/characterPoses.ts index 3d90bdd..a379849 100644 --- a/src/features/character/characterPoses.ts +++ b/src/features/character/characterPoses.ts @@ -4,10 +4,10 @@ export type ArmPose = { armL: number; armR: number } export const CHARACTER_POSES: Record = { idle: { armL: 0, armR: 0 }, - present: { armL: 42, armR: -34 }, - think: { armL: 58, armR: -14 }, - write: { armL: 24, armR: -52 }, - shop: { armL: 24, armR: -44 }, + present: { armL: -38, armR: -53 }, + think: { armL: 0, armR: 131 }, + write: { armL: -34, armR: 37 }, + shop: { armL: 22, armR: -75 }, wave: { armL: 104, armR: -28 }, } From f7b7ac15461933d7a185ef91af2a09099d7c10a5 Mon Sep 17 00:00:00 2001 From: Kiya Rose Date: Sat, 23 May 2026 19:12:56 -0400 Subject: [PATCH 13/14] Bump Version --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index b9268da..6ecac68 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.1 \ No newline at end of file +1.9.5 \ No newline at end of file diff --git a/package.json b/package.json index 22726c1..974f755 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mentell", "private": true, - "version": "1.8.1", + "version": "1.9.5", "type": "module", "scripts": { "sync:assets": "node scripts/sync-assets.mjs", From acf9d24a818bae866261783786c2ede991cb3a8f Mon Sep 17 00:00:00 2001 From: Kiya Rose Date: Sat, 23 May 2026 22:35:04 -0400 Subject: [PATCH 14/14] BugFix accessories --- asset/char/charprod.svg | 899 ++++++++++++++++- asset/shop/shoppe-items.json | 338 +++++++ public/asset/char/charprod.svg | 907 +++++++++++++++++- scripts/generate-char-manifest.mjs | 128 ++- src/features/character/CharacterLabPage.tsx | 122 ++- src/features/character/MentellCharacter.tsx | 66 +- .../character/applyCharacterAppearance.ts | 184 +++- .../character/charManifest.generated.ts | 336 ++++++- src/features/character/characterBlink.ts | 28 +- src/features/character/useArmPoseAnimation.ts | 38 +- .../character/usePetAccessoryAnimation.ts | 90 ++ src/features/debug/DebugPanel.tsx | 5 +- src/features/shop/Shoppe.tsx | 64 +- src/features/shop/shopCatalog.ts | 161 +++- src/features/shop/shopCharacterAccessories.ts | 86 ++ src/features/shop/shopInventory.ts | 60 ++ 16 files changed, 3451 insertions(+), 61 deletions(-) create mode 100644 src/features/character/usePetAccessoryAnimation.ts create mode 100644 src/features/shop/shopCharacterAccessories.ts diff --git a/asset/char/charprod.svg b/asset/char/charprod.svg index 1d011ca..9ebaad5 100644 --- a/asset/char/charprod.svg +++ b/asset/char/charprod.svg @@ -24,16 +24,56 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" inkscape:document-units="mm" - inkscape:zoom="0.62464089" - inkscape:cx="393.82629" - inkscape:cy="765.23969" + inkscape:zoom="0.61955875" + inkscape:cx="592.35706" + inkscape:cy="1263.8027" inkscape:window-width="1472" inkscape:window-height="771" inkscape:window-x="0" inkscape:window-y="33" inkscape:window-maximized="1" - inkscape:current-layer="layer11" />
+ inkscape:transform-center-y="16.191141" /> diff --git a/asset/shop/shoppe-items.json b/asset/shop/shoppe-items.json index 8e2a18b..8fa1c49 100644 --- a/asset/shop/shoppe-items.json +++ b/asset/shop/shoppe-items.json @@ -143,6 +143,344 @@ } } }, + { + "id": "accessory-pet-cat", + "type": "characterAccessory", + "name": "Desk Cat Companion", + "description": "A small cat friend for the character scene.", + "cost": 160, + "characterAccessory": { + "scope": "pet", + "exclusiveGroup": "pet", + "toggles": [ + { + "groupKey": "togglepet", + "optionIds": ["Catbase"] + } + ], + "fillKeys": ["catbase"] + } + }, + { + "id": "accessory-pet-dog", + "type": "characterAccessory", + "name": "Desk Dog Companion", + "description": "A small dog friend for the character scene.", + "cost": 160, + "characterAccessory": { + "scope": "pet", + "exclusiveGroup": "pet", + "toggles": [ + { + "groupKey": "togglepet", + "optionIds": ["Dogbase"] + } + ], + "fillKeys": ["dogbase"] + } + }, + { + "id": "accessory-watch", + "type": "characterAccessory", + "name": "Tiny Watch", + "description": "A little watch that follows both wrists during character poses.", + "cost": 95, + "characterAccessory": { + "scope": "wrist", + "exclusiveGroup": "wrist", + "fillKeys": ["watchr"], + "defaultChoiceId": "right", + "choices": [ + { + "id": "right", + "label": "Right", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["WatchR"] }], + "anchoredIds": { "armR": ["WatchR"] } + }, + { + "id": "left", + "label": "Left", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["WatchL"] }], + "anchoredIds": { "armL": ["WatchL"] } + }, + { + "id": "both", + "label": "Both", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["WatchR", "WatchL"] }], + "anchoredIds": { "armL": ["WatchL"], "armR": ["WatchR"] } + } + ] + } + }, + { + "id": "accessory-wristband", + "type": "characterAccessory", + "name": "Wristband", + "description": "Simple wristbands that stay anchored through arm animations.", + "cost": 85, + "characterAccessory": { + "scope": "wrist", + "exclusiveGroup": "wrist", + "fillKeys": ["bandr", "bandl"], + "defaultChoiceId": "right", + "choices": [ + { + "id": "right", + "label": "Right", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["BandR"] }], + "anchoredIds": { "armR": ["BandR"] } + }, + { + "id": "left", + "label": "Left", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["BandL"] }], + "anchoredIds": { "armL": ["BandL"] } + }, + { + "id": "both", + "label": "Both", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["BandR", "BandL"] }], + "anchoredIds": { "armL": ["BandL"], "armR": ["BandR"] } + } + ] + } + }, + { + "id": "accessory-hair-object", + "type": "characterAccessory", + "name": "Lemon Hair Pin", + "description": "A colorable hair object for the character.", + "cost": 120, + "characterAccessory": { + "scope": "hair", + "exclusiveGroup": "hair", + "parts": ["Lemmon"], + "fillKeys": ["lemmon"] + } + }, + { + "id": "accessory-full-body-ghost", + "type": "characterAccessory", + "name": "Ghost Costume", + "description": "A premium full-body costume that replaces the base skin.", + "cost": 475, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "fullBody", + "parts": ["GHOST", "GHOST_armL_joint", "GHOST_armR_joint"], + "hideSkin": true, + "hideBaseClothes": true, + "anchoredIds": { + "armL": ["GHOST_armL_joint"], + "armR": ["GHOST_armR_joint"] + } + } + }, + { + "id": "accessory-full-body-skeleton", + "type": "characterAccessory", + "name": "Skeleton Costume", + "description": "A premium full-body skeleton costume that replaces the base skin.", + "cost": 525, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "fullBody", + "parts": ["SKELETON", "SKELETON_armL_joint", "SKELETON_armR_joint"], + "hideSkin": true, + "hideBaseClothes": true, + "anchoredIds": { + "armL": ["SKELETON_armL_joint"], + "armR": ["SKELETON_armR_joint"] + } + } + }, + { + "id": "accessory-boots", + "type": "characterAccessory", + "name": "Boots", + "description": "A pair of sturdy character boots.", + "cost": 140, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shoes", + "toggles": [{ "groupKey": "toggleshoes", "optionIds": ["BOOTS"] }] + } + }, + { + "id": "accessory-sneakers", + "type": "characterAccessory", + "name": "Sneakers", + "description": "A pair of casual character sneakers.", + "cost": 140, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shoes", + "toggles": [{ "groupKey": "toggleshoes", "optionIds": ["SNEEKER"] }] + } + }, + { + "id": "accessory-heels", + "type": "characterAccessory", + "name": "Heels", + "description": "A pair of character heels.", + "cost": 140, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shoes", + "toggles": [{ "groupKey": "toggleshoes", "optionIds": ["HEEL"] }] + } + }, + { + "id": "accessory-gloves", + "type": "characterAccessory", + "name": "Gloves", + "description": "Colorable gloves for both hands.", + "cost": 125, + "characterAccessory": { + "scope": "wrist", + "exclusiveGroup": "wrist", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["GloveR", "GloveL"] }], + "fillKeys": ["glover", "glovel"], + "anchoredIds": { "armL": ["GloveL"], "armR": ["GloveR"] } + } + }, + { + "id": "accessory-pockets", + "type": "characterAccessory", + "name": "Pockets", + "description": "Colorable trouser pockets.", + "cost": 80, + "characterAccessory": { + "scope": "pants", + "exclusiveGroup": "pants", + "toggles": [{ "groupKey": "togglepants", "optionIds": ["PocketR", "PocketL"] }], + "fillKeys": ["pocketr", "pocketl"] + } + }, + { + "id": "accessory-shirt-star", + "type": "characterAccessory", + "name": "Star Shirt Patch", + "description": "A colorable shirt patch for the character.", + "cost": 110, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shirtPatch", + "toggles": [{ "groupKey": "layer6", "optionIds": ["STAR"] }], + "fillKeys": ["star"] + } + }, + { + "id": "accessory-shirt-circle", + "type": "characterAccessory", + "name": "Circle Shirt Patch", + "description": "A colorable circle shirt patch for the character.", + "cost": 100, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shirtPatch", + "toggles": [{ "groupKey": "layer6", "optionIds": ["CIRCLE"] }], + "fillKeys": ["circle"] + } + }, + { + "id": "accessory-shirt-square", + "type": "characterAccessory", + "name": "Square Shirt Patch", + "description": "A colorable square shirt patch for the character.", + "cost": 100, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shirtPatch", + "toggles": [{ "groupKey": "layer6", "optionIds": ["SQUARE"] }], + "fillKeys": ["square"] + } + }, + { + "id": "accessory-glasses-square", + "type": "characterAccessory", + "name": "Square Glasses", + "description": "Square glasses for the character.", + "cost": 130, + "characterAccessory": { + "scope": "face", + "exclusiveGroup": "face", + "toggles": [{ "groupKey": "toggleface", "optionIds": ["Glassessquare"] }] + } + }, + { + "id": "accessory-glasses-circle", + "type": "characterAccessory", + "name": "Circle Glasses", + "description": "Round glasses for the character.", + "cost": 130, + "characterAccessory": { + "scope": "face", + "exclusiveGroup": "face", + "toggles": [{ "groupKey": "toggleface", "optionIds": ["Glassescircle"] }] + } + }, + { + "id": "accessory-bandana", + "type": "characterAccessory", + "name": "Bandana", + "description": "A colorable face bandana.", + "cost": 120, + "characterAccessory": { + "scope": "face", + "exclusiveGroup": "face", + "toggles": [{ "groupKey": "toggleface", "optionIds": ["Bandana"] }], + "fillKeys": ["bandana"] + } + }, + { + "id": "accessory-mask", + "type": "characterAccessory", + "name": "Mask", + "description": "A face mask for the character.", + "cost": 120, + "characterAccessory": { + "scope": "face", + "exclusiveGroup": "face", + "toggles": [{ "groupKey": "toggleface", "optionIds": ["Mask"] }] + } + }, + { + "id": "accessory-cowboy-hat", + "type": "characterAccessory", + "name": "Cowboy Hat", + "description": "A wide-brimmed cowboy hat.", + "cost": 170, + "characterAccessory": { + "scope": "hat", + "exclusiveGroup": "hat", + "toggles": [{ "groupKey": "togglehat", "optionIds": ["Cowboy"] }] + } + }, + { + "id": "accessory-headcover", + "type": "characterAccessory", + "name": "Headcover", + "description": "A translucent full headcover.", + "cost": 180, + "characterAccessory": { + "scope": "hat", + "exclusiveGroup": "hat", + "toggles": [{ "groupKey": "togglehat", "optionIds": ["Headcover"] }] + } + }, + { + "id": "accessory-crown", + "type": "characterAccessory", + "name": "Crown", + "description": "A bright crown for celebratory days.", + "cost": 220, + "characterAccessory": { + "scope": "hat", + "exclusiveGroup": "hat", + "toggles": [{ "groupKey": "togglehat", "optionIds": ["Crown"] }] + } + }, { "id": "image-gift-print", "type": "image", diff --git a/public/asset/char/charprod.svg b/public/asset/char/charprod.svg index ca52ad9..9ebaad5 100644 --- a/public/asset/char/charprod.svg +++ b/public/asset/char/charprod.svg @@ -24,16 +24,56 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" inkscape:document-units="mm" - inkscape:zoom="0.49076868" - inkscape:cx="535.89402" - inkscape:cy="1024.9228" + inkscape:zoom="0.61955875" + inkscape:cx="592.35706" + inkscape:cy="1263.8027" inkscape:window-width="1472" inkscape:window-height="771" inkscape:window-x="0" inkscape:window-y="33" inkscape:window-maximized="1" - inkscape:current-layer="layer13" /> + inkscape:transform-center-y="16.191141" /> diff --git a/scripts/generate-char-manifest.mjs b/scripts/generate-char-manifest.mjs index 6052bf2..589a2cd 100644 --- a/scripts/generate-char-manifest.mjs +++ b/scripts/generate-char-manifest.mjs @@ -47,6 +47,16 @@ function parseFill(style) { return null } +function styleForElementId(block, id) { + const tagRe = /<(?:path|ellipse|rect)\b[^>]*>/gi + let m + while ((m = tagRe.exec(block)) !== null) { + const tag = m[0] + if (tag.match(ID_RE)?.[1] === id) return tag.match(STYLE_RE)?.[1] ?? null + } + return null +} + function isVisibleOpening(tag) { const style = tag.match(STYLE_RE)?.[1] ?? '' if (!style.includes('display:')) return true @@ -85,7 +95,12 @@ function directToggleChildren(parentInner) { i += 3 continue } - if (depth === 0 && parentInner.startsWith(']*>/g + const tagRe = /<(path|ellipse|rect)\b[^>]*>/g let m while ((m = tagRe.exec(block)) !== null) { const tag = m[0] @@ -161,16 +176,62 @@ function collectSleeveIdsBySide(xml, sleeveParentId) { /** armL / armR paths (gradient fills) tinted with skin. */ function collectArmSkinPathIds(xml) { + const accessoryStart = xml.search(/]*inkscape:label="accessoryengine"/i) + const bodyXml = accessoryStart >= 0 ? xml.slice(0, accessoryStart) : xml const ids = [] const re = /]*inkscape:label="arm[LR]"[^>]*>/gi let m - while ((m = re.exec(xml)) !== null) { + while ((m = re.exec(bodyXml)) !== null) { const id = m[0].match(ID_RE)?.[1] if (id) ids.push(id) } return [...new Set(ids)] } +function fillKeyForToggleOption(label) { + return humanize(label).toLowerCase().replace(/\s+/g, '_') +} + +function collectAccessoryFillables(xml, accessoryStart) { + if (accessoryStart < 0) return [] + const accessoryXml = xml.slice(accessoryStart) + const found = [] + const seen = new Set() + const tagRe = /<(g|path|ellipse|rect)\b[^>]*inkscape:label="([^"]*_TOGGLE_III)"[^>]*>/gi + let m + while ((m = tagRe.exec(accessoryXml)) !== null) { + const tag = m[0] + const tagName = m[1] + const label = m[2] + const id = tag.match(ID_RE)?.[1] + if (!id) continue + const key = fillKeyForToggleOption(label) + if (seen.has(key)) continue + const absoluteIndex = accessoryStart + m.index + const block = tagName.toLowerCase() === 'g' ? extractBalancedGroup(xml, absoluteIndex) : tag + if (!block) continue + const targetIds = collectSolidFillPathIds(block) + const tagFill = parseFill(tag.match(STYLE_RE)?.[1]) + if (!targetIds.length && tagFill) targetIds.push(id) + if (!targetIds.length) continue + const defaultFill = + targetIds + .map((targetId) => { + return parseFill(styleForElementId(block, targetId)) + }) + .find(Boolean) ?? tagFill ?? '#261b4f' + seen.add(key) + found.push({ + id, + key, + label: humanize(label), + defaultFill, + targetIds, + }) + } + return found +} + /** All path/ellipse under togglehair (layer16) with a solid hex fill. */ function collectHairFillIds(xml) { const parentMatch = /]*id="layer16"[^>]*inkscape:label="[^"]*"[^>]*>/i.exec(xml) @@ -217,10 +278,7 @@ function extractAppearanceFromSvg(xml) { extractBalancedGroup(xml, xml.search(/]*id="layer16"/i)) ?? '' const hairColor = hairFillIds .map((id) => { - const m = block.match( - new RegExp(`<(?:path|ellipse)\\b[^>]*id="${id}"[^>]*style="([^"]*)"`, 'i'), - ) - const fill = m ? parseFill(m[1]) : null + const fill = parseFill(styleForElementId(block, id)) return fill && fill.toLowerCase() !== '#ffffff' ? fill : null }) .find(Boolean) @@ -243,10 +301,7 @@ function extractAppearanceFromSvg(xml) { const targetIds = collectSolidFillPathIds(block) const color = targetIds .map((id) => { - const m = block.match( - new RegExp(`<(?:path|ellipse)\\b[^>]*id="${id}"[^>]*style="([^"]*)"`, 'i'), - ) - return m ? parseFill(m[1]) : null + return parseFill(styleForElementId(block, id)) }) .find(Boolean) if (color) fills[key] = color @@ -291,7 +346,9 @@ function collectBlinkLayers(xml) { } function applyHeadshotDefaults(structure, headshotDefaults) { + const baseFillKeys = new Set(['path45', 'path65', 'hair_fill']) for (const f of structure.fillables) { + if (!baseFillKeys.has(f.key)) continue const fromHeadshot = headshotDefaults.fills[f.key] if (fromHeadshot) f.defaultFill = fromHeadshot } @@ -326,6 +383,7 @@ async function main() { const globalFillGroups = [] const toggleGroups = [] const globalFillParentIds = new Set() + const accessoryStart = xml.search(/]*inkscape:label="accessoryengine"/i) const viewBoxMatch = xml.match(/viewBox="([^"]+)"/) const viewBox = viewBoxMatch?.[1] ?? '0 0 296 374' @@ -372,10 +430,7 @@ async function main() { const defaultFill = hairFillIds .map((id) => { - const m = block.match( - new RegExp(`<(?:path|ellipse)\\b[^>]*id="${id}"[^>]*style="([^"]*)"`, 'i'), - ) - const fill = m ? parseFill(m[1]) : null + const fill = parseFill(styleForElementId(block, id)) return fill && fill.toLowerCase() !== '#ffffff' ? fill : null }) .find(Boolean) ?? '#311e00' @@ -389,6 +444,12 @@ async function main() { }) } + for (const fillable of collectAccessoryFillables(xml, accessoryStart)) { + if (!fillables.some((entry) => entry.key === fillable.key)) { + fillables.push(fillable) + } + } + const parentRe = /]*inkscape:label="([^"]*)"[^>]*>/gi while ((tm = parentRe.exec(xml)) !== null) { const parentLabel = tm[1] @@ -406,10 +467,7 @@ async function main() { const defaultFill = targetIds .map((id) => { - const m = block.match( - new RegExp(`<(?:path|ellipse)\\b[^>]*id="${id}"[^>]*style="([^"]*)"`, 'i'), - ) - return m ? parseFill(m[1]) : null + return parseFill(styleForElementId(block, id)) }) .find(Boolean) ?? '#261b4f' @@ -423,6 +481,38 @@ async function main() { } const children = directToggleChildren(inner) + const isAccessoryParent = accessoryStart >= 0 && tm.index > accessoryStart + for (const child of isAccessoryParent ? children : []) { + if (!/_III$/i.test(child.label)) continue + const childMatch = new RegExp( + `<(?:g|path|ellipse|rect)\\b[^>]*id="${child.id}"[^>]*`, + 'i', + ).exec(block) + if (!childMatch) continue + const childBlock = block.startsWith(' { + return parseFill(styleForElementId(childBlock, id)) + }) + .find(Boolean) ?? childFill ?? '#261b4f' + const key = fillKeyForToggleOption(child.label) + if (fillables.some((entry) => entry.key === key)) continue + fillables.push({ + id: child.id, + key, + label: humanize(child.label), + defaultFill, + targetIds, + }) + } if (children.length < 2) continue const defaultOption = children.find((c) => c.visible)?.id ?? children[0].id toggleGroups.push({ diff --git a/src/features/character/CharacterLabPage.tsx b/src/features/character/CharacterLabPage.tsx index 4185f59..07a41de 100644 --- a/src/features/character/CharacterLabPage.tsx +++ b/src/features/character/CharacterLabPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { MentellCharacter } from './MentellCharacter' import { charManifest } from './charManifest' import { POSE_LABELS } from './characterPoses' @@ -11,10 +11,41 @@ import { import { OptionDial } from './lab/OptionDial' import { LabSwitch } from './lab/LabSwitch' import { useCharacterAppearance } from './useCharacterAppearance' +import { + equipCharacterAccessoryItem, + loadShopInventory, + setCharacterAccessoryChoice, + subscribeShopInventory, + type ShopInventory, +} from '../shop/shopInventory' +import { + loadShopCatalog, + type CharacterAccessoryItem, + type ShopCatalogItem, +} from '../shop/shopCatalog' +import { accessoryChoiceId, exclusiveAccessoryIdsFor } from '../shop/shopCharacterAccessories' + +function isCharacterAccessory(item: ShopCatalogItem): item is CharacterAccessoryItem { + return item.type === 'characterAccessory' +} export function CharacterLabPage() { const { appearance, setAppearance, resetAppearance, ready } = useCharacterAppearance() const [pose, setPose] = useState('wave') + const catalog = useMemo(() => loadShopCatalog(), []) + const [inventory, setInventory] = useState(() => loadShopInventory()) + + useEffect(() => subscribeShopInventory((next) => setInventory(next)), []) + + const ownedAccessories = useMemo(() => { + const owned = new Set(inventory.ownedItemIds) + return catalog.items.filter(isCharacterAccessory).filter((item) => owned.has(item.id)) + }, [catalog.items, inventory.ownedItemIds]) + + const equippedAccessoryIds = useMemo( + () => new Set(inventory.equipped.characterAccessoryIds), + [inventory.equipped.characterAccessoryIds], + ) const toggleByKey = useMemo(() => { const map = new Map( @@ -22,7 +53,6 @@ export function CharacterLabPage() { ) return map }, []) - function setFill(key: string, value: string) { setAppearance((prev) => ({ ...prev, @@ -48,6 +78,12 @@ export function CharacterLabPage() { return global?.defaultFill ?? '#000000' } + function toggleAccessory(item: CharacterAccessoryItem) { + equipCharacterAccessoryItem(item.id, { + exclusiveWith: exclusiveAccessoryIdsFor(item, catalog.items), + }) + } + return (
Character lab
@@ -114,6 +150,88 @@ export function CharacterLabPage() {
+
+ Shoppe accessories + {ownedAccessories.length === 0 ? ( +
+ Unlock character accessories in the Shoppe to equip and customize them here. +
+ ) : ( +
+ {ownedAccessories.map((item) => { + const equipped = equippedAccessoryIds.has(item.id) + const colorKeys = item.characterAccessory.fillKeys ?? [] + const choices = item.characterAccessory.choices ?? [] + const selectedChoiceId = accessoryChoiceId(item, inventory) + return ( +
+
+
+
{item.name}
+
+ {equipped ? 'Equipped' : 'Owned'} +
+
+ +
+ + {choices.length > 0 ? ( +
+ {choices.map((choice) => ( + + ))} +
+ ) : null} + + {colorKeys.length > 0 ? ( +
+ {colorKeys.map((key) => ( + + ))} +
+ ) : null} +
+ ) + })} +
+ )} +
+
Face {LAB_TOGGLE_CONFIG.filter((c) => c.kind === 'switch').map((cfg) => { diff --git a/src/features/character/MentellCharacter.tsx b/src/features/character/MentellCharacter.tsx index 9acdbae..15f5f55 100644 --- a/src/features/character/MentellCharacter.tsx +++ b/src/features/character/MentellCharacter.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from 'react' +import { useLayoutEffect, useMemo, useRef, useState } from 'react' import charSvg from '../../../asset/char/charprod.svg?raw' import headshotSvg from '../../../asset/char/headshot.svg?raw' import { applyCharacterAppearance } from './applyCharacterAppearance' @@ -15,7 +15,10 @@ import type { CharacterPoseId } from './charManifest' import { CHARACTER_POSES } from './characterPoses' import { useCharacterBlink } from './characterBlink' import { useArmPoseAnimation } from './useArmPoseAnimation' +import { usePetAccessoryAnimation } from './usePetAccessoryAnimation' import { useCharacterAppearance } from './useCharacterAppearance' +import { useEquippedCharacterAccessories } from '../shop/shopCharacterAccessories' +import type { CharacterAccessoryItem } from '../shop/shopCatalog' const DEFAULT_APPEARANCE = defaultCharacterAppearance() @@ -24,6 +27,42 @@ const SVG_SOURCE = { headshot: headshotSvg, } as const +let nextPaintServerInstanceId = 0 + +function namespaceSvgPaintServers(svg: SVGSVGElement) { + nextPaintServerInstanceId += 1 + const suffix = `mentell-svg-${nextPaintServerInstanceId}` + const idMap = new Map() + svg.querySelectorAll('defs [id]').forEach((el) => { + const id = el.id + if (!id) return + const nextId = `${id}-${suffix}` + idMap.set(id, nextId) + el.id = nextId + }) + if (!idMap.size) return + + const replaceRefs = (value: string) => + value + .replace(/url\(#([^)]+)\)/g, (match, id: string) => { + const nextId = idMap.get(id) + return nextId ? `url(#${nextId})` : match + }) + .replace(/^#(.+)$/, (match, id: string) => { + const nextId = idMap.get(id) + return nextId ? `#${nextId}` : match + }) + + svg.querySelectorAll('*').forEach((el) => { + for (const attr of ['style', 'fill', 'stroke', 'filter', 'href', 'xlink:href']) { + const value = el.getAttribute(attr) + if (!value) continue + const nextValue = replaceRefs(value) + if (nextValue !== value) el.setAttribute(attr, nextValue) + } + }) +} + export type CharacterAsset = keyof typeof SVG_SOURCE export type MentellCharacterProps = { @@ -32,6 +71,7 @@ export type MentellCharacterProps = { appearance?: CharacterAppearance className?: string title?: string + characterAccessories?: CharacterAccessoryItem[] } export function MentellCharacter({ @@ -40,15 +80,26 @@ export function MentellCharacter({ appearance: appearanceProp, className, title, + characterAccessories, }: MentellCharacterProps) { const svgRef = useRef(null) const hostRef = useRef(null) const [svgGeneration, setSvgGeneration] = useState(0) const { appearance: storedAppearance } = useCharacterAppearance() + const equippedAccessories = useEquippedCharacterAccessories(characterAccessories === undefined) + const accessories = characterAccessories ?? equippedAccessories const appearance = appearanceProp ?? storedAppearance ?? DEFAULT_APPEARANCE const appearanceKey = JSON.stringify(appearance) + const accessoryKey = JSON.stringify(accessories.map((item) => item.id)) const armPose = CHARACTER_POSES[pose] const isBody = asset === 'character' + const anchoredIds = useMemo( + () => ({ + armL: accessories.flatMap((item) => item.characterAccessory.anchoredIds?.armL ?? []), + armR: accessories.flatMap((item) => item.characterAccessory.anchoredIds?.armR ?? []), + }), + [accessories], + ) useLayoutEffect(() => { const host = hostRef.current @@ -67,12 +118,19 @@ export function MentellCharacter({ } else { fixHeadshotPaintOrder(svg) } - applyCharacterAppearance(svg, appearance) + namespaceSvgPaintServers(svg) + applyCharacterAppearance(svg, appearance, accessories) setSvgGeneration((g) => g + 1) - }, [appearanceKey, asset]) + }, [appearance, appearanceKey, accessories, accessoryKey, asset]) - useArmPoseAnimation(svgRef, isBody ? armPose : { armL: 0, armR: 0 }, svgGeneration) + useArmPoseAnimation( + svgRef, + isBody ? armPose : { armL: 0, armR: 0 }, + svgGeneration, + anchoredIds, + ) useCharacterBlink(svgRef, isBody ? svgGeneration : -1) + usePetAccessoryAnimation(svgRef, isBody ? svgGeneration : -1) const viewBox = asset === 'headshot' ? charManifest.headshotViewBox : charManifest.viewBox diff --git a/src/features/character/applyCharacterAppearance.ts b/src/features/character/applyCharacterAppearance.ts index 1eca368..807e780 100644 --- a/src/features/character/applyCharacterAppearance.ts +++ b/src/features/character/applyCharacterAppearance.ts @@ -1,6 +1,7 @@ import { applyBlinkOpenState } from './characterBlink' import { charManifest } from './charManifest' import type { CharacterAppearance } from './characterAppearance' +import type { CharacterAccessoryItem } from '../shop/shopCatalog' const SVG_NS = 'http://www.w3.org/2000/svg' const SVG_INSTANCE_ATTR = 'data-mentell-appearance-instance' @@ -17,6 +18,106 @@ function setToggleOptionVisible(el: SVGElement, show: boolean) { el.style.display = show ? 'inline' : 'none' } +function setElementVisible(el: SVGElement, show: boolean) { + el.style.display = show ? 'inline' : 'none' +} + +function bringToFront(el: SVGElement) { + const svg = el.ownerSVGElement + if (!svg || el.parentNode === svg) { + el.parentNode?.appendChild(el) + return + } + svg.appendChild(el) +} + +function normalizeLookup(value: string) { + return value + .toLowerCase() + .replace(/_(toggle|iii|dni).*$/i, '') + .replace(/^toggle/, '') + .replace(/[^a-z0-9]+/g, '') +} + +function toggleGroupMatches( + group: (typeof charManifest.toggleGroups)[number], + groupKey: string, +) { + const normalized = normalizeLookup(groupKey) + return ( + group.key === groupKey || + group.parentId === groupKey || + normalizeLookup(group.label) === normalized || + normalizeLookup(group.key) === normalized + ) +} + +function optionMatches(option: { id: string; label: string }, optionId: string) { + const normalized = normalizeLookup(optionId) + return option.id === optionId || normalizeLookup(option.label) === normalized +} + +function resolveAccessoryToggle(toggle: { groupKey: string; optionId: string }) { + const group = charManifest.toggleGroups.find((entry) => + toggleGroupMatches(entry, toggle.groupKey), + ) + const option = group?.options.find((entry) => optionMatches(entry, toggle.optionId)) + if (!group || !option) return null + return { groupKey: group.key, optionId: option.id } +} + +function isAccessoryToggleGroup(group: (typeof charManifest.toggleGroups)[number]) { + return ['layer2', 'layer3', 'layer4', 'layer5', 'layer6', 'layer7', 'layer8'].includes( + group.parentId, + ) +} + +function isBaseFillKey(key: string) { + return key === 'path45' || key === 'path65' || key === 'hair_fill' +} + +function accessoryToggleRows(accessory: CharacterAccessoryItem) { + const rows = accessory.characterAccessory.toggles ?? [] + const legacy = accessory.characterAccessory.toggle + ? [ + { + groupKey: accessory.characterAccessory.toggle.groupKey, + optionIds: [accessory.characterAccessory.toggle.optionId], + }, + ] + : [] + return [...rows, ...legacy] +} + +function setPartVisibleByKey(svg: SVGSVGElement, key: string, show: boolean) { + const byId = svg.getElementById(key) + if (byId instanceof SVGElement) { + setElementVisible(byId, show) + return + } + const normalized = normalizeLookup(key) + svg.querySelectorAll('*').forEach((el) => { + const label = el.getAttribute('inkscape:label') ?? '' + if (normalizeLookup(label) === normalized) setElementVisible(el, show) + }) +} + +function setPetFaceVisible(svg: SVGSVGElement, show: boolean) { + for (const key of ['facebase_DNI', 'FACETOGGLE']) { + const normalized = normalizeLookup(key) + svg.querySelectorAll('*').forEach((el) => { + const label = el.getAttribute('inkscape:label') ?? '' + if (normalizeLookup(label) !== normalized) return + setElementVisible(el, show) + if (show) bringToFront(el) + }) + } +} + +function hidePartByKey(svg: SVGSVGElement, key: string) { + setPartVisibleByKey(svg, key, false) +} + function parseHexColor(color: string): [number, number, number] | null { const hex = color.trim().replace(/^#/, '') if (hex.length === 3) { @@ -115,8 +216,16 @@ function applyEyeGradient(svg: SVGSVGElement, activeToggle: SVGGElement, active: export function applyCharacterAppearance( svg: SVGSVGElement, appearance: CharacterAppearance, + accessories: CharacterAccessoryItem[] = [], ) { + const activeAccessoryFillKeys = new Set( + accessories.flatMap((accessory) => accessory.characterAccessory.fillKeys ?? []), + ) + for (const fillable of charManifest.fillables) { + if (!isBaseFillKey(fillable.key) && !activeAccessoryFillKeys.has(fillable.key)) { + continue + } const color = appearance.fills[fillable.key] ?? fillable.defaultFill const targetIds = 'targetIds' in fillable && fillable.targetIds @@ -142,14 +251,38 @@ export function applyCharacterAppearance( } } + const accessoryToggles = new Map>() + const accessoryPartKeys = new Set() + for (const accessory of accessories) { + for (const row of accessoryToggleRows(accessory)) { + for (const optionId of row.optionIds) { + const resolved = resolveAccessoryToggle({ groupKey: row.groupKey, optionId }) + if (!resolved) continue + const active = accessoryToggles.get(resolved.groupKey) ?? new Set() + active.add(resolved.optionId) + accessoryToggles.set(resolved.groupKey, active) + } + } + for (const part of accessory.characterAccessory.parts ?? []) { + accessoryPartKeys.add(part) + } + } + let activeSkinHidingAccessory = false + for (const group of charManifest.toggleGroups) { - const active = appearance.toggles[group.key] ?? group.defaultOption + const activeAccessoryOptions = accessoryToggles.get(group.key) + const active = activeAccessoryOptions + ? [...activeAccessoryOptions][0] + : isAccessoryToggleGroup(group) + ? null + : appearance.toggles[group.key] ?? group.defaultOption if (group.key === 'blush' && 'elementId' in group) { const blush = svg.getElementById(group.elementId) if (blush instanceof SVGElement) { blush.style.display = active === 'on' ? 'inline' : 'none' blush.style.opacity = active === 'on' ? '1' : '0' + blush.style.visibility = active === 'on' ? 'visible' : 'hidden' } continue } @@ -157,10 +290,16 @@ export function applyCharacterAppearance( for (const opt of group.options) { const el = svg.getElementById(opt.id) if (!(el instanceof SVGElement)) continue - setToggleOptionVisible(el, opt.id === active) + const show = activeAccessoryOptions ? activeAccessoryOptions.has(opt.id) : opt.id === active + setToggleOptionVisible( + el, + show, + ) + if (show && activeAccessoryOptions && group.key !== 'layer2') bringToFront(el) } if (group.key === 'layer18') { + if (!active) continue const activeToggle = svg.getElementById(active) if (activeToggle instanceof SVGGElement) { activeToggle.style.opacity = '1' @@ -169,5 +308,46 @@ export function applyCharacterAppearance( } } + setPetFaceVisible(svg, accessoryToggles.has('layer2')) + + const knownAccessoryParts = [ + 'GHOST', + 'GHOST_armL_joint', + 'GHOST_armR_joint', + 'SKELETON', + 'SKELETON_armL_joint', + 'SKELETON_armR_joint', + 'Lemmon', + ] + for (const part of knownAccessoryParts) { + if (!accessoryPartKeys.has(part)) hidePartByKey(svg, part) + } + + for (const accessory of accessories) { + for (const part of accessory.characterAccessory.parts ?? []) { + setPartVisibleByKey(svg, part, true) + const el = svg.getElementById(part) + if (el instanceof SVGElement) bringToFront(el) + } + if (accessory.characterAccessory.hideSkin) { + activeSkinHidingAccessory = true + } + if (accessory.characterAccessory.hideBaseClothes) { + for (const part of ['layer15', 'layer19', 'path65', 'shoesDNI']) { + setPartVisibleByKey(svg, part, false) + } + } + } + + if (activeSkinHidingAccessory) { + const skin = charManifest.fillables.find((fillable) => fillable.key === 'path45') + const targetIds = + skin && 'targetIds' in skin && skin.targetIds ? [...skin.targetIds] : skin ? [skin.id] : [] + for (const id of targetIds) { + const el = svg.getElementById(id) + if (el instanceof SVGElement) setElementVisible(el, false) + } + } + applyBlinkOpenState(svg) } diff --git a/src/features/character/charManifest.generated.ts b/src/features/character/charManifest.generated.ts index 452f012..feb4d34 100644 --- a/src/features/character/charManifest.generated.ts +++ b/src/features/character/charManifest.generated.ts @@ -56,6 +56,145 @@ export const charManifest = { "path97", "path100" ] + }, + { + "id": "g86", + "key": "catbase", + "label": "Catbase", + "defaultFill": "#ffce67", + "targetIds": [ + "path77", + "path78", + "path79", + "path81", + "path82", + "path86", + "path86-4" + ] + }, + { + "id": "g91", + "key": "dogbase", + "label": "Dogbase", + "defaultFill": "#725d34", + "targetIds": [ + "ellipse86", + "ellipse87", + "ellipse88", + "ellipse89", + "ellipse90", + "path92", + "path92-9" + ] + }, + { + "id": "path3", + "key": "bandr", + "label": "BandR", + "defaultFill": "#c32033", + "targetIds": [ + "path3" + ] + }, + { + "id": "path3-0", + "key": "bandl", + "label": "BandL", + "defaultFill": "#c32033", + "targetIds": [ + "path3-0" + ] + }, + { + "id": "path13", + "key": "watchr", + "label": "WatchR", + "defaultFill": "#35090e", + "targetIds": [ + "path13" + ] + }, + { + "id": "path16", + "key": "glover", + "label": "GloveR", + "defaultFill": "#35090e", + "targetIds": [ + "path16" + ] + }, + { + "id": "path16-0", + "key": "glovel", + "label": "GloveL", + "defaultFill": "#35090e", + "targetIds": [ + "path16-0" + ] + }, + { + "id": "path10", + "key": "pocketr", + "label": "PocketR", + "defaultFill": "#c32033", + "targetIds": [ + "path10" + ] + }, + { + "id": "path10-7", + "key": "pocketl", + "label": "PocketL", + "defaultFill": "#2c2460", + "targetIds": [ + "path10-7" + ] + }, + { + "id": "path21", + "key": "circle", + "label": "CIRCLE", + "defaultFill": "#d38d5f", + "targetIds": [ + "path21" + ] + }, + { + "id": "rect21", + "key": "square", + "label": "SQUARE", + "defaultFill": "#d38d5f", + "targetIds": [ + "rect21" + ] + }, + { + "id": "path22", + "key": "star", + "label": "STAR", + "defaultFill": "#d38d5f", + "targetIds": [ + "path22" + ] + }, + { + "id": "path40", + "key": "bandana", + "label": "Bandana", + "defaultFill": "#800000", + "targetIds": [ + "path40" + ] + }, + { + "id": "g55", + "key": "lemmon", + "label": "Lemmon", + "defaultFill": "#ffe166", + "targetIds": [ + "path54", + "path55" + ] } ], "globalFillGroups": [ @@ -181,6 +320,174 @@ export const charManifest = { } ] }, + { + "key": "layer2", + "label": "Togglepet", + "parentId": "layer2", + "defaultOption": "g91", + "options": [ + { + "id": "g86", + "label": "Catbase" + }, + { + "id": "g91", + "label": "Dogbase" + } + ] + }, + { + "key": "layer18-6", + "label": "Toggleeyecolour BLK", + "parentId": "layer18-6", + "defaultOption": "g105-1", + "options": [ + { + "id": "g104-5", + "label": "Default" + }, + { + "id": "g105-1", + "label": "Brown" + }, + { + "id": "g107-1", + "label": "Blue" + } + ] + }, + { + "key": "layer3", + "label": "Togglewrist", + "parentId": "layer3", + "defaultOption": "path3", + "options": [ + { + "id": "path3", + "label": "BandR" + }, + { + "id": "path3-0", + "label": "BandL" + }, + { + "id": "g15", + "label": "WatchR" + }, + { + "id": "g15-5", + "label": "WatchL" + }, + { + "id": "path16", + "label": "GloveR" + }, + { + "id": "path16-0", + "label": "GloveL" + } + ] + }, + { + "key": "layer4", + "label": "Togglepants", + "parentId": "layer4", + "defaultOption": "path10-7", + "options": [ + { + "id": "path10", + "label": "PocketR" + }, + { + "id": "path10-7", + "label": "PocketL" + } + ] + }, + { + "key": "layer5", + "label": "Toggleshoes", + "parentId": "layer5", + "defaultOption": "g16", + "options": [ + { + "id": "g16", + "label": "BOOTS" + }, + { + "id": "g20", + "label": "SNEEKER" + }, + { + "id": "g21", + "label": "HEEL" + } + ] + }, + { + "key": "layer6", + "label": "Toggleshirt", + "parentId": "layer6", + "defaultOption": "path22", + "options": [ + { + "id": "path21", + "label": "CIRCLE" + }, + { + "id": "rect21", + "label": "SQUARE" + }, + { + "id": "path22", + "label": "STAR" + } + ] + }, + { + "key": "layer7", + "label": "Toggleface", + "parentId": "layer7", + "defaultOption": "g27", + "options": [ + { + "id": "g27", + "label": "Glassessquare" + }, + { + "id": "g32", + "label": "Glassescircle" + }, + { + "id": "path40", + "label": "Bandana" + }, + { + "id": "g47", + "label": "Mask" + } + ] + }, + { + "key": "layer8", + "label": "Togglehat", + "parentId": "layer8", + "defaultOption": "g51", + "options": [ + { + "id": "g51", + "label": "Cowboy" + }, + { + "id": "path52", + "label": "Headcover" + }, + { + "id": "path53", + "label": "Crown" + } + ] + }, { "key": "blush", "label": "Blush", @@ -204,6 +511,20 @@ export const charManifest = { "path45": "#ddae67", "path65": "#5042ae", "hair_fill": "#311e00", + "catbase": "#ffce67", + "dogbase": "#725d34", + "bandr": "#c32033", + "bandl": "#c32033", + "watchr": "#35090e", + "glover": "#35090e", + "glovel": "#35090e", + "pocketr": "#c32033", + "pocketl": "#2c2460", + "circle": "#d38d5f", + "square": "#d38d5f", + "star": "#d38d5f", + "bandana": "#800000", + "lemmon": "#ffe166", "shirt": "#261b4f", "sleeves": "#261b4f" }, @@ -213,6 +534,14 @@ export const charManifest = { "layer17": "g103", "layer18": "g105", "layer19": "g127", + "layer2": "g91", + "layer18-6": "g105-1", + "layer3": "path3", + "layer4": "path10-7", + "layer5": "g16", + "layer6": "path22", + "layer7": "g27", + "layer8": "g51", "blush": "on" } }, @@ -220,9 +549,12 @@ export const charManifest = { "openLayerIds": [ "layer14", "layer18", - "g100" + "g100", + "layer14-9", + "layer18-6", + "g100-4" ], - "closedLayerId": "g11", + "closedLayerId": "g11-7", "closedDurationMs": 120, "minIntervalMs": 2000, "maxIntervalMs": 4500 diff --git a/src/features/character/characterBlink.ts b/src/features/character/characterBlink.ts index d4093ff..f96f2f6 100644 --- a/src/features/character/characterBlink.ts +++ b/src/features/character/characterBlink.ts @@ -8,20 +8,32 @@ function setLayerVisible(svg: SVGSVGElement, id: string, show: boolean) { el.style.display = show ? 'inline' : 'none' } +function blinkLayerIds(svg: SVGSVGElement) { + const openLayerIds = new Set(charManifest.blink?.openLayerIds ?? []) + const closedLayerIds = new Set( + charManifest.blink ? [charManifest.blink.closedLayerId] : [], + ) + svg.querySelectorAll('*').forEach((el) => { + const label = el.getAttribute('inkscape:label') ?? '' + if (!el.id) return + if (label === 'BLK') closedLayerIds.add(el.id) + else if (label.endsWith('_BLK')) openLayerIds.add(el.id) + }) + return { openLayerIds: [...openLayerIds], closedLayerIds: [...closedLayerIds] } +} + /** Default: open-eye *_BLK layers on, closed-eye BLK layer off. */ export function applyBlinkOpenState(svg: SVGSVGElement) { - const blink = charManifest.blink - if (!blink) return - for (const id of blink.openLayerIds) setLayerVisible(svg, id, true) - setLayerVisible(svg, blink.closedLayerId, false) + const { openLayerIds, closedLayerIds } = blinkLayerIds(svg) + for (const id of openLayerIds) setLayerVisible(svg, id, true) + for (const id of closedLayerIds) setLayerVisible(svg, id, false) } /** Blink frame: hide *_BLK, show BLK overlay. */ export function applyBlinkClosedState(svg: SVGSVGElement) { - const blink = charManifest.blink - if (!blink) return - for (const id of blink.openLayerIds) setLayerVisible(svg, id, false) - setLayerVisible(svg, blink.closedLayerId, true) + const { openLayerIds, closedLayerIds } = blinkLayerIds(svg) + for (const id of openLayerIds) setLayerVisible(svg, id, false) + for (const id of closedLayerIds) setLayerVisible(svg, id, true) } export function useCharacterBlink( diff --git a/src/features/character/useArmPoseAnimation.ts b/src/features/character/useArmPoseAnimation.ts index 1caef8c..3bb90d2 100644 --- a/src/features/character/useArmPoseAnimation.ts +++ b/src/features/character/useArmPoseAnimation.ts @@ -80,10 +80,29 @@ function isRendered(el: SVGGraphicsElement) { return getComputedStyle(el).display !== 'none' } +function normalizeLookup(value: string) { + return value + .toLowerCase() + .replace(/_(toggle|iii|dni).*$/i, '') + .replace(/[^a-z0-9]+/g, '') +} + +function resolveGraphics(svg: SVGSVGElement, key: string) { + const byId = svg.getElementById(key) + if (byId instanceof SVGGraphicsElement) return [byId] + const normalized = normalizeLookup(key) + return Array.from(svg.querySelectorAll('*')).filter((el): el is SVGGraphicsElement => { + if (!(el instanceof SVGGraphicsElement)) return false + const label = el.getAttribute('inkscape:label') ?? '' + return normalizeLookup(label) === normalized + }) +} + export function useArmPoseAnimation( svgRef: React.RefObject, pose: ArmPose, svgGeneration = 0, + anchoredIds?: { armL?: readonly string[]; armR?: readonly string[] }, ) { const prevPose = useRef(pose) @@ -121,11 +140,20 @@ export function useArmPoseAnimation( pivot: pivotRLocal, } - const leftSleeves = charManifest.arms.armL.sleeveIds - .map((id) => svg.getElementById(id)) + const leftSleeveIds = [ + ...charManifest.arms.armL.sleeveIds, + ...(anchoredIds?.armL ?? []), + ] + const rightSleeveIds = [ + ...charManifest.arms.armR.sleeveIds, + ...(anchoredIds?.armR ?? []), + ] + + const leftSleeves = leftSleeveIds + .flatMap((id) => resolveGraphics(svg, id)) .filter((el): el is SVGGraphicsElement => el instanceof SVGGraphicsElement && isRendered(el)) - const rightSleeves = charManifest.arms.armR.sleeveIds - .map((id) => svg.getElementById(id)) + const rightSleeves = rightSleeveIds + .flatMap((id) => resolveGraphics(svg, id)) .filter((el): el is SVGGraphicsElement => el instanceof SVGGraphicsElement && isRendered(el)) const leftSleeveNodes: RotatingNode[] = leftSleeves.map((el) => { @@ -195,5 +223,5 @@ export function useArmPoseAnimation( controlsL.stop() controlsR.stop() } - }, [svgRef, pose.armL, pose.armR, svgGeneration]) + }, [svgRef, pose.armL, pose.armR, svgGeneration, anchoredIds]) } diff --git a/src/features/character/usePetAccessoryAnimation.ts b/src/features/character/usePetAccessoryAnimation.ts new file mode 100644 index 0000000..0a97ef5 --- /dev/null +++ b/src/features/character/usePetAccessoryAnimation.ts @@ -0,0 +1,90 @@ +import { useEffect } from 'react' +import { shouldReduceMotion } from '../../shared/motion/useMotionPrefs' + +const BASE_TRANSFORM_ATTR = 'data-mentell-pet-base-transform' + +type TailNode = { + el: SVGGraphicsElement + baseTransform: string + pivot: { cx: number; cy: number } + kind: 'cat' | 'dog' +} + +function getBaseTransform(el: SVGGraphicsElement) { + const stored = el.getAttribute(BASE_TRANSFORM_ATTR) + if (stored !== null) return stored + const initial = el.getAttribute('transform') ?? '' + el.setAttribute(BASE_TRANSFORM_ATTR, initial) + return initial +} + +function isRendered(el: SVGGraphicsElement) { + return getComputedStyle(el).display !== 'none' +} + +function petKind(el: Element): 'cat' | 'dog' | null { + let current: Element | null = el + while (current) { + const label = (current.getAttribute('inkscape:label') ?? '').toLowerCase() + if (label.includes('catbase')) return 'cat' + if (label.includes('dogbase')) return 'dog' + current = current.parentElement + } + return null +} + +function collectTailNodes(svg: SVGSVGElement) { + return Array.from(svg.querySelectorAll('[inkscape\\:label="tail"]')) + .filter((el) => el instanceof SVGGraphicsElement && isRendered(el)) + .map((el): TailNode | null => { + const kind = petKind(el) + if (!kind) return null + const box = el.getBBox() + return { + el, + baseTransform: getBaseTransform(el), + pivot: { cx: box.x + box.width / 2, cy: box.y + box.height / 2 }, + kind, + } + }) + .filter((node): node is TailNode => node !== null) +} + +export function usePetAccessoryAnimation( + svgRef: React.RefObject, + svgGeneration = 0, +) { + useEffect(() => { + const svg = svgRef.current + if (!svg || shouldReduceMotion()) return undefined + + const tails = collectTailNodes(svg) + if (!tails.length) return undefined + + let raf = 0 + const start = performance.now() + + const tick = (now: number) => { + const elapsed = now - start + for (const node of tails) { + const amplitude = node.kind === 'dog' ? 12 : 4 + const speed = node.kind === 'dog' ? 0.018 : 0.0045 + const deg = Math.sin(elapsed * speed) * amplitude + const rotate = `rotate(${deg.toFixed(3)} ${node.pivot.cx} ${node.pivot.cy})` + node.el.setAttribute( + 'transform', + node.baseTransform ? `${node.baseTransform} ${rotate}` : rotate, + ) + } + raf = requestAnimationFrame(tick) + } + + raf = requestAnimationFrame(tick) + return () => { + cancelAnimationFrame(raf) + for (const node of tails) { + node.el.setAttribute('transform', node.baseTransform) + } + } + }, [svgRef, svgGeneration]) +} diff --git a/src/features/debug/DebugPanel.tsx b/src/features/debug/DebugPanel.tsx index 9987e97..fa8f4a8 100644 --- a/src/features/debug/DebugPanel.tsx +++ b/src/features/debug/DebugPanel.tsx @@ -13,6 +13,7 @@ import { import { clearWeeklyAiCache } from '../compilation/weeklyAiCache' import { ensurePackage } from '../packages/packageService' import { clearCatCollection } from '../shop/catCollection' +import { clearShopInventory } from '../shop/shopInventory' import { notifyScoreChanged } from '../score/scoreEvents' import { debugForegroundNotification, @@ -106,7 +107,7 @@ export function DebugPanel() { if (notifSnap.serviceWorkerState !== 'installing' && !notifSnap.serviceWorkerRegistered) return const id = window.setInterval(() => void refreshNotifications(), 2000) return () => window.clearInterval(id) - }, [open, notifSnap?.serviceWorkerReady, notifSnap?.serviceWorkerState, notifSnap?.serviceWorkerRegistered]) + }, [open, notifSnap]) async function runNotifAction( label: string, @@ -679,6 +680,7 @@ export function DebugPanel() { await database.stickies.clear() await database.packages.clear() await database.characterAppearance.clear() + clearShopInventory() localStorage.removeItem(scopedStorageKey('mentell.score.total')) localStorage.removeItem(scopedStorageKey('mentell.score.streak')) localStorage.removeItem(scopedStorageKey('mentell.score.lastDay')) @@ -760,4 +762,3 @@ export function DebugPanel() { ) } - diff --git a/src/features/shop/Shoppe.tsx b/src/features/shop/Shoppe.tsx index 22ea12e..6c4a2fe 100644 --- a/src/features/shop/Shoppe.tsx +++ b/src/features/shop/Shoppe.tsx @@ -7,6 +7,8 @@ import { type CollectedCat, } from './catCollection' import { WeekTimelineCard } from './WeekTimelineCard' +import { MentellCharacter } from '../character/MentellCharacter' +import { defaultCharacterAppearance } from '../character/characterAppearance' import { useAppSettings } from '../../shared/settings/useAppSettings' import { publicUrl } from '../../shared/publicUrl' import { SCORE_CHANGED_EVENT } from '../score/scoreEvents' @@ -19,6 +21,7 @@ import { } from './shopInventory' import { loadShopCatalog, + type CharacterAccessoryItem, type CursorItem, type ShopCatalogItem, type ThemeItem, @@ -87,9 +90,45 @@ function CursorHoverPreview({ item }: { item: CursorItem }) { ) } +function previewAccessoryItem(item: CharacterAccessoryItem): CharacterAccessoryItem { + const choice = + item.characterAccessory.choices?.find( + (entry) => entry.id === item.characterAccessory.defaultChoiceId, + ) ?? item.characterAccessory.choices?.[0] + if (!choice) return item + return { + ...item, + characterAccessory: { + ...item.characterAccessory, + toggles: choice.toggles?.length ? choice.toggles : item.characterAccessory.toggles, + parts: choice.parts?.length ? choice.parts : item.characterAccessory.parts, + anchoredIds: choice.anchoredIds ?? item.characterAccessory.anchoredIds, + }, + } +} + +function CharacterAccessoryPreview({ item }: { item: CharacterAccessoryItem }) { + const previewItem = previewAccessoryItem(item) + return ( +
+ +
+ ) +} + function ShopItemPreview({ item, preview }: { item: ShopCatalogItem; preview: string | null }) { if (item.type === 'theme') return if (item.type === 'cursor') return + if (item.type === 'characterAccessory') return if (!preview) return null return ( { const owned = itemOwned(item.id) const equippable = canEquip(item) + const accessory = item.type === 'characterAccessory' const equipped = equippable && equippedId(item.type) === item.id const preview = itemPreview(item) return ( @@ -346,7 +406,7 @@ export function Shoppe({ )} {owned ? ( - {equipped ? 'Equipped' : 'Unlocked'} + {equipped ? 'Equipped' : accessory ? 'Owned' : 'Unlocked'} ) : null}
diff --git a/src/features/shop/shopCatalog.ts b/src/features/shop/shopCatalog.ts index e157cfa..05c21a4 100644 --- a/src/features/shop/shopCatalog.ts +++ b/src/features/shop/shopCatalog.ts @@ -1,6 +1,6 @@ import catalogJson from '../../../asset/shop/shoppe-items.json?raw' -export type ShopItemType = 'image' | 'theme' | 'stamp' | 'cursor' +export type ShopItemType = 'image' | 'theme' | 'stamp' | 'cursor' | 'characterAccessory' type ShopItemBase = { id: string @@ -61,7 +61,50 @@ export type ImageItem = ShopItemBase & { } } -export type ShopCatalogItem = ThemeItem | StampItem | CursorItem | ImageItem +export type CharacterAccessoryItem = ShopItemBase & { + type: 'characterAccessory' + characterAccessory: { + scope: 'pet' | 'wrist' | 'hair' | 'fullBody' | 'shirt' | 'shoes' | 'pants' | 'face' | 'hat' + exclusiveGroup?: string + toggle?: { + groupKey: string + optionId: string + } + toggles?: { + groupKey: string + optionIds: string[] + }[] + parts?: string[] + fillKeys?: string[] + hideSkin?: boolean + hideBaseClothes?: boolean + anchoredIds?: { + armL?: string[] + armR?: string[] + } + choices?: { + id: string + label: string + toggles?: { + groupKey: string + optionIds: string[] + }[] + parts?: string[] + anchoredIds?: { + armL?: string[] + armR?: string[] + } + }[] + defaultChoiceId?: string + } +} + +export type ShopCatalogItem = + | ThemeItem + | StampItem + | CursorItem + | ImageItem + | CharacterAccessoryItem export type ShopCatalog = { version: number @@ -89,6 +132,11 @@ function asTuple(value: unknown): [number, number] | undefined { return [Math.trunc(first), Math.trunc(second)] } +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((row): row is string => typeof row === 'string' && row.trim().length > 0) +} + function parseThemePalette(value: unknown): ThemePalette { const row = asRecord(value) if (!row) return { deskBg: '' } @@ -179,6 +227,115 @@ function parseItem(value: unknown): ShopCatalogItem | null { if (!parsed.image.url) return null return parsed } + if (type === 'characterAccessory') { + const accessory = asRecord(row.characterAccessory) + if (!accessory) return null + const scope = asString(accessory.scope) + if ( + scope !== 'pet' && + scope !== 'wrist' && + scope !== 'hair' && + scope !== 'fullBody' && + scope !== 'shirt' && + scope !== 'shoes' && + scope !== 'pants' && + scope !== 'face' && + scope !== 'hat' + ) { + return null + } + const toggle = asRecord(accessory.toggle) + const toggles = Array.isArray(accessory.toggles) ? accessory.toggles : [] + const choices = Array.isArray(accessory.choices) ? accessory.choices : [] + const anchoredIds = asRecord(accessory.anchoredIds) + const parsed: CharacterAccessoryItem = { + ...base, + type: 'characterAccessory', + characterAccessory: { + scope, + exclusiveGroup: asString(accessory.exclusiveGroup) || undefined, + toggle: + toggle && asString(toggle.groupKey) && asString(toggle.optionId) + ? { + groupKey: asString(toggle.groupKey), + optionId: asString(toggle.optionId), + } + : undefined, + toggles: toggles + .map((entry) => { + const toggleRow = asRecord(entry) + return toggleRow && asString(toggleRow.groupKey) + ? { + groupKey: asString(toggleRow.groupKey), + optionIds: asStringArray(toggleRow.optionIds), + } + : null + }) + .filter( + (entry): entry is { groupKey: string; optionIds: string[] } => + Boolean(entry && entry.optionIds.length > 0), + ), + parts: asStringArray(accessory.parts), + fillKeys: asStringArray(accessory.fillKeys), + hideSkin: accessory.hideSkin === true, + hideBaseClothes: accessory.hideBaseClothes === true, + anchoredIds: { + armL: asStringArray(anchoredIds?.armL), + armR: asStringArray(anchoredIds?.armR), + }, + choices: choices + .map((entry) => { + const choice = asRecord(entry) + if (!choice || !asString(choice.id) || !asString(choice.label)) return null + const choiceAnchors = asRecord(choice.anchoredIds) + const choiceToggles = Array.isArray(choice.toggles) ? choice.toggles : [] + return { + id: asString(choice.id), + label: asString(choice.label), + toggles: choiceToggles + .map((toggleEntry) => { + const toggleRow = asRecord(toggleEntry) + return toggleRow && asString(toggleRow.groupKey) + ? { + groupKey: asString(toggleRow.groupKey), + optionIds: asStringArray(toggleRow.optionIds), + } + : null + }) + .filter( + (row): row is { groupKey: string; optionIds: string[] } => + Boolean(row && row.optionIds.length > 0), + ), + parts: asStringArray(choice.parts), + anchoredIds: { + armL: asStringArray(choiceAnchors?.armL), + armR: asStringArray(choiceAnchors?.armR), + }, + } + }) + .filter( + ( + choice, + ): choice is { + id: string + label: string + toggles: { groupKey: string; optionIds: string[] }[] + parts: string[] + anchoredIds: { armL: string[]; armR: string[] } + } => choice !== null, + ), + defaultChoiceId: asString(accessory.defaultChoiceId) || undefined, + }, + } + if ( + !parsed.characterAccessory.toggle && + !parsed.characterAccessory.toggles?.length && + !parsed.characterAccessory.parts?.length + ) { + return null + } + return parsed + } return null } diff --git a/src/features/shop/shopCharacterAccessories.ts b/src/features/shop/shopCharacterAccessories.ts new file mode 100644 index 0000000..c8fc54d --- /dev/null +++ b/src/features/shop/shopCharacterAccessories.ts @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useState } from 'react' +import { + loadShopCatalog, + type CharacterAccessoryItem, + type ShopCatalogItem, +} from './shopCatalog' +import { + loadShopInventory, + subscribeShopInventory, + type ShopInventory, +} from './shopInventory' + +function isCharacterAccessory(item: ShopCatalogItem): item is CharacterAccessoryItem { + return item.type === 'characterAccessory' +} + +function accessoryExclusiveKey(item: CharacterAccessoryItem) { + return ( + item.characterAccessory.exclusiveGroup ?? + item.characterAccessory.toggle?.groupKey ?? + item.characterAccessory.scope + ) +} + +export function exclusiveAccessoryIdsFor( + item: CharacterAccessoryItem, + items = loadShopCatalog().items, +) { + const key = accessoryExclusiveKey(item) + return items + .filter(isCharacterAccessory) + .filter((entry) => entry.id !== item.id && accessoryExclusiveKey(entry) === key) + .map((entry) => entry.id) +} + +export function equippedCharacterAccessories( + inventory: ShopInventory, + items = loadShopCatalog().items, +) { + const owned = new Set(inventory.ownedItemIds) + const equipped = new Set(inventory.equipped.characterAccessoryIds) + return items + .filter(isCharacterAccessory) + .filter((item) => owned.has(item.id) && equipped.has(item.id)) + .map((item) => applyAccessoryChoice(item, inventory)) +} + +export function accessoryChoiceId(item: CharacterAccessoryItem, inventory: ShopInventory) { + return ( + inventory.equipped.characterAccessoryChoices[item.id] ?? + item.characterAccessory.defaultChoiceId ?? + item.characterAccessory.choices?.[0]?.id ?? + '' + ) +} + +export function applyAccessoryChoice( + item: CharacterAccessoryItem, + inventory: ShopInventory, +): CharacterAccessoryItem { + const choiceId = accessoryChoiceId(item, inventory) + const choice = item.characterAccessory.choices?.find((entry) => entry.id === choiceId) + if (!choice) return item + return { + ...item, + characterAccessory: { + ...item.characterAccessory, + toggles: choice.toggles?.length ? choice.toggles : item.characterAccessory.toggles, + parts: choice.parts?.length ? choice.parts : item.characterAccessory.parts, + anchoredIds: choice.anchoredIds ?? item.characterAccessory.anchoredIds, + }, + } +} + +export function useEquippedCharacterAccessories(enabled = true) { + const catalog = useMemo(() => loadShopCatalog(), []) + const [inventory, setInventory] = useState(() => loadShopInventory()) + useEffect(() => { + if (!enabled) return undefined + return subscribeShopInventory((next) => setInventory(next)) + }, [enabled]) + return useMemo( + () => (enabled ? equippedCharacterAccessories(inventory, catalog.items) : []), + [catalog.items, enabled, inventory], + ) +} diff --git a/src/features/shop/shopInventory.ts b/src/features/shop/shopInventory.ts index bf56698..91f2fa6 100644 --- a/src/features/shop/shopInventory.ts +++ b/src/features/shop/shopInventory.ts @@ -8,6 +8,8 @@ export type EquippedShopItems = { themeId: string | null stampId: string | null cursorId: string | null + characterAccessoryIds: string[] + characterAccessoryChoices: Record } export type ShopInventory = { @@ -27,6 +29,8 @@ const DEFAULT_SHOP_INVENTORY: ShopInventory = { themeId: null, stampId: null, cursorId: null, + characterAccessoryIds: [], + characterAccessoryChoices: {}, }, updatedAt: 0, } @@ -48,6 +52,21 @@ function sanitizeInventory(input: unknown): ShopInventory { themeId: typeof equippedRow?.themeId === 'string' ? equippedRow.themeId : null, stampId: typeof equippedRow?.stampId === 'string' ? equippedRow.stampId : null, cursorId: typeof equippedRow?.cursorId === 'string' ? equippedRow.cursorId : null, + characterAccessoryIds: Array.isArray(equippedRow?.characterAccessoryIds) + ? [ + ...new Set( + equippedRow.characterAccessoryIds.filter( + (id): id is string => typeof id === 'string', + ), + ), + ] + : [], + characterAccessoryChoices: Object.fromEntries( + Object.entries(asRecord(equippedRow?.characterAccessoryChoices) ?? {}).filter( + (entry): entry is [string, string] => + typeof entry[0] === 'string' && typeof entry[1] === 'string', + ), + ), }, updatedAt: Number.isFinite(updatedAtRaw) ? Math.max(0, Math.trunc(updatedAtRaw)) : 0, } @@ -111,6 +130,47 @@ export function equipShopItem(kind: 'theme' | 'stamp' | 'cursor', itemId: string })) } +export function equipCharacterAccessoryItem( + itemId: string, + options?: { exclusiveWith?: string[] }, +) { + const clean = itemId.trim() + if (!clean) return loadShopInventory() + const exclusiveWith = new Set(options?.exclusiveWith ?? []) + return updateShopInventory((current) => { + const currentlyEquipped = current.equipped.characterAccessoryIds.includes(clean) + const nextIds = currentlyEquipped + ? current.equipped.characterAccessoryIds.filter((id) => id !== clean) + : [ + ...current.equipped.characterAccessoryIds.filter((id) => !exclusiveWith.has(id)), + clean, + ] + return { + ...current, + equipped: { + ...current.equipped, + characterAccessoryIds: nextIds, + }, + } + }) +} + +export function setCharacterAccessoryChoice(itemId: string, choiceId: string) { + const cleanItem = itemId.trim() + const cleanChoice = choiceId.trim() + if (!cleanItem || !cleanChoice) return loadShopInventory() + return updateShopInventory((current) => ({ + ...current, + equipped: { + ...current.equipped, + characterAccessoryChoices: { + ...current.equipped.characterAccessoryChoices, + [cleanItem]: cleanChoice, + }, + }, + })) +} + export function applyShopInventoryFromCloud(input: unknown) { const parsed = sanitizeInventory(input) return writeInventory(parsed, { notifySync: false, preserveUpdatedAt: true })