void refreshNotifications(), 2000)
return () => window.clearInterval(id)
- }, [open, notifSnap?.serviceWorkerReady, notifSnap?.serviceWorkerState, notifSnap?.serviceWorkerRegistered])
+ }, [open, notifSnap])
async function runNotifAction(
label: string,
@@ -678,6 +679,8 @@ export function DebugPanel() {
await database.notes.clear()
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'))
@@ -759,4 +762,3 @@ export function DebugPanel() {
>
)
}
-
diff --git a/src/features/legal/PrivacyPolicyPage.tsx b/src/features/legal/PrivacyPolicyPage.tsx
index 7057c24..93cff38 100644
--- a/src/features/legal/PrivacyPolicyPage.tsx
+++ b/src/features/legal/PrivacyPolicyPage.tsx
@@ -79,9 +79,9 @@ export function PrivacyPolicyPage() {
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/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/features/shop/Shoppe.tsx b/src/features/shop/Shoppe.tsx
index 34d81eb..6c4a2fe 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 {
@@ -7,11 +7,138 @@ 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'
+import {
+ equipShopItem,
+ loadShopInventory,
+ subscribeShopInventory,
+ unlockShopItem,
+ type ShopInventory,
+} from './shopInventory'
+import {
+ loadShopCatalog,
+ type CharacterAccessoryItem,
+ type CursorItem,
+ type ShopCatalogItem,
+ type ThemeItem,
+} from './shopCatalog'
+import { renderCursorCssValue } from './shopCursorAsset'
+import { renderStampPreviewForItem } from './shopStampAsset'
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
+
+ pointer
+
+
+
+
+ )
+}
+
+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 (
+
+ )
+}
export function Shoppe({
onScoreChange,
@@ -21,20 +148,81 @@ 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])
+ 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.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
+ }
+
+ 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'
+ if (item.type === 'characterAccessory') {
+ if (item.characterAccessory.scope === 'fullBody') return 'Full-body accessory'
+ if (item.characterAccessory.scope === 'hair') return 'Hair accessory'
+ if (item.characterAccessory.scope === 'wrist') return 'Wrist accessory'
+ if (item.characterAccessory.scope === 'shirt') return 'Shirt accessory'
+ if (item.characterAccessory.scope === 'shoes') return 'Shoe accessory'
+ if (item.characterAccessory.scope === 'pants') return 'Pants accessory'
+ if (item.characterAccessory.scope === 'face') return 'Face accessory'
+ if (item.characterAccessory.scope === 'hat') return 'Hat accessory'
+ return 'Pet accessory'
+ }
+ 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.'
+ if (item.type === 'characterAccessory') {
+ return 'Unlocks an accessory for the Character lab.'
+ }
+ return 'Collectible image item for future gallery drops.'
+ }
+
+ 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 +261,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 +269,57 @@ 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(
+ item.type === 'characterAccessory'
+ ? `${item.name} unlocked. Customize it in Character lab.`
+ : 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 +333,10 @@ export function Shoppe({
collected
{collection.length}
+
+
shop unlocks
+
{inventory.ownedItemIds.length}
+
{pointsOn ? (
balance
@@ -105,6 +349,73 @@ 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 accessory = item.type === 'characterAccessory'
+ const equipped = equippable && equippedId(item.type) === item.id
+ const preview = itemPreview(item)
+ return (
+
+
+
+
{item.name}
+
{itemTypeLabel(item)}
+
+
{item.cost} pts
+
+ {item.description}
+ {catalogHint(item)}
+
+
+ {!owned ? (
+ void buyShopItem(item)}
+ >
+ {busyItemId === item.id ? 'Unlocking…' : 'Unlock'}
+
+ ) : equippable ? (
+ toggleEquip(item)}
+ >
+ {equipped ? 'Unequip' : 'Equip'}
+
+ ) : (
+
+ Owned
+
+ )}
+ {owned ? (
+
+ {equipped ? 'Equipped' : accessory ? 'Owned' : 'Unlocked'}
+
+ ) : null}
+
+
+ )
+ })}
+
+
+
@@ -136,6 +447,11 @@ export function Shoppe({
{error}
) : null}
+ {status ? (
+
+ {status}
+
+ ) : null}
diff --git a/src/features/shop/shopCatalog.ts b/src/features/shop/shopCatalog.ts
new file mode 100644
index 0000000..05c21a4
--- /dev/null
+++ b/src/features/shop/shopCatalog.ts
@@ -0,0 +1,361 @@
+import catalogJson from '../../../asset/shop/shoppe-items.json?raw'
+
+export type ShopItemType = 'image' | 'theme' | 'stamp' | 'cursor' | 'characterAccessory'
+
+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 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
+ 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 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: '' }
+ 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
+ }
+ 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
+}
+
+let catalogCache: ShopCatalog | null = null
+
+export function loadShopCatalog(): ShopCatalog {
+ if (catalogCache) return catalogCache
+ let parsed: unknown
+ 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/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/shopCosmetics.tsx b/src/features/shop/shopCosmetics.tsx
new file mode 100644
index 0000000..d97a88c
--- /dev/null
+++ b/src/features/shop/shopCosmetics.tsx
@@ -0,0 +1,95 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useTheme } from '../../shared/theme/useTheme'
+import {
+ loadShopCatalog,
+ type CursorItem,
+ type ShopCatalogItem,
+ type ThemeItem,
+} from './shopCatalog'
+import { renderCursorCssValue } from './shopCursorAsset'
+import {
+ loadShopInventory,
+ subscribeShopInventory,
+ type ShopInventory,
+} from './shopInventory'
+
+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 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 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)
+}
+
+function useShopInventoryState() {
+ const [inventory, setInventory] = useState(() => loadShopInventory())
+ useEffect(() => subscribeShopInventory((next) => setInventory(next)), [])
+ return inventory
+}
+
+function useShopCatalogState() {
+ return useMemo(() => loadShopCatalog(), [])
+}
+
+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/shopCursorAsset.ts b/src/features/shop/shopCursorAsset.ts
new file mode 100644
index 0000000..5703052
--- /dev/null
+++ b/src/features/shop/shopCursorAsset.ts
@@ -0,0 +1,131 @@
+import pointerTemplateSvg from '../../../asset/shop/pointer.svg?raw'
+import type { CursorItem } from './shopCatalog'
+
+export type CursorContext = 'default' | 'pointer' | 'text'
+
+const FALLBACK_HOTSPOT: Record = {
+ default: [3, 3],
+ pointer: [3, 3],
+ text: [7, 12],
+}
+const CURSOR_RENDER_SIZE = '24'
+
+function serializeSvgElement(svg: SVGSVGElement) {
+ const raw = new XMLSerializer().serializeToString(svg)
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(raw)}`
+}
+
+function hintedContext(hint: string | undefined | null): CursorContext | null {
+ const normalized = hint?.trim().toLowerCase()
+ if (!normalized) return null
+ if (normalized.includes('point') || normalized.includes('pointer')) return 'pointer'
+ if (normalized.includes('text')) return 'text'
+ if (normalized.includes('default') || normalized.includes('base')) return 'default'
+ return null
+}
+
+function hintForElement(el: SVGElement): string {
+ const dataContext = el.getAttribute('data-context') ?? ''
+ const inkLabel = el.getAttribute('inkscape:label') ?? ''
+ return `${dataContext} ${inkLabel}`.trim()
+}
+
+function applyLegacyContextLayers(svg: SVGSVGElement, context: CursorContext): boolean {
+ const contextLayers = svg.querySelectorAll('g[data-context]')
+ if (!contextLayers.length) return false
+ contextLayers.forEach((layer) => {
+ const active = layer.dataset.context === context
+ layer.style.display = active ? 'inline' : 'none'
+ })
+ return true
+}
+
+function applyHintedContextVisibility(svg: SVGSVGElement, context: CursorContext) {
+ const drawables = Array.from(
+ svg.querySelectorAll('path,rect,circle,ellipse,polygon,polyline'),
+ )
+ if (!drawables.length) return
+
+ let foundContextHint = false
+ drawables.forEach((el) => {
+ const hinted = hintedContext(hintForElement(el))
+ if (!hinted) return
+ foundContextHint = true
+ el.style.display = hinted === context ? 'inline' : 'none'
+ })
+ if (!foundContextHint) return
+
+ if (context === 'default') {
+ const explicitDefault = drawables.find((el) => hintedContext(hintForElement(el)) === 'default')
+ const outlineFallback = drawables.find((el) => el.dataset.fill === 'outline')
+ const unlabeledFallback = drawables.find((el) => hintedContext(hintForElement(el)) === null)
+ const active = explicitDefault ?? outlineFallback ?? unlabeledFallback
+ if (active) active.style.display = 'inline'
+ return
+ }
+
+ drawables.forEach((el) => {
+ if (hintedContext(hintForElement(el)) === null) {
+ el.style.display = 'none'
+ }
+ })
+}
+
+function colorForContext(item: CursorItem, context: CursorContext) {
+ if (context === 'text') return item.cursor.textPrimary ?? item.cursor.primary
+ if (context === 'default') return item.cursor.outline
+ return item.cursor.primary
+}
+
+function applyCursorColors(svg: SVGSVGElement, item: CursorItem, context: CursorContext) {
+ const fills = svg.querySelectorAll('[data-fill]')
+ fills.forEach((el) => {
+ const role = el.dataset.fill
+ if (role === 'primary') el.style.fill = item.cursor.primary
+ if (role === 'secondary') el.style.fill = item.cursor.secondary
+ if (role === 'outline') el.style.fill = item.cursor.outline
+ if (role === 'text') el.style.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.style.stroke = item.cursor.outline
+ if (role === 'primary') el.style.stroke = item.cursor.primary
+ })
+
+ // New pointer assets do not need data-fill/data-stroke attributes;
+ // tint unlabeled geometry from context to keep previews and live cursors aligned.
+ const drawables = svg.querySelectorAll('path,rect,circle,ellipse,polygon,polyline')
+ drawables.forEach((el) => {
+ if (el.dataset.fill || el.dataset.stroke) return
+ const fillAttr = el.getAttribute('fill')?.toLowerCase()
+ if (fillAttr !== 'none') {
+ el.style.fill = colorForContext(item, context)
+ }
+ const strokeAttr = el.getAttribute('stroke')?.toLowerCase()
+ if (strokeAttr && strokeAttr !== 'none') {
+ el.style.stroke = item.cursor.outline
+ }
+ })
+}
+
+export function renderCursorCssValue(item: CursorItem, context: CursorContext): string | null {
+ const doc = new DOMParser().parseFromString(pointerTemplateSvg, 'image/svg+xml')
+ const svg = doc.documentElement
+ if (!(svg instanceof SVGSVGElement)) return null
+
+ svg.setAttribute('width', CURSOR_RENDER_SIZE)
+ svg.setAttribute('height', CURSOR_RENDER_SIZE)
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
+
+ const usedLegacyContext = applyLegacyContextLayers(svg, context)
+ if (!usedLegacyContext) {
+ applyHintedContextVisibility(svg, context)
+ }
+ applyCursorColors(svg, item, context)
+
+ const hotspot = item.cursor.hotspot?.[context]
+ const [hx, hy] = hotspot ?? FALLBACK_HOTSPOT[context]
+ return `url("${serializeSvgElement(svg)}") ${hx} ${hy}, auto`
+}
diff --git a/src/features/shop/shopInventory.ts b/src/features/shop/shopInventory.ts
new file mode 100644
index 0000000..91f2fa6
--- /dev/null
+++ b/src/features/shop/shopInventory.ts
@@ -0,0 +1,192 @@
+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
+ characterAccessoryIds: string[]
+ characterAccessoryChoices: Record
+}
+
+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,
+ characterAccessoryIds: [],
+ characterAccessoryChoices: {},
+ },
+ 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,
+ 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,
+ }
+}
+
+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 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 })
+}
+
+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
+ cb(detail ?? loadShopInventory())
+ }
+ window.addEventListener(SHOP_INVENTORY_CHANGED_EVENT, handler)
+ return () => window.removeEventListener(SHOP_INVENTORY_CHANGED_EVENT, handler)
+}
diff --git a/src/features/shop/shopStampAsset.ts b/src/features/shop/shopStampAsset.ts
new file mode 100644
index 0000000..a757ddc
--- /dev/null
+++ b/src/features/shop/shopStampAsset.ts
@@ -0,0 +1,144 @@
+import { useEffect, useMemo, useState } from 'react'
+import stampTemplateSvg from '../../../asset/shop/stamp.svg?raw'
+import { loadShopCatalog, type StampItem } from './shopCatalog'
+import { loadShopInventory, subscribeShopInventory, type ShopInventory } from './shopInventory'
+
+const DEFAULT_STAMP_TEXT = 'STAMP'
+const DEFAULT_STAMP_INK = '#c61d1d'
+const DEFAULT_STAMP_OUTLINE = '#9e1717'
+const DEFAULT_STAMP_TEXT_COLOR = '#9e1717'
+const DEFAULT_STAMP_SRC = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(stampTemplateSvg)}`
+const DEFAULT_STAMP_ROTATION = -12
+const DEFAULT_STAMP_OPACITY = 0.88
+
+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 clampNumber(value: number | undefined, fallback: number, min: number, max: number) {
+ if (!Number.isFinite(value)) return fallback
+ return Math.min(max, Math.max(min, value as number))
+}
+
+function normalizedStampText(value: string) {
+ const clean = value.replace(/\s+/g, ' ').trim().toUpperCase()
+ if (!clean) return DEFAULT_STAMP_TEXT
+ return clean.slice(0, 20)
+}
+
+function escapeXmlText(value: string) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+}
+
+function svgElementById(doc: Document, id: string) {
+ const el = doc.getElementById(id)
+ return el instanceof SVGElement ? el : null
+}
+
+function renderSimpleStampDataUri(item: StampItem): string {
+ const text = escapeXmlText(normalizedStampText(item.stamp.text))
+ const textColor = item.stamp.textColor ?? item.stamp.outline
+ const tilt = clampNumber(item.stamp.tiltDeg, DEFAULT_STAMP_ROTATION, -36, 36)
+ const opacity = clampNumber(item.stamp.opacity, DEFAULT_STAMP_OPACITY, 0.32, 1)
+ const viewBox = '0 0 256 256'
+ const svg = `${text} `
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
+}
+
+function renderLegacyTemplateDataUri(item: StampItem, doc: Document): string {
+ const svg = doc.documentElement
+ if (!(svg instanceof SVGSVGElement)) return renderSimpleStampDataUri(item)
+
+ const stampRoot = svgElementById(doc, 'stamp-root')
+ const border = svgElementById(doc, 'stamp-border')
+ const inner = svgElementById(doc, 'stamp-inner')
+ const text = svgElementById(doc, 'stamp-text')
+ const tilt = clampNumber(item.stamp.tiltDeg, DEFAULT_STAMP_ROTATION, -36, 36)
+ const opacity = clampNumber(item.stamp.opacity, DEFAULT_STAMP_OPACITY, 0.32, 1)
+
+ if (stampRoot) {
+ stampRoot.setAttribute('transform', `rotate(${tilt} 128 128)`)
+ stampRoot.style.opacity = String(opacity)
+ }
+ if (border) {
+ border.style.stroke = item.stamp.outline
+ border.style.fill = 'none'
+ }
+ if (inner) {
+ inner.style.stroke = item.stamp.ink
+ inner.style.fill = 'none'
+ inner.style.strokeDasharray = '8 7'
+ }
+ if (text) {
+ text.style.fill = item.stamp.textColor ?? item.stamp.outline
+ text.setAttribute('font-size', '84')
+ text.setAttribute('letter-spacing', '2.8')
+ text.textContent = normalizedStampText(item.stamp.text)
+ }
+ return serializeSvgElement(svg)
+}
+
+function renderStampDataUri(item: StampItem): string {
+ const doc = new DOMParser().parseFromString(stampTemplateSvg, 'image/svg+xml')
+ const svg = doc.documentElement
+ if (!(svg instanceof SVGSVGElement)) return renderSimpleStampDataUri(item)
+
+ const hasLegacyTargets = Boolean(
+ svgElementById(doc, 'stamp-root') &&
+ svgElementById(doc, 'stamp-border') &&
+ svgElementById(doc, 'stamp-inner') &&
+ svgElementById(doc, 'stamp-text'),
+ )
+ if (hasLegacyTargets) return renderLegacyTemplateDataUri(item, doc)
+ return renderSimpleStampDataUri(item)
+}
+
+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: DEFAULT_STAMP_SRC,
+ 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: normalizedStampText(equipped.stamp.text),
+ ink: equipped.stamp.ink,
+ outline: equipped.stamp.outline,
+ textColor: equipped.stamp.textColor ?? equipped.stamp.outline,
+ }
+ }, [inventory])
+}
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 c7691cb..7738d2c 100644
--- a/src/shared/account/accountDataService.ts
+++ b/src/shared/account/accountDataService.ts
@@ -1,6 +1,8 @@
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'
@@ -32,6 +34,8 @@ export async function clearLocalJournalData() {
getDb().stickies.clear(),
getDb().packages.clear(),
])
+ await clearCharacterAppearance()
+ clearShopInventory()
for (const key of SCORE_KEYS) localStorage.removeItem(key)
window.dispatchEvent(new CustomEvent(SCORE_CHANGED_EVENT))
}
@@ -54,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(() => {})),
)
@@ -65,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 })
@@ -80,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/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
+
+declare module '*.svg?raw' {
+ const content: string
+ export default content
+}
///
+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