This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Classicy is a React/TypeScript UI framework that replicates the Mac OS 8 (Platinum) interface. It's distributed as an npm package with ES and UMD module formats.
This project uses pnpm (enable via corepack enable). The repo is a pnpm workspace: the root is the classicy library and example/ is a member that consumes it via a workspace:* dependency — no npm link needed.
pnpm install # Install dependencies (root + example workspace)
pnpm build:source # TypeScript + Vite build only (fastest iteration)
pnpm build # Full build (audio sprites + source)
pnpm build:audio # Generate audio sprites from assets/sounds/
pnpm build:watch # Watch mode: rebuilds source + audio on file changes
pnpm lint # Run Biome (`biome check .`); lint:fix writes changes
pnpm preview # Full build → run example app
pnpm storybook # Run the component showcase (Storybook) dev server
pnpm build:storybook # Build static Storybook to storybook/storybook-static/Local dev workflow: pnpm build:source to (re)build the library; the example/ app resolves it automatically through the workspace symlink. For live iteration use pnpm build:watch (or pnpm preview, which builds then runs the example dev server with the source watcher).
@/→./src/@snd/→./assets/sounds/@img/→./assets/img/
ClassicyAppManagerProvider # Thin wrapper - Analytics + SoundManager only
└── AnalyticsProvider # Google Analytics/GTM
└── ClassicySoundManagerProvider # Sound (still uses React Context)
└── ClassicyDesktop # Desktop surface with icons and menu bar
├── ClassicyDesktopMenuBar
└── ClassicyApp # Individual application container
└── ClassicyWindow # Window component with controls
└── UI Components (Button, Input, Tabs, etc.)
Uses Zustand for app/desktop state with the existing event reducer for domain logic:
- Zustand store (
src/SystemFolder/ControlPanels/AppManager/ClassicyAppManagerUtils.tsx) - Global store created withcreate<ClassicyStoreWithActions>() useAppManager(selector)- Zustand hook to read state. Use selectors for performance.useAppManagerDispatch()- Returns the dispatch function from the Zustand store- classicyDesktopStateEventReducer (
ClassicyAppManager.ts) - Still routes events by prefix to domain-specific handlers (called inside Zustand'sset()) - SoundManager still uses React Context/useReducer (not migrated to Zustand)
Event routing by prefix (unchanged):
ClassicyWindow*→ Window operations (focus, move, resize, collapse, zoom)ClassicyDesktop*→ Desktop operations (menus, themes, backgrounds)ClassicyDesktopIcon*→ Icon selection and interactionClassicyApp*→ App lifecycle (open, close, focus, activate)ClassicyAppFinder*,ClassicyAppMoviePlayer*,ClassicyAppPictureViewer*→ App-specific handlersClassicyManagerDateTime*→ Date/time manager operations
State persists to localStorage (key: classicyDesktopState) via Zustand's subscribe() on every state change.
src/SystemFolder/ControlPanels/- System-level managers (AppManager, SoundManager, AppearanceManager, DateAndTimeManager)src/SystemFolder/SystemResources/- Reusable UI components (Window, Button, Input, Menu, BalloonHelp, etc.)src/SystemFolder/Finder/- Finder app implementationsrc/SystemFolder/QuickTime/- Media player appsexample/- Standalone Vite app that consumes the built package for local testing
Apps follow this pattern:
- Use
ClassicyAppwrapper with id, name, icon props - Use
useAppManager(selector)to read state with a selector for performance - Use
useAppManagerDispatch()to get the dispatch function for events - Wrap content in
ClassicyWindowcomponents - Create an event handler in
ClassicyAppManager.tsif the app needs custom state
// Reading state — always use a selector to avoid unnecessary re-renders
const appState = useAppManager(state => state.System.Manager.App.apps[id]);
// Dispatching actions
const dispatch = useAppManagerDispatch();
dispatch({ type: 'ClassicyAppOpen', app: { id, name, icon } });Apps register through registerApp (ClassicyAppManifest.ts) — one call
declaring routing, actions, and state shape as zod schemas with .describe()
commentary:
actions: per-type{ description, params?, scriptable? }.scriptable: trueexposes the action to HyperCard stack scripts (delegates to the untrusted-action allowlist; the guarded-route floor still applies, and script-authored args are validated againstparamsbefore dispatch).state: az.looseObjectdescribingapps[id].data. MUST be loose (the kernel writes undeclared keys likeopenFilesqueues) with.optional()top-level fields. In dev builds the kernel warns (never rejects) when a routed app's data fails its schema.- Re-registering an id merges additively (HyperCard.app spans two modules:
player and editor prefixes); the same prefix twice is a no-op, actions
merge first-wins, and the first
stateschema wins. - Read side:
getAppManifest,listAppManifests,listScriptableActions,describeAppAction/describeAppState(balloon-ready{title, content}), andparseAppData(typed guard replacing hand-rolledisXDatafunctions).
registerAppEventHandler and registerClassicyUntrustedActionAllowlist are
deprecated in favor of registerApp but keep working unchanged.
ClassicyApp controls its two icon surfaces independently. Both default to on;
extension apps get neither.
<ClassicyApp
id="TV.app" name="TV" icon={icon}
showDesktopIcon={false} // keep it off the desktop
showInApplicationsFolder={true} // but list it in Applications
desktopIconBalloonHelp="Double-click to watch TV."
/>showDesktopIcon(defaulttrue) — draw an icon on the desktop.showInApplicationsFolder(defaulttrue) — list the app in the derived Applications folder. Independent ofshowDesktopIcon, so all four combinations are reachable; turning both off removes any icon record.desktopIconBalloonHelp—string(titled with the app name) or{ title?, content, position?, delay? }. When omitted, the app's registered manifest description (registerApp) is the balloon, so every registered app — bundled apps included — carries purpose copy with no extra wiring. Every bundled app registers a described manifest; apps without a reducer use a description-only*Manifest.tsmodule (pinned byClassicyBundledAppManifests.test.ts).
noDesktopIcon and inApplicationsFolder are deprecated aliases. Note the
behavior change: noDesktopIcon alone no longer keeps an app out of
Applications — pass showInApplicationsFolder={false} for that.
The Trash and drive icons carry stock balloon help automatically
(ClassicyDesktopIconBalloons.ts), resolved by icon kind at render time.
Desktop icons whose kind is app_shortcut or shortcut — every icon
ClassicyApp registers — render the Mac OS 8 alias arrow over the icon's
bottom-left corner and italicize their label. System kinds (drive, trash,
directory, file, icon) get neither. The predicate lives in
ClassicyDesktopIconKinds.ts; the badge is a second mask pair reusing
classicyDesktopIconMaskOuter/classicyDesktopIconMask, so it inherits the
selected and open state styling automatically. Swap the artwork by registering
your own system.alias entry via registerClassicyIcons.
useClassicyAnalytics() returns track(eventName, payload) and
page(path, title). Both are referentially stable, and both fall back to
silent no-ops when no AnalyticsProvider is mounted. Event names are prefixed
with ClassicyAnalyticsPrefixContext (default classicy_); pageview paths
are not — a path is a URL namespace, not an event name.
ClassicyWindow emits a pageview whenever a window becomes open (whether or
not it is focused) and whenever an open window gains focus — there is no
deduplication between the two. Emission waits until the window is actually
registered in the store: a brand-new window's first render has no store entry
yet, so nothing is emitted for that render; the first commit where the window
is present in the store is what counts as "just opened", even though that
commit already has the window focused (opening a window focuses it in the same
store update), so that's a single emit, not two. A window that opens unfocused
(e.g. several windows restored on reload) emits on open, then emits again when
it's later focused. Closing and blurring emit nothing.
<ClassicyWindow analyticsPath="/editor/compose" /> // override the derived path
<ClassicyWindow analyticsExclude /> // no pageview at allPaths are derived by ClassicyAnalyticsPath.ts as /<app>/<window>: the
appId minus a trailing .app, plus the window id with any redundant app
prefix stripped. A window id containing a filesystem separator (: or /)
is treated as user data and collapses to /file or /folder — ClassicyApp
builds file-window ids as `${id}_file_${filePath}` and Finder keys its
windows by folder path, so slugifying either would leak user file names into GA
and make path cardinality unbounded.
That collapse is a syntactic rule — it can only catch user data that happens
to carry a separator. If your app builds a window id from user or consumer
data, pass analyticsPath explicitly; only the app knows that a segment is
user data. Movie Player and Picture Viewer do this, because their window ids
embed a document key that may be a bare relative URL (clip.mp4) with no
separator to catch. Build the override with
classicyWindowPagePath(appId, "<safe-token>") rather than hand-writing the
path, so the app segment stays in sync with appId.
The window title is sent to GA verbatim, including titles derived from user file names. That is a deliberate trade-off for readable content reports; the path is what stays free of user data.
ClassicyBalloonHelp is a Mac OS 8-style tooltip component. Wrap any element with it to show a speech-bubble tooltip after a delay:
<ClassicyBalloonHelp content="Click to open" title="Open File" position="top-left">
<ClassicyButton>Open</ClassicyButton>
</ClassicyBalloonHelp>position: one oftop-left | top-center | top-right | bottom-left | bottom-center | bottom-right(defaulttop-left)delay: hover delay in ms before balloon appears (default600)- Rendered via a React portal into
#classicyDesktopso it is never clipped by parent overflow - The wrapper is
position: relative; display: inline-block. If that would break your layout (e.g. an absolutely positioned element), use theuseClassicyBalloonHelp(ref, config)hook instead — it returns{ handlers, balloon }to spread onto an element you already own, adding no DOM.ClassicyDesktopIcondoes this. - Globally disabled by
System.Manager.Desktop.disableBalloonHelp(Zustand store). Toggle with eventClassicyDesktopSetBalloonHelp— e.g.dispatch({ type: 'ClassicyDesktopSetBalloonHelp', disableBalloonHelp: true })
All internal diagnostics go through classicyLog(level, subsystem, message, ...details) (src/SystemFolder/SystemResources/Log/ClassicyLog.ts) — never
call console.warn/error directly in library code (the only exceptions: the
debug-flag console.group state dumps, the crash boundary's own
console.error, and ClassicyLog's sink-failure report). Hosts subscribe via
registerClassicyLogSink({ id, onLog?, onError?, onCrash? }).
- Sinks get every entry including in production; the console mirror is
dev-only for
debug/info/warnand unconditional forerror. - Console format is
[Subsystem] message— byte-identical to the pre-pipeline bracket prefixes, so tests asserting console output keep passing. onCrashis fed byClassicyCrashScreen.componentDidCatchviaemitClassicyCrash.- Sink exceptions are isolated and reported straight to the console (never back through the pipeline — recursion).
ScreenSaver.app (src/SystemFolder/Extensions/ScreenSaver/) is an extension
mounted by ClassicyDesktop that idle-activates a full-viewport screensaver.
Idle detection is wall-clock (document-level activity listeners + refs;
independent of the Classicy virtual clock, and never writes the store per
event — only the activate/deactivate transitions dispatch). The waking
keystroke/click is swallowed in the capture phase.
- State in
apps["ScreenSaver.app"].data:enabled(default true),timeoutMinutes(default 5, clamped 1–240),selectedSaver(defaultbouncing-ball),saverConfigs(per-saver options), and transientactive(stripped insanitizeStateForPersistence). - Actions (prefix
ClassicyAppScreenSaver):Activate(scriptable),Deactivate,SetSaver { saverId },SetTimeout { minutes },SetEnabled { enabled },SetConfig { saverId, config }(validated against the saver's zod schema). - Register savers at module scope:
registerClassicyScreenSaver({ id, name, component, configSchema?, defaultConfig?, configComponent?, transparentBackground? })(ClassicyScreenSaverRegistry.tsx). Re-registering an id replaces it. Options UI is two-tier: a customconfigComponent, else a form derived fromconfigSchema(ClassicyScreenSaverConfigForm).transparentBackgroundsavers (Fade Out, Spotlight) reveal the live desktop through the overlay. - 12 built-ins ported from bryanbraun/after-dark-css (
savers/; CSS is MIT, sprite art © Berkeley Systems). Keyframes/classes are prefixedclassicySaver*to avoid bundle-level CSS collisions. - The Screen Saver control panel
(
ControlPanels/ScreenSaverManager/) is part ofClassicyControlPanels. - Overlay z-index is 100001 (above dialog overlay 99999 and startup 100000); reduced-motion users get a paused (static) saver.
ClassicySplitView (src/SystemFolder/SystemResources/SplitView/) is a
resizable container splitting two or three content areas along one axis:
<ClassicySplitView direction="horizontal" defaultSizes={[30, 70]} onResizeCommit={saveSizes}>
<Sidebar />
<Content />
</ClassicySplitView>direction("horizontal"default = side-by-side),minPaneSize(px, default 48),defaultSizes(percentages, normalized to 100; equal split when the length doesn't match the pane count).- Max 3 panes per component (extras dropped with a dev warning) — nest a split view inside a pane to combine directions.
- Sizes are uncontrolled after mount.
onResizefires per drag step;onResizeCommitfires once per gesture (mouse release / arrow-key release) — persist there and feed the saved sizes back asdefaultSizes. - The divider follows the ARIA window-splitter pattern (
role="separator", focusable, arrow keys nudge by 1%) and uses the same document-level drag listener idiom asClassicyWindow. Pure resize math is exported ascomputeSplitViewSizesfor tests.
Right-click menus resolve target-based, innermost wins: a ClassicyContextualMenuTarget-wrapped control > the window's contextMenu prop > the app's contextMenu prop (ClassicyApp) > the desktop default menu (empty desktop only). Desktop icons take an optional contextMenu prop. A single ClassicyContextualMenuProvider (mounted by ClassicyDesktop) renders the one open menu via portal.
<ClassicyContextualMenuTarget menuItems={[{ id: "copy", title: "Copy" }]}>
<ClassicyButton>Copy</ClassicyButton>
</ClassicyContextualMenuTarget>- Components with custom right-click behavior and no menu call
e.preventDefault()in their ownonContextMenu; every menu layer checkse.defaultPreventedand stays silent. - If neither a window nor its app defines a menu, right-click shows nothing (the native browser menu stays suppressed inside the desktop).
- Right-clicking a window focuses it first, so the active menu always tracks focus.
ClassicyFileSystem is browser-local (localStorage-primary), but consumers can
mirror it to a backend by registering an adapter at app entry
(src/SystemFolder/SystemResources/File/ClassicyFileSystemAdapter.ts):
registerClassicyFileSystemAdapter({
id: 'my-backend',
onChange: (entry) => {}, // journal mode: every mutation, sequenced
onSnapshot: (snapshot) => {}, // snapshot mode: debounced full tree + sha256 hash
reconcile: async (local) => ({ action: 'useLocal' }), // two-way boot sync
}, { snapshotDebounceMs: 500 })All methods are optional (capability-based). Every mutation flows through
writeFile/mkDir/rmDir/load/setMetadata — never mutate fs.fs or
entries directly; use fs.setMetadata(path, patch) for metadata changes.
Snapshots carry a sha256 hash and a persisted monotonic seq for drift/gap
detection. At boot, reconcile may return
{ action: 'replace', tree } to adopt a remote tree (validated, persisted,
then rebuilt via the store's fsVersion bump); errors always degrade to
local-wins. Derived folders (Applications, Extensions) are applied via
applyDerivedTree() and never journal. Design spec:
docs/superpowers/specs/2026-07-20-filesystem-adapter-design.md.
classicyDesktopStateEventReducer takes an optional trust argument
(ClassicyActionTrust, default "trusted"). Dispatching an action with
"untrusted" — e.g. an effect produced by an interpreted HyperCard stack
script — makes it clear two checks in
src/SystemFolder/ControlPanels/AppManager/ClassicyActionTrust.ts before any
handler, built-in or plugin, sees it:
- The guarded-route floor (
isActionTrustGuarded) —ClassicyDesktop*,ClassicyWindow*,ClassicyAppFinderEmptyTrash,ClassicyAppHCEditSetScriptare unreachable to untrusted actions, unconditionally. Nothing can opt back in. - The deny-by-default allowlist — an untrusted action must also be an allowlisted type. Clearing the floor is necessary but not sufficient.
Both are enforced in the kernel, not only at call sites. That is deliberate:
the guarantee must not depend on every dispatch site remembering to check, so a
call site that forgets still cannot reach a non-allowlisted route. There is a
single definition of the rule — isActionTrustPermitted("untrusted", …)
delegates to isUntrustedActionAllowed, so a call-site check and the kernel's
own check can never disagree.
Call sites should still consult the gate explicitly, because for something like
a HyperCard effect the action type itself is script-authored data — checking
first lets the caller drop the effect quietly instead of dispatching a doomed
action and tripping the reducer's warning:
import {
isUntrustedActionAllowed,
registerClassicyUntrustedActionAllowlist,
} from 'classicy/SystemFolder/ControlPanels/AppManager/ClassicyActionTrust'
registerClassicyUntrustedActionAllowlist('ClassicyAppMyCustomEffect')
// at the dispatch call site:
if (!isUntrustedActionAllowed(actionType)) return // drop the effect quietly
dispatch(action, 'untrusted') // the kernel re-checks the same rule anywayThe default allowlist holds exactly one entry, ClassicyAppOpen — HyperCard's
core function, so a stack opens apps with zero host configuration. Every other
type must be registered explicitly. Registering can never grant a type past the
floor: isUntrustedActionAllowed ANDs allowlist membership with
!isActionTrustGuarded(...), so allowlisting e.g. ClassicyDesktopSetTheme has
no effect. Registering the same type twice is a no-op, matching
registerAppEventHandler.
Themes are JSON-based (src/SystemFolder/ControlPanels/AppearanceManager/styles/themes.json) and control:
- Typography (body, ui, header fonts)
- Colors (system palette, theme accent colors)
- Desktop appearance (background, patterns)
- Uses mise for tool version management (
mise.toml) — Node 24, ffmpeg 8.0.1 - ffmpeg is built from source on macOS and Linux (prebuilt binaries were inconsistent and missing codecs).
mise installrunsbin/ffmpeg-deps.shvia apreinstallhook, which installs the required development libraries withbreworapt-get— on Linux that means an automaticsudoprompt the first time. The script is a fast no-op once the libraries are present. Runbin/ffmpeg-deps.sh --checkto see what's missing without installing. Its package lists must stay in sync withASDF_FFMPEG_ENABLEinmise.toml; it must stay executable (mise runs hooks viash, so the#!/bin/bashshebang is what provides bash semantics). Non-apt Linux distros are reported, not automated. CI deliberately still uses the distroffmpegpackage. pnpm build:sourcerunsgenerate-barrelsfirst — barrelsby auto-generates allindex.tsbarrel files. Don't manually edit barrel files.- Audio sprites generated via audiosprite from
assets/sounds/directories - Library outputs to
dist/asclassicy.es.jsandclassicy.umd.js - Consumers must import the CSS separately:
import 'classicy/dist/classicy.css' - Breaking change: bundled
@font-facerules (base64-embedded fonts) no longer ship insideclassicy.css— they're extracted at build time (see thesplitFontFacesPlugininvite.config.ts) into a separate, opt-in stylesheet. Consumers who want the bundled fonts must additionallyimport 'classicy/dist/fonts.css'; consumers who supply their own fonts, or don't need the bundled ones, can skip it and avoid the payload entirely. Both stylesheets are exposed viapackage.json'sexportsmap. - All styling uses SCSS files co-located with components — no Tailwind or inline styles for layout/presentation
@.claude/wiz-claude.md