diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 319766f..5105dc8 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -1,49 +1,74 @@ -# Architecture Reference (v0.8) +# Architecture Reference (v0.9.0-beta) ## Stack -Vite + React 19 + TypeScript + Tailwind CSS v4 + Zustand (persist middleware) + React Router v6 +Vite + React 19 + TypeScript + Tailwind CSS v4 + Zustand (persist + non-persisted UI store) + React Router v6. Meadow design tokens (CSS custom properties in `src/index.css` `@theme`); Fraunces (display) + Inter (UI). ## Key Files -- `src/lib/store.ts` — Zustand store, persist v3, 3-level donation schema -- `src/lib/types.ts` — GameId union, Town, Game interface, GAMES registry -- `src/lib/constants.ts` — MONTH_NAMES, CATEGORY_LABELS, SEASONS (single source of truth) -- `src/lib/colors.ts` — design token hex constants -- `src/lib/categoryMeta.ts` — CATEGORY_META constant (label/Icon/file per category) -- `src/lib/viewTypes.ts` — ViewId and AllData types -- `src/lib/storeMigrations.ts` — Zustand migrate callback (v1→v2→v3; backfills gameId, then hemisphere) -- `src/lib/bootstrapMigration.ts` — one-time localStorage key rename, called in main.tsx before createRoot -- `src/hooks/useHydration.ts` — onFinishHydration guard, prevents flash of empty state -- `src/hooks/useMuseumData.ts` — fetches and caches all category JSONs for the active town's game (including sea creatures for ACNH); accepts `gameId` and fetches from `/data//`; re-fetches when active town's game changes -- `src/hooks/useSearch.ts` — search history, click-outside, debounce -- `src/hooks/useCategoryStats.ts` — memoized donated counts per category -- `src/components/ErrorBoundary.tsx` — wraps app root, crashes show ErrorState not blank page -- `src/components/ACCanvas.tsx` — ~298-line orchestration shell; decomposition complete (v0.7 PR #25); wires ItemExpandPanel inline-expand and DetailModal bottom-sheet -- `src/components/ItemExpandPanel.tsx` — inline accordion panel for Fish/Bugs/Fossils rows (month grid, bells, habitat, donate toggle) -- `src/components/shared/` — HabitatChip, DonateToggle, CategoryProgress, SearchBar, EmptyState, MonthGrid -- `src/components/modals/` — CreateTownModal (game selector, new town form), EditTownModal, DetailModal (bottom-sheet for Art + Search results) -- `src/components/views/` — AnalyticsView, ActivityFeed, SectionCard -- `src/components/search/` — GlobalSearchBar, GlobalSearchResults, SearchHistoryPopover -- `src/components/CollectibleRow.tsx`, `TownSwitcher.tsx`, `MuseumHeader.tsx`, `TabBar.tsx` -- `public/data//` — item data files for all 5 games: acgcn/, acww/, accf/, acnl/, acnh/ all present + +### State + data +- `src/lib/store.ts` — Zustand app store, persist key `ac-web` schema v3, 3-level donation schema. `createTown(name, gameId, hemisphere?)`, `updateTown(id, patch: { name?, hemisphere? })` — `gameId` is intentionally not patchable post-create (Decision 1). Includes `resetActiveTownDonations()` + `resetAll()` for the Phase 3 Settings danger zone. +- `src/lib/uiStore.ts` — non-persisted Zustand store for transient UI state (`townManagerOpen`, `townManagerForceCreate`). +- `src/lib/types.ts` — GameId union, `Town` (no longer has `playerName` — removed in Phase 4 / Decision 5), Game interface, GAMES registry, GAME_LIST. +- `src/lib/constants.ts` — MONTH_NAMES, CATEGORY_LABELS, CATEGORY_ORDER, SEASONS. +- `src/lib/colors.ts` — `meadow` token export (mirrors CSS custom properties), `fontStacks` for Fraunces/Inter, legacy `colors` retained until consumers retire. +- `src/lib/categoryMeta.ts` — CATEGORY_META (label / Icon / file / data presence per game). +- `src/lib/viewTypes.ts` — ViewId union and AllData shape. Phase 8 removed the `'search'` route. +- `src/lib/storeMigrations.ts` — Zustand migrate callback v1→v2 (gameId backfill) and v2→v3 (hemisphere backfill). +- `src/lib/bootstrapMigration.ts` — one-time localStorage key rename, called in main.tsx before createRoot. + +### Hooks +- `src/hooks/useHydration.ts` — onFinishHydration guard. +- `src/hooks/useMuseumData.ts` — fetches all category JSONs for the active town's game (sea creatures included for ACNL/ACNH); re-fetches when active town's game changes. +- `src/hooks/useCategoryStats.ts` — memoized donated counts per category. +- `src/hooks/useJumpToRow.ts` — Phase 6 navigation helper. Pushes `/town/:townId/:tab` and sets `highlightId` on the next animation frame; ACCanvas's effect picks up the change and scrolls + pulses the matching row. +- (`useSearch` retired in Phase 8 — search state lives inside GlobalSearchDropdown.) + +### Components — shell +- `src/components/Sidebar.tsx` — 280px sticky left sidebar (Phase 2). Brand, active-town card, NavLink nav with per-category counts, Export CSV / Settings footer. "Switch town ›" opens TownManager via useUIStore. +- `src/components/TownManager.tsx` — right-side drawer (bottom sheet ≤720px) mounted at App layout level (Phase 4). Switch / inline-edit / create / delete towns. Edit form is name + (ACNH-only) hemisphere; game is read-only post-create. +- `src/components/SettingsPage.tsx` + `SettingsRoute.tsx` — `/settings` route (Phase 3). About + Danger zone only (no Appearance, Decision 3). +- `src/components/ACCanvas.tsx` — orchestration shell. Owns `highlightId` state. Mounts active tab; wires GlobalSearchDropdown topbar (Home only). All categories (including art) use the inline `ItemExpandPanel` as of v0.9 (#81). +- `src/components/ErrorBoundary.tsx` / `ErrorBanner.tsx` / `ErrorState.tsx`. + +### Components — tabs +- `src/components/HomeTab.tsx` — Phase 6 rebuild. Hero stat + ProgressMeter, month strip, "Leaving end of {month}" shelf, "Just arrived" shelf, latest donations. Cards fire `jumpTo`. +- `src/components/CategoryTab.tsx` — Phase 7. Sections: Leaving this month / Available now / Out of season / Already donated. Owns expandedId; reacts to `highlightId`. Hosts per-tab `SearchBar`. All categories (including art) use the inline `ItemExpandPanel` as of v0.9 (#81). +- `src/components/StatsTab.tsx` — Phase 9. Per-category cards (3/4/5 by game) + 12-column yearly rhythm chart. Replaces AnalyticsView. +- `src/components/ProgressMeter.tsx` (+ `progressMeterUtils.ts`, `ProgressMeter.test.ts`) — Phase 6. 4 segments (fish/bugs/fossils/art) for ACGCN/ACWW/ACCF; 5 segments (+ sea) for ACNL/ACNH. `.ac-meter-5` modifier handles responsive wrapping. + +### Components — rows + panels +- `src/components/CollectibleRow.tsx` — Phase 5 restyle. Monogram glyph, meta line with `·` separators, leaving/new pills, animated chevron. Stamps `data-row-id`. Donate UI lives in the expand panel only. +- `src/components/ItemExpandPanel.tsx` — Phase 5 rebuild. Two-column grid: MonthGrid + stats stack (bells / shadow / hours / notes) + donate button. +- `src/components/shared/` — DonateToggle, EmptyState, HabitatChip, MonthGrid (Phase 5 re-skin, accepts `current` prop), SearchBar (per-tab, used by CategoryTab). `CategoryProgress.tsx` is dead — file remains pending cleanup. +- `src/components/modals/` — DetailModal **retired in v0.9 (#81)**; file deleted. Art now uses the inline `ItemExpandPanel` like every other category. +- `src/components/views/ActivityFeed.tsx` — recent donations list (consumed by HomeTab). `AnalyticsView` and `SectionCard` retired in Phase 9. +- `src/components/search/GlobalSearchDropdown.tsx` — Phase 8 unified search dropdown (anchored under Home topbar). Grouped category results (5 groups for ACNL/ACNH, 4 elsewhere), keyboard nav (↑↓↵esc), search history at localStorage key `ac-curator-search-history` (max 8). Replaces GlobalSearchBar / GlobalSearchResults / SearchHistoryPopover. + +### Data +- `public/data//` — `acgcn/`, `acww/`, `accf/`, `acnl/`, `acnh/` all present. Sea creatures for ACNL + ACNH. Art for ACGCN + ACNH today; ACWW + ACCF art incoming via PR #78 (closes Issue #74). ## Store Schema (v3) ``` donated: Record>> donatedAt: Record>> -towns: Town[] // each Town has id, name, playerName, gameId, hemisphere ('NH'|'SH'), createdAt +towns: Town[] // Town: id, name, gameId, hemisphere ('NH'|'SH'), createdAt ``` -CRITICAL: callers use getActiveTown().gameId to scope donations — store handles the 3rd level. -`hemisphere` defaults to 'NH'; only used for ACNH month display (months_nh / months_sh). +CRITICAL: callers use `getActiveTown().gameId` to scope donations — store handles the 3rd level. `hemisphere` defaults to `'NH'`; only meaningful for ACNH (drives months_nh / months_sh). ## Multi-Game Support - GameId = 'ACGCN' | 'ACWW' | 'ACCF' | 'ACNL' | 'ACNH' -- Data files use game-local IDs (sea-bass in ACGCN == sea-bass in ACWW — schema handles scoping) -- Only show games with data files present in CreateTownModal +- Data files use game-local IDs; the schema scopes by gameId. +- Game is **immutable post-create** (Decision 1). Only display games with data files in TownManager's create form. + +## Scroll-to + highlight (Decision 10) +`ACCanvas` owns `highlightId`. Setters: HomeTab (via `useJumpToRow`) and `GlobalSearchDropdown.onJump` both `setTab + setHighlightId`. `CategoryTab` opens the matching row, scrolls (`block: 'center'`, smooth) on the next animation frame, and adds `.ac-row-pulse` (1.4s `@keyframes ac-row-pulse`). Rows stamp `data-row-id={item.id}`. The state clears after the pulse so re-jumping the same id retriggers. ## Dev Preview -https://development-animalcrossingwebapp.vercel.app/ — auto-deploys from `development` branch via Vercel GitHub integration. Never run `vercel` CLI manually. +https://development-animalcrossingwebapp.vercel.app/ — auto-deploys from `development` via Vercel GitHub integration. Never run `vercel` CLI manually. ## Reference Docs -- docs/v0.7-audit.md — full ranked audit (P0/P1/P2) -- docs/v0.7-architecture-proposal.md — approved design decisions -- docs/dev-process.md — full dev process (this is the .claude/rules/ version) +- `docs/v0.9-plan.md` — canonical v0.9 implementation plan + locked decisions +- `docs/decisions.md` — reverse-chronological decision log +- `docs/design-handoffs/` — v0.9 / v0.9.1 / v0.9.2 design specs +- `docs/v0.7-audit.md` / `docs/v0.7-architecture-proposal.md` — multi-game foundation history +- `docs/dev-process.md` — full dev process diff --git a/.claude/rules/dev-process.md b/.claude/rules/dev-process.md index 19aa6ef..f4344a9 100644 --- a/.claude/rules/dev-process.md +++ b/.claude/rules/dev-process.md @@ -7,7 +7,7 @@ Every feature, fix, or change must follow this checklist before the PR is comple 3. CHANGELOG.md updated with entry under the current version section 4. CLAUDE.md updated if any of these changed: file structure, new components/hooks/utils, architecture, commands, known issues, version number 5. Tests pass — run `npm run build` and `npm test` before pushing -6. Dev preview tested at https://animalcrossingwebapp-git-development-jacuzzicodings-projects.vercel.app for any user-visible changes +6. Dev preview tested at https://development-animalcrossingwebapp.vercel.app for any user-visible changes 7. Version references kept current (CLAUDE.md, README.md) ## PR Description Requirements diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d9849..c3297c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,178 @@ All notable changes to this project are documented here. +## [Unreleased] + +## [v0.9.0-beta] — 2026-05-04 + +### Fixed — Version footer suppresses `release/` branch suffix (#60) +- **`src/App.tsx`** version footer now hides the `· ` suffix when the active branch starts with `release/`. Production hostname behavior unchanged. Closes #60. + +### Changed — Art tab uses inline ItemExpandPanel (#81) +- **Art tab converted from `DetailModal` bottom-sheet to inline `ItemExpandPanel`.** Art rows now expand inline like fish/bugs/fossils/sea creatures — same `.ac-row` → `.ac-expand` flow. Closes #81 (v0.9 release blocker). +- **`ItemExpandPanel`** surfaces art-specific fields: `basedOn` (real-world artwork + artist) as the primary stat, plus a Crazy Redd authentication note keyed off `hasFake` (counterfeit-possible vs. always-genuine). Hidden when `hasFake` is undefined (ACGCN/ACWW/ACCF/ACNL data). +- **`ArtPiece` type** gains optional `hasFake?: boolean` (ACNH-only, sourced from existing `acnh/art.json`). +- **`DetailModal` retired** — file deleted. It was the only remaining consumer; global search jumps via `onJump` (highlight + scroll), and no other tab uses it. `CategoryTab` drops the `onItemSelect` prop and the `category === 'art'` special-cases. `ACCanvas` drops the `selected` state. +- **CSS:** `.ac-art-fake-note` (warn / ok variants reusing existing `--warn` / `--accent` tokens) and `.ac-stat-art` italic basedOn rendering, appended to `src/index.css`. + +### Added — Phase 10: Mobile responsive polish +- **Mobile breakpoint hierarchy** documented (`980 / 720 / 700 / 480`). Surgical CSS additions to `src/index.css` Phase 10 block address touch targets and overflow at iPhone SE (375px) through iPad portrait (768px). +- **Touch targets ≥44px** at ≤720px: `.ac-tm-close`, `.ac-settings-close`, `.ac-tm-row-edit`, `.ac-tm-ghost / -primary / -danger`, `.ac-gs-history-row`, `.ac-gs-row`, `.ac-donate-btn`, `.ac-chevron`. Hemisphere toggle and segmented controls bumped to comfortable tap sizes. +- **iOS zoom prevention** — `.ac-search input` rendered at `font-size: 16px` on stacked layouts so Safari does not auto-zoom on focus. +- **Hero / category title overflow** — `word-break: break-word` on `.ac-hero-headline` and `.ac-category-title`; further font shrink at ≤480px (hero 26px, category 28px, settings 32px). +- **Recent activity row** at ≤720px — category label hidden so item name + relative time fit on one line at 360–390px. +- **Topbar wraps** at ≤480px so the search input stays full-width on narrow viewports. +- **Sidebar foot links** get full 44px tap height when the sidebar stacks above main at ≤980px. +- **Kbd hint footer** in `GlobalSearchDropdown` hidden at ≤720px (no hardware keyboard assumed). Tap-to-select via existing `onClick` handlers verified. + +### Verified — Phase 10 +- Sidebar stacks above main at ≤980px (not hidden); ProgressMeter `.ac-meter-5` wraps to 2 rows; TownManager renders as bottom sheet at ≤720px; Settings collapses + danger buttons go full-width at ≤700px; scroll-to + `.ac-row-pulse` highlight is viewport-agnostic. + +### Added — Phase 9: StatsTab +- **`StatsTab` component** (`src/components/StatsTab.tsx`) — replaces `AnalyticsView`. Renders per-category cards (3/4/5 cards, gated by game: fish/bugs/fossils always; art for ACGCN/ACNL/ACNH; sea for ACNL/ACNH) above a 12-column "Yearly rhythm" availability chart. Each card shows category eyebrow tinted with the matching `--chip-*` token, donated/total in Fraunces 32, a thin tinted progress bar, and "X% complete" caption. +- **Yearly rhythm chart** — 12 stacked columns. Background bar height = `avail / maxAvail`; inner accent fill = `donated / avail`. Number above each column = items available that month. Current month column borders in `--accent`. Includes fish + bugs always; sea creatures added for ACNL/ACNH (Decision 4). Hemisphere-aware via `itemMonths(item, cat, hemisphere)`. Legend below: "Available" / "Already donated". +- **Phase 9 CSS** appended to `src/index.css` — `.ac-stats`, `.ac-stats-grid` (responsive 3/4/5 → 2 → 1 columns at 980px / 480px), `.ac-statcard` / `-cat` / `-num` / `-of` / `-bar` / `-fill` / `-pct`, `.ac-chartcard`, `.ac-chart` / `-col` / `-bar` / `-bar-bg` / `-bar-fill` / `-num` / `-month`, `.ac-chart-legend` / `-dot` / `-dot-bg` / `-dot-fill`. Current-month column gets accent border via `.ac-chart-col.is-now`. + +### Removed — Phase 9 +- **`AnalyticsView`** (`src/components/views/AnalyticsView.tsx`) — superseded by `StatsTab`. +- **`SectionCard`** (`src/components/views/SectionCard.tsx`) — its only consumer was `AnalyticsView`; new card primitives are inline. + +### Decisions — Phase 9 +- **Sea creatures included in the chart for ACNL/ACNH.** Per Decision 4, sea is a first-class category in nav/ProgressMeter/HomeTab/Search, so excluding it from the yearly rhythm would be inconsistent. ACGCN/ACWW/ACCF still chart only fish + bugs (no sea data exists for those games). +- **3-letter month labels** (`Jan`/`Feb`/…) instead of the 1-letter mocks in the handoff — readable at the production sidebar layout width and at the 980px breakpoint. +- **Card-count attribute drives grid sizing only.** The component derives the actual list of cards from `gameId` data presence (mirroring `getDataPaths`); `data-card-count` on the grid is set from `cards.length` purely so CSS can pick the right column template. + +### Added — Art data for ACWW + ACCF +- **`public/data/acww/art.json`** — 20 paintings sourced from Wikibooks (`Animal_Crossing:_Wild_World/Paintings`). Schema matches `acnl/art.json` (`id`, `name`, `basedOn`). +- **`public/data/accf/art.json`** — 23 paintings. City Folk adds 7 over Wild World (dynamic, jolly, moody, proper, scenic, serene, wistful) and drops 4 (dainty, lovely, opulent, rare). `basedOn` strings reuse ACNL phrasing verbatim wherever the real-world reference matches, so cross-game search stays consistent. +- **Loader fix** (`src/lib/categoryMeta.ts`) — added `'ACWW'` and `'ACCF'` to `GAMES_WITH_ART` so `getDataPaths()` actually fetches the new files. Sidebar's `data.art.length > 0` gate then lights up the tab. Includes corresponding 4-card StatsTab grid (fish/bugs/fossils/art) for both games. +- Closes #74. + +### Added — Phase 8: GlobalSearchDropdown +- **`GlobalSearchDropdown` component** (`src/components/search/GlobalSearchDropdown.tsx`) — unified search dropdown anchored under a topbar input on the Home tab. Four states: empty + intro hint, empty + recent searches (history), no-match for query, and grouped category results. Shows up to 5 items per group, max 5 groups (fish, bugs, fossils, art, sea). Sea group gated on `gameId ∈ {ACNL, ACNH}` and non-empty data. Art search matches both `name` and `basedOn` (Decision 8 — "Leonardo" → Famous Painting). Each row shows category-tinted monogram glyph, name with `donated` badge, and meta line (habitat / location / part / basedOn / shadow + bells). +- **Keyboard navigation** — `↑↓` move the active row, `↵` selects + jumps + closes, `Esc` closes the panel. Hovering a row also sets the active index. +- **Search history** — persists in `localStorage` under `ac-curator-search-history`, max 8 entries, deduplicated, most recent first. Stored on result selection. "Clear" button in the history header empties it. +- **Result jump wiring** — selecting a result calls `onJump(category, id)`, which sets the active tab via React Router and stamps `highlightId` so `CategoryTab`'s scroll-to + `.ac-row-pulse` animation fires (Decision 10). Identical pattern to Phase 6 home shelves. +- **Phase 8 CSS** appended to `src/index.css` — `.ac-topbar`, `.ac-search-wrap`, `.ac-search` (rounded pill input with focus ring), `.ac-gs-panel`, `.ac-gs-empty` / `-title` / `-sub` / `-hint`, `.ac-gs-section-head` / `.ac-gs-eyebrow` / `.ac-gs-clear`, `.ac-gs-history` / `-row` / `-icon`, `.ac-gs-group` / `-head` / `-dot` / `-count`, `.ac-gs-row` / `-active` / `-glyph` / `-text` / `-name` / `-donated` / `-meta` / `-arrow`, `.ac-gs-foot` + `kbd` chip styling. + +### Removed — Phase 8 +- **`GlobalSearchBar`, `GlobalSearchResults`, `SearchHistoryPopover`** — deleted (`src/components/search/`). Replaced wholesale by `GlobalSearchDropdown`. +- **`useSearch` hook** — deleted (`src/hooks/useSearch.ts`). Search state now lives inside `GlobalSearchDropdown`. +- **`'search'` view route** — removed from `ViewId`, `VALID_TABS`, and the Sidebar nav. The retired Search tab is replaced by the dropdown on Home. + +### Decisions — Phase 8 +- **Topbar lives only on Home tab.** Per the v0.9.2 spec, other tabs keep their per-tab inline search inside `CategoryTab`. Mounting the topbar on Home only avoids two competing search affordances on category pages. +- **Click-outside dismisses the panel; result clicks use `onMouseDown` preventDefault.** Without `preventDefault` on mousedown, the input would lose focus before the click handler fired and the dropdown would close mid-click. +- **Sea group gated on both game and data presence.** Mirrors the gating in Sidebar nav and ProgressMeter — a town whose game JSON happens to be missing sea data still renders correctly without an empty section. +- **Per-group limit kept at 5.** Matches the v0.9.2 design and keeps the dropdown short enough to scan without scrolling on a 1080p viewport even when all 5 groups have hits. + +### Added — Phase 7: CategoryTab sectioning +- **`CategoryTab` component** (`src/components/CategoryTab.tsx`) — replaces the inline category render in `ACCanvas`. Groups items into four sections (Leaving this month / Available now / Out of season / Already donated), each with an eyebrow header and item count. Empty groups are hidden. December→January wrap is handled via `next = currentMonth === 12 ? 1 : currentMonth + 1`. Donated items always land in "Already donated" regardless of season. Categories without month data (fossils, art) treat all non-donated items as "Available now". Owns its own `expandedId` state — only one row open at a time per tab — and reacts to `highlightId` by opening the matching row before ACCanvas's scroll-to fires. +- **Category page header** — Fraunces 44 `{donated} of {total} {category}` title and right-aligned meta line ("X% complete" + "Showing availability for {month}" for seasonal categories). Stats card-style header sits above the per-tab search bar. +- **Phase 7 CSS** appended to `src/index.css` (`.ac-category`, `.ac-category-head`, `.ac-category-title`, `.ac-category-meta`, `.ac-group`, `.ac-group-head`, `.ac-group-title`, `.ac-group-count`, plus `.ac-group-warn` / `.ac-group-accent` / `.ac-group-muted` / `.ac-group-done` tone modifiers). Header collapses to single column ≤700px and the category title shrinks to 32px. + +### Removed — Phase 7 +- Inline category render in `ACCanvas` (`CategoryProgress` import + flat `.ac-list` map). The progress display moves into the new category header. `CategoryProgress` itself remains in the codebase for now and is slated for removal during the Phase 9 stats rebuild per the v0.9 retirement list. + +### Decisions — Phase 7 +- **Section ordering is fixed and ungrouped categories collapse cleanly.** Fossils and art have no month data, so for those tabs only the "Available now" and "Already donated" groups can ever appear. The ordering still reads naturally; we don't special-case the layout for non-seasonal categories. +- **Per-tab search lives inside CategoryTab, not above it.** Keeping the search bar inside the new component lets the section grouping recompute on filter without re-flowing the page header. The header always shows totals for the full category, not the filtered subset, so users can see overall progress while narrowing the list. +- **Art keeps its bottom-sheet `DetailModal`.** The plan's "row click toggles inline expand" rule applies to fish/bugs/fossils; art was already on a different pattern in v0.8 and the v0.9 design preserves that. CategoryTab routes art clicks to the existing `onItemSelect` callback in `ACCanvas` and renders no inline panel for that category. +- **`ItemExpandPanel` is rendered as a sibling of `CollectibleRow` inside the same `.ac-list`.** This preserves the existing CSS that ties `.ac-row` divider + `.ac-row-pulse` keyframe to the row container without restructuring the row primitive (locked from Phase 5). + +### Added — Phase 6: HomeTab + ProgressMeter +- **`ProgressMeter` component** (`src/components/ProgressMeter.tsx`) — segmented donation progress bar. 4 segments (fish/bugs/fossils/art) for ACGCN/ACWW/ACCF; 5 segments (adds sea) for ACNL/ACNH. Each segment uses its category-tinted Meadow chip token (`--chip-fish`/`--chip-bugs`/`--chip-fossils`/`--chip-art`/`--chip-sea`) and exposes a per-segment aria-label like "Fish: 12 of 40 donated". Pure helper `segmentsForGame` extracted to `src/components/progressMeterUtils.ts` and unit-tested. +- **`HomeTab` rebuilt** (`src/components/HomeTab.tsx`) — new structure per v0.9 design: hero stat with italic Fraunces accent number ("X creatures still to donate this month"), warn-italic aside ("N leaving soon"), `ProgressMeter` directly underneath, 12-cell month strip with current-month highlight, "Leaving end of {month}" warn-toned shelf, "Just arrived" accent-toned shelf, and a "Latest donations" card. Sea creatures included in shelves and progress for ACNL/ACNH towns. Each shelf card / latest-donations row click fires `jumpTo(category, id)` which sets the active tab and `highlightId` so the matching row scrolls into view and pulses (Decision 10). Hero falls back to "X of Y donated" when the active game has no seasonal categories. +- **`useJumpToRow` hook** (`src/hooks/useJumpToRow.ts`) — reusable navigation helper. Pushes `/town/:townId/:tab` and sets `highlightId` on the next animation frame; ACCanvas's existing scroll-to-and-pulse effect picks up the change. Will also be wired into `GlobalSearchDropdown` in Phase 8. +- **Phase 6 CSS** appended to `src/index.css` (`.ac-meter` / `.ac-meter-seg` / `.ac-meter-track` / `.ac-meter-fill` + `.ac-meter-5` responsive modifier, `.ac-home`, `.ac-hero` + `.ac-hero-headline` / `.ac-hero-aside`, `.ac-eyebrow` + `.ac-eyebrow-warn` / `.ac-eyebrow-accent`, `.ac-month-strip` / `.ac-month-cell`, `.ac-shelf-head` / `.ac-shelf-title` / `.ac-shelf-grid` / `.ac-shelf-card` / `.ac-shelf-glyph`, `.ac-month-dots`, `.ac-recent-card` / `.ac-recent-row`). 5-segment ProgressMeter drops fractions between 980-1180px and wraps to 2 rows ≤980px. + +### Decisions — Phase 6 +- **Seasonal UI gated on at least one seasonal category having data.** Fish + bugs always seasonal; sea creatures added to the list only for ACNL/ACNH where we ship hemisphere-aware month data. ACGCN/ACWW/ACCF still get the seasonal UI (fish + bugs). +- **`ProgressMeter` lives on the HomeTab hero, not the sidebar.** The plan allows either; placing it directly under the hero stat keeps the bar near the headline number it relates to and avoids crowding the sidebar's nav counts (which already give per-category progress). +- **Hero copy adapts to game.** When there are no seasonal categories with data the hero falls back to "{donated} of {total} donated." rather than showing a "0 creatures still to donate" line that would read as empty-state success when it's actually a coverage gap. +- **Shelves cap at 6 items per shelf.** A horizontal-scroll variant was considered but the v0.9 design uses a 3-column grid; we keep the same grid and slice to a sensible number to avoid an unbounded vertical wall on towns with many in-season items. +- **`jumpTo` clears `highlightId` to null before re-setting on the next frame.** Without the clear, jumping to a row that's already the highlight target wouldn't retrigger the pulse — the effect dependency is the id and React would batch a same-id setter into a no-op. + +### Added — Phase 1: tokens + fonts +- **Meadow design tokens** — full palette added to `src/index.css` `@theme` block as CSS custom properties (`--bg`, `--surface`, `--surface-alt`, `--ink`, `--ink-soft`, `--ink-muted`, `--border`, `--border-strong`, `--accent`, `--accent-soft`, `--accent-ink`, `--warn`, `--warn-soft`, `--chip-fish`, `--chip-bugs`, `--chip-fossils`, `--chip-art`, `--chip-sea`). Mirrored in `src/lib/colors.ts` as the new `meadow` export. +- **Fraunces** (variable, opsz 9..144, weights 400/500/600 + italic 400/500) and **Inter** (400/500/600/700) loaded via Google Fonts in `index.html` and `src/index.css`. Registered as `--font-display` and `--font-sans` in `@theme`. New `fontStacks` export in `src/lib/colors.ts`. + +### Removed — Phase 1 +- **Varela Round** — fully retired. `@import` removed from `src/index.css`, `index.html`, and `public/version-history.html`. Inline `fontFamily: 'Varela Round, ...'` reference in `src/App.tsx` loading state replaced with Inter. Per locked decision #2 in `docs/v0.9-plan.md`, Fraunces + Inter is the sole type stack. + +### Notes +- This phase is plumbing only. No components consume the new tokens yet; visual output is intentionally near-identical to v0.8.2-alpha. Component restyles begin in Phase 5. +- **Parchment, Midnight, and Sakura themes are intentionally excluded** per locked decision #2 in `docs/v0.9-plan.md` ("ship Meadow only"). The legacy `colors` export in `src/lib/colors.ts` is kept untouched until later phases retire its consumers. + +### Added — Phase 2: sidebar + shell layout +- **`Sidebar` component** (`src/components/Sidebar.tsx`) — 280px sticky left sidebar with brand mark/wordmark, active-town card, vertical nav with per-category `donated/total` counts, and Export CSV / Settings footer. Uses `` so active state tracks the URL. +- **App shell layout** in `src/index.css` (`.ac-app`, `.ac-sidebar`, `.ac-main`, `.ac-nav-*`, `.ac-town-*`, `.ac-brand-*`, `.ac-foot-link`, `.ac-hem-toggle`, `.ac-hem-btn`) — CSS grid `280px 1fr`, max-width 1440px centered. Below 980px sidebar stacks above main (CSS grid row change). +- Sea nav entry gated on `gameId in {ACNL, ACNH}` and presence of sea data; Art nav entry hidden when game has no art. +- Hemisphere toggle (NH/SH) inline in the sidebar town card for ACNH towns — preserves the toggle that previously lived in `MuseumHeader`. Will move into `TownManager` in Phase 4. +- Settings nav button routes to `/settings` (route lands in Phase 3). + +### Removed — Phase 2 +- **`MuseumHeader`**, **`TabBar`**, **`TownSwitcher`** — deleted. Replaced by `Sidebar`. ACCanvas no longer renders the wood-toned header bar or horizontal tab strip; main column now sits inside `.ac-main`. + +### Decisions — Phase 2 +- The plan calls the Active Town card's "Switch town ›" button a Phase 4 stub. Implemented as a temporary lightweight switcher: 0 other towns → opens `CreateTownModal`; 1 other → activates it; 2+ → `window.prompt` picker. Replaced wholesale by `TownManager` in Phase 4. +- Edit / New town are exposed as small links inside the active-town card so users don't lose town CRUD between Phase 2 and Phase 4 (they previously lived in the now-deleted `TownSwitcher`). Both still wire to the existing `EditTownModal` / `CreateTownModal`, which Phase 4 will retire. +- Hemisphere toggle relocated to the sidebar town card to avoid regressing the v0.8 functionality that lived in `MuseumHeader`. Phase 4 moves it into `TownManager`. +- Brand wordmark uses **"Museum Tracker"** (matching the prior MuseumHeader) — not "Curator", per the codename note in `docs/v0.9-plan.md` (no user-facing copy says "Curator"). + +### Added — Phase 3: settings page +- **`SettingsPage` component** (`src/components/SettingsPage.tsx`) — full-page settings route with two sections: **About** (version, source link, live storage summary, credits) and **Danger zone** (reset donations for active town, reset everything). No Appearance section per locked decision #3. +- **`SettingsRoute` wrapper** (`src/components/SettingsRoute.tsx`) — renders the Sidebar + SettingsPage in the same shell layout as `ACCanvas`, so the sidebar (active town, nav, footer) stays in place when at `/settings`. +- **`/settings` route** added to `App.tsx`. Sidebar Settings link now navigates to it; Esc and the close button navigate back to `/town/:id/home` (or `/` if there's no active town). +- **Store reset actions** in `src/lib/store.ts`: `resetActiveTownDonations()` clears `donated` and `donatedAt` for the active town only; `resetAll()` empties towns + donations + clears `ac-curator-search-history` from localStorage. Both are gated behind native `confirm()` per locked decision #7. +- **Settings page styles** in `src/index.css` (`.ac-settings`, `.ac-settings-head`, `.ac-settings-eyebrow`, `.ac-settings-title`, `.ac-settings-close`, `.ac-settings-section`, `.ac-settings-card`, `.ac-about-list`, `.ac-settings-danger`, `.ac-danger-row`, `.ac-danger-btn`, `.ac-danger-btn-strong`, `ac-fade-up` keyframe). Responsive collapse at ≤700px (title 56→40px, About list single-column, Danger rows stack with full-width buttons). + +### Decisions — Phase 3 +- **No Appearance section** — locked decision #3. Meadow is the only theme in v0.9, so a one-card theme switcher would feel hollow. Brought back when there are real alternatives. +- **Eyebrow text on Settings header** is "Museum Tracker" rather than "Curator" — matches the brand-wordmark decision from Phase 2 (the codename note in `docs/v0.9-plan.md`). +- **Reset donations is disabled** when there's no active town, instead of being hidden — keeps the Danger zone shape stable across states. +- **Settings is a top-level route** (`/settings`) rather than nested under `/town/:townId/settings`. The settings page is a property of the app, not the town — and a user mid-reset-everything wouldn't have an active town to nest under. + +### Added — Phase 5: CollectibleRow + ItemExpandPanel restyle +- **`CollectibleRow`** (`src/components/CollectibleRow.tsx`) — rebuilt to match the v0.9 design. Replaces icon tile with a category-tinted **monogram glyph** (32×32, 1.5px border, Fraunces initials). Donated state fills the glyph with the chip color and inverts text. Donated rows strike through the name and add a `●` accent checkmark. Meta line uses `·` separators with category-specific bits (habitat for fish, part for fossils, shadow for sea, italic `basedOn` for art) and a tabular bells value. Renders `Leaving soon` (warn pill) or `New this month` (accent pill) per current-month wrap logic, plus an active-hours pill for sea creatures and an animated chevron. Stamps `data-row-id={item.id}` so jump-to logic can locate it. Accepts new `highlighted`, `currentMonth`, `hemisphere` props. +- **`ItemExpandPanel`** (`src/components/ItemExpandPanel.tsx`) — rebuilt as a two-column grid (`1fr 240px`, padded so the left column aligns with the row text after the glyph). Left column is the 12-cell `MonthGrid` with current-month highlight; right column stacks `bells · sell value`, optional `shadow size` and `active hours`, optional notes block, and the donate / undonate button at the bottom. Donate now lives only inside the panel — the row no longer shows a separate `DonateToggle`. +- **`MonthGrid`** (`src/components/shared/MonthGrid.tsx`) — re-skinned to use `.ac-monthgrid` / `.ac-monthcell` (12-column grid, square cells, accent-soft fill for available months, inset accent ring for the current month). Now accepts a `current` prop. +- **Scroll-to + highlight wiring** — `ACCanvas` owns `highlightId` state plus an effect that, on change, expands the matching row, schedules a `scrollIntoView({ behavior: 'smooth', block: 'center' })` on the next animation frame, and clears the state after the 1.4s pulse so re-jumping retriggers the effect (Decision 10). Phase 6 (`HomeTab` shelves) and Phase 8 (`GlobalSearchDropdown`) wire callers to the setter; the state shell is in place. +- **Phase 5 CSS** appended to `src/index.css`: `.ac-row`, `.ac-row-main`, `.ac-row-expanded`, `.ac-row-donated`, `.ac-row-pulse` + `@keyframes ac-row-pulse`, `.ac-glyph`, `.ac-row-text`, `.ac-row-name`, `.ac-row-checkmark`, `.ac-row-meta`, `.ac-row-meta-bit`, `.ac-row-meta-italic`, `.ac-row-meta-bells`, `.ac-row-side`, `.ac-row-time`, `.ac-chevron`, `.ac-pill` + `.ac-pill-warn` / `.ac-pill-accent`, `.ac-expand`, `.ac-expand-section`, `.ac-expand-label`, `.ac-monthgrid`, `.ac-monthcell` + states, `.ac-expand-side`, `.ac-stat`, `.ac-stat-num`, `.ac-stat-num-text`, `.ac-stat-label`, `.ac-note`, `.ac-donate-btn` + `.ac-donate-btn-on`, plus a 980px breakpoint that collapses the expand panel to a single column. +- Category list now wraps rows in a single `.ac-list` card (one rounded surface with internal dividers) instead of free-floating bordered rows. + +### Decisions — Phase 5 +- **`highlightId` state lives in `ACCanvas`, not `App`.** The plan/spec says "App owns highlightId," but `HomeTab`, the search results, and the category lists are all rendered inside `ACCanvas` and there is no Phase 5 caller above it. Keeping the state in `ACCanvas` keeps the wiring local; promoting it to `App` is cheap and can happen in Phase 6/8 if a caller above the router outlet ever needs it. +- **Donate / undonate is panel-only.** The design's row no longer has its own donate button — toggling moves into the expand panel, matching the v0.9.2 mockup. The row's `onToggle` prop is kept (optional) for back-compat with `GlobalSearchResults`, which Phase 8 will replace. +- **Time pill is sea-only.** The design's `item.time` field exists on sea creatures in our schema (Fish/Bug have `hours: number[]`, not a formatted string). Wiring fish/bug time displays would need a formatter — out of scope for Phase 5; revisit alongside `hours` rendering in v0.9.x. +- **Shadow size shown for sea creatures only**, not fish — our `Fish` type has no `shadow` field, so issue #59 (display shadow size) is partially addressed for sea creatures via the new stats stack but does not change fish rendering. Issue stays open for fish-specific shadow data work. + +### Added — Phase 4: TownManager drawer +- **`TownManager` component** (`src/components/TownManager.tsx`) — right-side drawer (420px) that mounts at the layout level in `App.tsx`, so it overlays every route (home, category tabs, settings) without z-index or `overflow-hidden` issues. Below 720px it renders as a bottom sheet. Contains: the list of towns with active-town indicator, inline row edit (name + hemisphere only), a `+ New town` form (name + game + hemisphere), and per-row delete with native `confirm()` guard. +- **`useUIStore`** (`src/lib/uiStore.ts`) — small non-persisted Zustand store for transient UI state (`townManagerOpen`, `townManagerForceCreate`). Exposes `openTownManager(forceCreate?)` and `closeTownManager()`. +- **Auto-open in create mode when no towns exist** — `App.tsx` opens the TownManager forced-create when `towns.length === 0`. The drawer hides its close button, ignores Esc, and ignores scrim clicks in this state — equivalent to the previous "required" behavior on `CreateTownModal`. +- **`GAME_LIST`** export in `src/lib/types.ts` — ordered array of `Game`, used by the create-town form's game selector. +- **TownManager styles** in `src/index.css` (`.ac-tm-scrim`, `.ac-tm-drawer`, `.ac-tm-row*`, `.ac-tm-form`, `.ac-tm-seg`, `.ac-tm-cta`, `.ac-tm-newform`, `.ac-tm-empty`, `ac-fade` / `ac-slide` / `ac-slide-up` keyframes). Bottom-sheet variant at `(max-width: 720px)`. + +### Changed — Phase 4 +- **`Sidebar`** — the Phase 2 bridge stubs are removed: the `window.prompt` switcher, the Edit / + New buttons inside the active-town card, and the inline NH/SH segmented toggle. The single `Switch town ›` button now opens the TownManager. Hemisphere is shown as a read-only `Hem. NH` / `Hem. SH` label (editing happens in the drawer). Sidebar no longer takes `onOpenCreateTown` / `onOpenEditTown` props. +- **`useAppStore.createTown`** signature is `(name, gameId, hemisphere?)` — `playerName` removed. **`useAppStore.updateTown`** signature is `(id, patch: TownPatch)` where `TownPatch` is `{ name?, hemisphere? }`. `gameId` is intentionally not part of the patch (Decision 1 — game is immutable post-create). +- **`Town` type** — `playerName: string` field removed (Decision 5). Existing values in localStorage are silently dropped on next write; no migration step required. +- **`downloadCSV` / `buildCSV`** no longer take a `playerName` argument; the "Player" row is removed from CSV exports. + +### Removed — Phase 4 +- **`CreateTownModal`** (`src/components/modals/CreateTownModal.tsx`) — replaced by TownManager's New Town form. +- **`EditTownModal`** (`src/components/modals/EditTownModal.tsx`) — replaced by TownManager's inline row edit. +- **`TownNameFields`** (`src/components/shared/TownNameFields.tsx`) — no remaining consumers. +- **v0.8.1 greyed-out-buttons stopgap** — no longer needed; the TownManager drawer mounts at the layout level and works on every route, resolving the issue the stopgap worked around. + +### Decisions — Phase 4 +- **Decision 1 honored** — the inline edit form has no game `` — game is read-only post-create. +**Shell layout (v0.9 Phase 2):** `Sidebar` (280px left, sticky) + `
` in CSS grid `280px 1fr`, max-width 1440px centered. Below 980px sidebar stacks above main. `MuseumHeader`, `TabBar`, `TownSwitcher` retired — nav lives in the sidebar. +**Mobile breakpoints (v0.9 Phase 10):** `980` = sidebar stacks + ProgressMeter 5-seg wraps + stats grid → 2 col; `720` = TownManager bottom-sheet + touch targets ≥44px + recent-row strips category label + GlobalSearch kbd footer hidden; `700` = settings sections collapse + danger buttons full-width; `480` = stats grid → 1 col + hero/category titles shrink + topbar wraps. Edits are CSS-only in `src/index.css` Phase 10 block. +**Scroll-to + highlight (v0.9 Phase 5/6/8):** `ACCanvas` owns `highlightId` state. `HomeTab` shelves and `GlobalSearchDropdown` results call `jumpTo(category, id)` (via `useJumpToRow`); `CategoryTab` opens the matching row, scrolls it into view (`block: 'center'`, smooth) on the next animation frame, and adds `.ac-row-pulse` (1.4s `@keyframes ac-row-pulse`). Rows stamp `data-row-id={item.id}` so the effect can locate them. See locked decision #10 in `docs/v0.9-plan.md`. +**Search (v0.9 Phase 8):** `GlobalSearchDropdown` is mounted only on the Home tab inside an `.ac-topbar`. Other tabs use `CategoryTab`'s inline per-tab `SearchBar`. Search history persists at localStorage key `ac-curator-search-history` (max 8, deduplicated). ### File Structure @@ -49,50 +52,88 @@ src/ App.tsx # Root component — hydration guard + ErrorBoundary + Routes (/, /town/:townId/:tab) main.tsx # Entry point — runs bootstrapMigration, wraps app in BrowserRouter components/ - ACCanvas.tsx # Orchestration shell ~298 lines. Mounts active tab view, - # wires modals and global search. Decomposition complete (v0.7). - HomeTab.tsx # Home screen: seasonal availability, leaving-soon, - # progress cards, recent activity - CollectibleRow.tsx # Single item row with donate toggle; shows chevron + rounded-top when expanded - ItemExpandPanel.tsx # Inline accordion panel shown below CollectibleRow for fish/bugs/fossils - MuseumHeader.tsx # Header bar + TownSwitcher dropdown - TabBar.tsx # Tab navigation strip - TownSwitcher.tsx # Town dropdown with game badge per town + ACCanvas.tsx # Orchestration shell. Mounts active tab view; owns + # `highlightId` state for scroll-to + pulse; wires + # GlobalSearchDropdown topbar (Home only). All categories, + # including art, use ItemExpandPanel inline expand (v0.9 #81). + HomeTab.tsx # v0.9 Phase 6: rebuilt — hero stat + ProgressMeter, + # month strip, leaving-soon shelf, just-arrived shelf, + # latest donations. Cards fire jumpTo (scroll + pulse). + ProgressMeter.tsx # v0.9 Phase 6: segmented progress bar (4 or 5 segments + # gated by gameId; sea segment for ACNL/ACNH). + progressMeterUtils.ts # Pure helper segmentsForGame (unit-tested). + CategoryTab.tsx # v0.9 Phase 7: sectioned category page (Leaving / Available + # / Out of season / Already donated). Owns expandedId, + # reacts to highlightId by opening the matching row + # before ACCanvas's scroll-to fires. Hosts per-tab SearchBar. + CollectibleRow.tsx # v0.9 Phase 5 restyle: monogram glyph tile, meta line with + # `·` separators, leaving/new pills, animated chevron. + # Stamps data-row-id; accepts highlighted/currentMonth/hemisphere props. + ItemExpandPanel.tsx # v0.9 Phase 5 rebuild: two-column inline panel (MonthGrid + # + stats stack with bells/shadow/hours/notes) with the + # donate / undonate button at the bottom. Donate UI lives + # in the panel only — the row no longer renders a toggle. + Sidebar.tsx # v0.9 Phase 2: 280px left sidebar — brand, active town card, NavLink nav with counts, footer (replaces MuseumHeader/TabBar/TownSwitcher) + SettingsPage.tsx # v0.9 Phase 3: full-page Settings — About + Danger zone (no Appearance per locked decision #3) + SettingsRoute.tsx # v0.9 Phase 3: route wrapper that mounts Sidebar + SettingsPage at /settings ErrorBanner.tsx # Dismissible inline error notification ErrorBoundary.tsx # Top-level React error boundary; crashes render ErrorState ErrorState.tsx # Full-page error fallback UI shared/ - CategoryProgress.tsx # "X / Y donated" progress bar - DonateToggle.tsx # Checkbox/button to mark item donated + DonateToggle.tsx # Checkbox/button to mark item donated (used by sea-creature search rows; donate on category rows is panel-only as of Phase 5) EmptyState.tsx # "Nothing here yet" placeholder HabitatChip.tsx # Fish habitat badge - MonthGrid.tsx # 12-cell month availability grid - SearchBar.tsx # Per-tab inline search input + MonthGrid.tsx # 12-cell month availability grid (Phase 5 re-skin; accepts `current` prop) + SearchBar.tsx # Per-tab inline search input (consumed by CategoryTab) + # CategoryProgress.tsx — DEAD: its only consumers (ACCanvas inline category + # render + AnalyticsView) were retired in Phase 7 / Phase 9. File remains + # in tree pending a follow-up cleanup PR. modals/ - CreateTownModal.tsx # New town form with game selector - EditTownModal.tsx # Rename town form (gameId immutable) - DetailModal.tsx # Item detail sheet + # DetailModal.tsx — RETIRED in v0.9 (#81). Was the bottom-sheet for the Art + # tab; art now uses the inline ItemExpandPanel like every other category. + TownManager.tsx # v0.9 Phase 4: right-side drawer (bottom sheet ≤720px) mounted + # at App layout level via useUIStore. Switch/edit/create/delete + # towns. Inline edit = name + (ACNH-only) hemisphere — game is + # read-only post-create (Decision 1). Replaces CreateTownModal, + # EditTownModal, TownSwitcher, TownNameFields. + StatsTab.tsx # v0.9 Phase 9: per-category cards (3/4/5 by game) + + # 12-column yearly rhythm chart (fish + bugs always, + # sea added for ACNL/ACNH). Replaces AnalyticsView. views/ - AnalyticsView.tsx # Charts + stats tab content - ActivityFeed.tsx # Recent donations list - SectionCard.tsx # Reusable card wrapper + ActivityFeed.tsx # Recent donations list (consumed by HomeTab). + # AnalyticsView + SectionCard retired in Phase 9. search/ - GlobalSearchBar.tsx # Global search input - GlobalSearchResults.tsx # Cross-category search results - SearchHistoryPopover.tsx # Recent search history dropdown + GlobalSearchDropdown.tsx # v0.9 Phase 8: unified search dropdown — anchored + # under the Home topbar input. Grouped category + # results (5 groups for ACNL/ACNH, 4 elsewhere), + # keyboard nav (↑↓↵esc), localStorage history + # under `ac-curator-search-history` (max 8). + # Replaces GlobalSearchBar/Results/HistoryPopover. hooks/ useHydration.ts # Gates render on Zustand persist rehydration (onFinishHydration) - useMuseumData.ts # Fetches and caches all 4 category JSONs for active town's game - useSearch.ts # Search history, click-outside, debounce + useMuseumData.ts # Fetches and caches all category JSONs for the active town's game useCategoryStats.ts # Memoized donated counts per category + useJumpToRow.ts # v0.9 Phase 6: navigate to a category tab + set + # highlightId so ACCanvas scrolls + pulses the target row. + # Wired by HomeTab shelves and GlobalSearchDropdown. + # `useSearch.ts` retired in Phase 8 — search state now + # lives inside GlobalSearchDropdown. lib/ - store.ts # Zustand store: towns, donations, activeTownId. persist key 'ac-web' v2. + store.ts # Zustand app store: towns, donations, activeTownId. persist + # key 'ac-web' schema v3. Includes resetActiveTownDonations + # + resetAll (Phase 3 Settings danger zone). createTown signature + # is (name, gameId, hemisphere?); updateTown takes a TownPatch + # ({ name?, hemisphere? }) — gameId is intentionally not patchable + # post-create (Decision 1). `playerName` removed from Town in + # Phase 4 (Decision 5). + uiStore.ts # v0.9 Phase 4: non-persisted Zustand store for transient UI + # state (`townManagerOpen`, `townManagerForceCreate`). bootstrapMigration.ts # One-time localStorage rename (ac-web:v1 → ac-web), called in main.tsx - storeMigrations.ts # Zustand migrate callback: v1→v2 schema lift + storeMigrations.ts # Zustand migrate callback: v1→v2 schema lift, v2→v3 hemisphere backfill categoryMeta.ts # CATEGORY_META constant (label/Icon/file per category) viewTypes.ts # ViewId and AllData types constants.ts # MONTH_NAMES, CATEGORY_LABELS, CATEGORY_ORDER, SEASONS - colors.ts # Design token hex constants + colors.ts # Design tokens — `meadow` export (v0.9 Phase 1) mirrors the CSS custom properties in `src/index.css` `@theme`. Legacy `colors` export kept until all consumers are removed. Includes `fontStacks` for Fraunces/Inter. types.ts # Shared TypeScript interfaces (Town, Donation, GameId, Game, etc.) utils.ts # Helper functions (formatting, date math, type guards) csvExport.ts # CSV export logic for donation data @@ -122,20 +163,28 @@ public/data/acnh/ docs/ dev-process.md # PR checklist and dev process rules for Claude Code sessions architecture.md # Deep architectural context: store schema, migrations, multi-game types + decisions.md # Reverse-chronological design decision log + v0.9-plan.md # Canonical v0.9.0-beta implementation plan + locked decisions + design-handoffs/ # v0.9 / v0.9.1 / v0.9.2 design specs ("Curator" codename) v0.7-audit.md # Codebase audit: component modularity, type safety, latent bugs v0.7-architecture-proposal.md # Multi-game foundation design: store schema, decomposition plan ``` -### Design System +### Design System (Meadow — v0.9) -Inline hex constants via `src/lib/colors.ts` — **no Tailwind design tokens**: -- `#7B5E3B` — wood (header/section backgrounds) -- `#F5E9D4` — paper (card backgrounds) -- `#2A2A2A` — ink (primary text) -- `#3CA370` — leaf (progress bars, success states) -- `#E7DAC4` — border colour -- `#5a4a35` — secondary/muted text -- Google Fonts: **Varela Round** +Tokens are CSS custom properties in `src/index.css` `@theme` block, mirrored in `src/lib/colors.ts` as the `meadow` export. The legacy `colors` export (parchment/wood) is retained for backwards-compatibility but is no longer the active palette. See section 5 of `docs/v0.9-plan.md` for the full token table and typographic scale. + +Key tokens: +- `--bg` `#F4EFE3` · `--surface` `#FFFDF7` · `--surface-alt` `#F8F2E2` +- `--ink` `#23241F` · `--ink-soft` `#5C5848` · `--ink-muted` `#8A8470` +- `--border` `#E2D9C3` · `--border-strong` `#CFC4A8` +- `--accent` `oklch(0.55 0.09 150)` (moss green) · `--accent-soft` · `--accent-ink` +- `--warn` `oklch(0.62 0.12 50)` (clay) — leaving-soon +- `--chip-fish` / `--chip-bugs` / `--chip-fossils` / `--chip-art` / `--chip-sea` — category identity tokens + +Type stack: **Fraunces** (display, opsz 9..144, 400/500/600 + italic) and **Inter** (UI, 400/500/600/700) loaded via Google Fonts. Varela Round was retired in Phase 1. + +Per locked decision #2, Meadow is the **only theme** — Parchment, Midnight, and Sakura were dropped from the v0.9 scope. ## Git Workflow @@ -170,14 +219,13 @@ See `.claude/rules/vercel.md` for full deployment rules. Key points: - **issue #26** — Art tab persistent label — **fixed in v0.8.2 (PR #57)**; `setSelected(null)` added to tab-change `useEffect` - **issue #31** — Create-town edge case; low priority, open - **Sea creatures tab** — **shipped in v0.8.2 (PR #44, Closes #56)**; Sea tab visible for ACNL and ACNH towns -- **Edit/new-town buttons greyed out on Fish, Bugs, Fossils tabs** — intentional v0.8.1 stopgap. Modals (EditTownModal, CreateTownModal) are mounted in ACCanvas, which sits below the router layout; on museum category tabs the modal renders fine but overlapping scroll context causes visual issues. Buttons show `opacity: 0.4` + tooltip directing users to Home/Search/Recent Donations instead. Proper fix (lift modals to layout level) deferred to v0.9 UI revamp. +- **Edit/new-town buttons greyed out on Fish, Bugs, Fossils tabs** — **resolved in v0.9 Phase 4**. The `TownManager` drawer mounts at the App layout level and renders correctly on every route, so the overflow/z-index issues that motivated the stopgap no longer apply. ## ACCanvas.tsx -`src/components/ACCanvas.tsx` was decomposed in v0.7 and is now ~298 lines (orchestration shell only). -It mounts the active tab view, wires modals, and handles global search. All data fetching, -filtering, and sub-component logic lives in dedicated hooks and components. -Do not add new top-level tabs without updating the tab switch and `TabBar` props. +`src/components/ACCanvas.tsx` is the orchestration shell that lives below the `Sidebar`. It mounts the active tab view (HomeTab, CategoryTab, StatsTab), owns the `highlightId` state for the scroll-to + pulse effect (Phase 5/6/8), and wires the GlobalSearchDropdown topbar (Home tab only). All categories — including art — now use the inline `ItemExpandPanel` (v0.9 #81; `DetailModal` retired). All data fetching lives in `useMuseumData`; per-category filtering and sectioning lives in `CategoryTab`. + +Do not add new top-level tabs without updating the tab switch in ACCanvas, the nav list in `Sidebar`, and `VALID_TABS` / `ViewId` in `src/lib/viewTypes.ts`. ## Roadmap @@ -219,9 +267,21 @@ Do not add new top-level tabs without updating the tab switch and `TabBar` props - Art tab persistent label fix — `setSelected(null)` on tab change (PR #57, Closes #26) - Branch-label footer suffix for non-main/development/release builds -### v0.9 — Polish, onboarding, and PWA (next) -- Seasonal/time-based filtering -- UI redesign pass; PWA support; mobile-first responsive pass; first-run onboarding +### v0.9.0-beta — UI revamp (in progress on `development`) +Phases shipped to `development`: +- Phase 1 — Meadow tokens + Fraunces/Inter; Varela Round retired (PR #63) +- Phase 2 — Sidebar shell; MuseumHeader/TabBar/TownSwitcher retired (PR #65) +- Phase 3 — Settings page (About + Danger zone) (PR #66) +- Phase 4 — TownManager drawer; CreateTownModal/EditTownModal/TownNameFields retired; `playerName` removed from Town (PR #67) +- Phase 5 — CollectibleRow + ItemExpandPanel restyle; donate moves into expand panel (PR #70) +- Phase 6 — HomeTab rebuild + ProgressMeter (4/5 segments); closed Issue #71 ACNH "all caught up" bug (PR #72) +- Phase 7 — CategoryTab sectioning (Leaving / Available / Out of season / Already donated) (PR #73) +- Phase 8 — GlobalSearchDropdown; GlobalSearchBar/Results/HistoryPopover/`useSearch` retired; a11y polish tracked in Issue #76 (PR #75) +- Phase 9 — StatsTab rebuild; AnalyticsView + SectionCard retired (PR #77) + +Pending: +- Phase 10 — Mobile responsive verification pass +- ACWW + ACCF art data (PR #78, closes Issue #74) ### v1.0 — Launch ready - Branding, SEO, accessibility, performance audit diff --git a/README.md b/README.md index 1bb3eb2..68f0b96 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,17 @@ A cozy companion web app for tracking Animal Crossing museum donations across mu ## Features - Track donations for all museum categories: Fish, Bugs, Fossils, Art, and Sea Creatures -- Manage multiple towns — create, switch, and rename them at any time +- Manage multiple towns from a single TownManager drawer — switch, rename, create, and delete (game is locked at create-time) - **Five games supported:** Animal Crossing (GCN), Wild World, City Folk, New Leaf, New Horizons -- **Item inline expand** — tap a Fish, Bug, or Fossil row to expand it in-place: month availability grid, sell value, habitat, and donate/undonate button (powered by `ItemExpandPanel`) -- **Bottom-sheet detail view** — Art rows and Search results open a full detail sheet (`DetailModal`) -- **Hemisphere toggle** — New Horizons towns show an NH/SH toggle; month grids reflect the correct hemisphere +- **Persistent left sidebar** with brand, active town card, per-category donation counts, and Export CSV / Settings footer +- **Inline item expand** — tap any row (Fish, Bugs, Fossils, Sea Creatures, or Art) to open a two-column panel with the month availability grid, bells / shadow / hours / `basedOn` real-world reference / Crazy Redd authentication note (ACNH art), notes, and donate/undonate button +- **Hemisphere toggle** — New Horizons towns expose an NH/SH toggle; month grids reflect the correct hemisphere - **URL-based navigation** — every town and tab has a shareable URL via React Router v6 -- Home screen with seasonal availability, leaving-soon alerts, and progress cards -- Global search across all categories -- Stats tab with collection analytics and monthly availability chart -- Recent activity feed per town +- Home screen with hero stat, current-month strip, "Leaving end of {month}" and "Just arrived" shelves, segmented progress meter, and latest donations +- Sectioned category pages — Leaving this month / Available now / Out of season / Already donated +- Unified global search dropdown with grouped category results, keyboard navigation, and recent-search history +- Stats tab with per-category cards and a 12-column "Yearly rhythm" availability chart +- Settings page — About + Danger zone (reset active town donations, reset everything) - CSV export of your donation records - Fully persistent — data saved to localStorage, no account required @@ -59,12 +60,12 @@ Museum data lives in `public/data//`: - `public/data/acnl/` — New Leaf: fish, bugs, fossils - `public/data/acnh/` — New Horizons: 81 fish, 80 bugs, 86 fossils, 43 art, 40 sea creatures (NH/SH month availability) -> Sea creatures are fully supported for New Horizons and New Leaf — a dedicated Sea tab appears in the TabBar for those games (shipped in v0.8.2). +> Sea creatures are fully supported for New Horizons and New Leaf — a dedicated Sea entry appears in the sidebar nav for those games (shipped in v0.8.2). --- ## Version -Current release: **v0.8.2-alpha** — see [CHANGELOG.md](CHANGELOG.md) for history. +Current release: **v0.9.0-beta** (shipped 2026-05-04) — full UI revamp staged for release on `main`. Last stable on `main`: **v0.8.2-alpha**. See [CHANGELOG.md](CHANGELOG.md) for history and [docs/v0.9-plan.md](docs/v0.9-plan.md) for the v0.9 plan. Significant design decisions are logged in [docs/decisions.md](docs/decisions.md). diff --git a/docs/architecture.md b/docs/architecture.md index 24768d4..44ea1e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,94 +1,22 @@ -# Architecture Reference (v0.8.2) +# Architecture Reference (v0.9.0-beta) -Key architectural context for Claude Code sessions. See also `docs/v0.7-architecture-proposal.md`. +This document mirrors `.claude/rules/architecture.md` — the canonical copy that Claude Code sessions auto-load. They are kept in lockstep; if they diverge, the `.claude/rules/` copy wins. Update both together. -## Stack +For the full reference (stack, key files, store schema, scroll-to + highlight wiring, multi-game support), see [`.claude/rules/architecture.md`](../.claude/rules/architecture.md). -- **Framework:** Vite + React 19 + TypeScript -- **Styling:** Tailwind CSS v4 — utility classes only; design tokens are inline hex constants in `src/lib/colors.ts` -- **State:** Zustand ^5 with `persist` middleware (localStorage key: `ac-web`, schema version 2) -- **Tests:** Vitest -- **Hosting:** Vercel (auto-deploys from `main`) +## Quick reference -## Store Schema (v2, as of v0.7) +- **Stack:** Vite + React 19 + TypeScript + Tailwind CSS v4 + Zustand + React Router v6 +- **Design:** Meadow tokens (CSS custom properties in `src/index.css` `@theme`), Fraunces + Inter (Varela Round retired in Phase 1) +- **State:** persisted `useAppStore` (key `ac-web`, schema v3) + non-persisted `useUIStore` (TownManager open flags) +- **Layout:** `Sidebar` (280px, sticky) + main column. `TownManager` drawer mounts at App level. `MuseumHeader` / `TabBar` / `TownSwitcher` retired in Phase 2. +- **Highlight:** `ACCanvas` owns `highlightId`; HomeTab + `GlobalSearchDropdown` call `jumpTo(category, id)` (via `useJumpToRow`) and `CategoryTab` scrolls + pulses the matching row. +- **Settings:** `/settings` route — About + Danger zone only (no Appearance per Decision 3). -Donation data uses a **3-level schema**: +## Reference docs -``` -donated[townId][gameId][itemId] = boolean -donatedAt[townId][gameId][itemId] = ISO timestamp -``` - -- `gameId` is a `GameId` union: `'ACGCN' | 'ACWW' | 'ACCF' | 'ACNL' | 'ACNH'` -- `Town` has a `gameId` field (backfilled to `'ACGCN'` for pre-v0.7 towns) -- Zustand `persist` is at `version: 2`; migrations in `src/lib/storeMigrations.ts` - -## Migration Path - -- `src/lib/bootstrapMigration.ts` — runs in `main.tsx` before React mounts; one-time localStorage key rename (`ac-web:v1` → `ac-web`) -- `src/lib/storeMigrations.ts` — Zustand `migrate()` callback: v1→v2 lifts flat schema to 3-level, backfills `gameId` -- Zero data loss for existing users - -## Hydration Guard - -- `src/hooks/useHydration.ts` — custom hook that resolves after `onFinishHydration` -- `App.tsx` renders a loading state until the store is rehydrated — eliminates empty-state flash for returning users - -## ACCanvas.tsx - -- Orchestration shell; mounts the active tab view, wires modals, and handles global search -- Do not add new top-level tabs without updating `VALID_TABS`, the tab-switch render, and `TabBar` props - -## Categories - -`CategoryId = 'fish' | 'bugs' | 'fossils' | 'art' | 'sea_creatures'` (added v0.8.2) - -`CATEGORY_ORDER` and `AllData` both include `sea_creatures`. Sea creatures are loaded for ACNL and ACNH only (`GAMES_WITH_SEA_CREATURES` set in `categoryMeta.ts`); the tab is hidden for other games via data-length check. - -## Data Files - -Museum data lives in `public/data//`: -- `public/data/acgcn/` — Animal Crossing GCN (40 fish, 40 bugs, 25 fossils, 13 art) -- `public/data/acww/` — Animal Crossing Wild World (56 fish, 56 bugs, 52 fossils) — added v0.7 -- `public/data/accf/` — City Folk (40 fish, 40 bugs, 52 fossils) — added v0.7 -- `public/data/acnl/` — New Leaf (fish, bugs, fossils, art, sea creatures) — added v0.8 -- `public/data/acnh/` — New Horizons (81 fish, 80 bugs, 86 fossils, 43 art, 40 sea creatures; NH/SH months) — added v0.8 - -Item IDs are shared across games where species overlap. The store scopes by `gameId`, so this is safe. - -## Multi-Game Types - -```ts -type GameId = 'ACGCN' | 'ACWW' | 'ACCF' | 'ACNL' | 'ACNH'; - -interface Game { - id: GameId; - name: string; - shortName: string; - year: number; -} - -const GAMES: Record = { ... }; // in src/lib/types.ts -``` - -## Error Handling - -- `src/components/ErrorBoundary.tsx` — top-level React error boundary in `App.tsx`; unhandled crashes render `ErrorState` -- `src/components/ErrorBanner.tsx` — dismissible inline error for soft failures -- `src/components/ErrorState.tsx` — full-page fallback for hard failures -- `AppErrorKind` discriminated union in `src/lib/types.ts` - -## Design Tokens - -Inline hex values via `src/lib/colors.ts` — **no Tailwind design tokens**: -- `#7B5E3B` — wood (header/section backgrounds) -- `#F5E9D4` — paper (card backgrounds) -- `#2A2A2A` — ink (primary text) -- `#3CA370` — leaf (progress bars, success states) -- `#E7DAC4` — border colour -- `#5a4a35` — secondary/muted text -- Google Fonts: **Varela Round** - -## Constants - -Shared constants in `src/lib/constants.ts`: `MONTH_NAMES`, `CATEGORY_LABELS`, `CATEGORY_ORDER`, `SEASONS`. +- `docs/v0.9-plan.md` — canonical v0.9.0-beta plan + 10 locked decisions +- `docs/decisions.md` — reverse-chronological decision log +- `docs/design-handoffs/` — v0.9 / v0.9.1 / v0.9.2 design specs +- `docs/dev-process.md` — full dev process +- `docs/v0.7-audit.md` / `v0.7-architecture-proposal.md` — multi-game foundation history diff --git a/docs/decisions.md b/docs/decisions.md index ba6b484..302c333 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,16 @@ Reverse-chronological record of significant design and scope decisions. Newest f --- +## 2026-05-03 — v0.9.0-beta locked decisions live in `docs/v0.9-plan.md` + +**Decision:** The 10 locked design decisions for the v0.9 UI revamp (game immutability, Meadow-only theme, Settings = About + Danger only, sea creatures in StatsTab, `playerName` deprecation, ACNH-only hemisphere, native `confirm()`, `basedOn` art search, sea in GlobalSearchDropdown, scroll-to + highlight wiring) are recorded in `docs/v0.9-plan.md` section 4 rather than duplicated here. + +**Why:** The plan doc is the canonical reference for v0.9 scope and is read alongside the design handoffs in `docs/design-handoffs/`. Splitting the decisions across two files would create two sources of truth. This log continues to capture decisions that don't sit inside an active plan doc (incident-driven, post-ship, scope tradeoffs). + +**Pointer:** See `docs/v0.9-plan.md` § 4 "Locked Design Decisions" for the binding list. + +--- + ## 2026-05-01 — Shadow size not surfaced in any UI — defer to v0.9 (Issue #59) **Decision:** Defer adding shadow size display to v0.9. Do not add it to v0.8.2. diff --git a/docs/design-handoffs/README.md b/docs/design-handoffs/README.md new file mode 100644 index 0000000..44b8fe2 --- /dev/null +++ b/docs/design-handoffs/README.md @@ -0,0 +1,13 @@ +# v0.9 Design Handoffs + +Read-only design references for the v0.9 implementation. Three iterative passes from the design curator, each building on the previous one: + +| Pass | Folder | Notes | +|------|--------|-------| +| First | `v0.9_curator/` | Design language, palette, type scale, all screens | +| Second | `v0.9.1_curator/` | TownManager drawer, GlobalSearchDropdown, Sea Creatures nav, `chip-sea` token | +| Third (most current) | `v0.9.2_curator/` | ProgressMeter 5-segment responsive, scroll-to + highlight, Settings page | + +**v0.9.2 is the most current handoff.** Where passes disagree, the later pass wins. + +These are *inputs*. The canonical implementation source of truth is [`docs/v0.9-plan.md`](../v0.9-plan.md). Don't modify the handoff files — treat them as historical design artifacts. diff --git a/docs/design-handoffs/v0.9.1_curator/Curator.html b/docs/design-handoffs/v0.9.1_curator/Curator.html new file mode 100644 index 0000000..5adf237 --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/Curator.html @@ -0,0 +1,268 @@ + + + + + +Curator — AC Museum Tracker + + + + +
+ + + + + + + + + + + + + + + + + + diff --git a/docs/design-handoffs/v0.9.1_curator/README.md b/docs/design-handoffs/v0.9.1_curator/README.md new file mode 100644 index 0000000..3110a08 --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/README.md @@ -0,0 +1,75 @@ +# Handoff: v0.9.1 UI Redesign — "Curator" + +## What's new since v0.9 + +This bundle replaces the prior `design_handoff_v0.9_curator/` snapshot. Same overall direction (sidebar + sectioned list, Meadow palette, Fraunces+Inter type), but with three additions and one bugfix: + +1. **Town Manager drawer** (`addons.jsx` → `TownManager`, `addons-styles.css`) + - Right-side drawer (420px wide) with scrim. Replaces the old town-switcher dropdown + Create/Edit modals. + - Row-level inline edit (name, game, hemisphere for ACNH). "+ New town" sticky CTA in the footer expands into the same form pattern. + - Active town shows accent border + filled radio mark. + - Mobile: collapses to a bottom sheet at ≤720px. + - **Wiring:** Replaces `CreateTownModal`, `EditTownModal`, and the `TownSwitcher` dropdown. Reads/writes `towns` and `activeTownId` from the existing Zustand store. + +2. **Sea Creatures tab** (already in v0.8.2 but visually styled here) + - Sidebar shows the Sea entry only when `activeTown.gameId === "acnh" || "acnl"`. + - Same row treatment as fish/bugs/fossils, with shadow size + time pill in the meta line. + - Slotted between Art and Stats. + +3. **Global Search dropdown** (`addons.jsx` → `GlobalSearchDropdown`) + - Anchored under the search input on Home only (`tab === "home"`). Other tabs keep per-tab inline filtering. + - Three states: empty (recent searches from `localStorage`), no-match, and grouped results. + - Grouped by category with colored dot + count. Each row: monogram glyph (category-tinted), name, meta line (habitat / value), donated badge. + - Keyboard nav: ↑↓ to move, ↵ to open, esc to close. Search history persisted to `ac-curator-search-history` (8 entries max). + - **Wiring:** Replaces the existing `GlobalSearchBar` + `GlobalSearchResults` + `SearchHistoryPopover` trio. Selecting a result jumps to that category's tab. + +4. **Bugfix: theme switching no longer tints all category icons green** + - `theme.js` was writing `--chip-bug` and `--chip-fossil` (singular) but the CSS reads `--chip-bugs` and `--chip-fossils` (plural). Fixed all four themes. + - Added `chipSea` token to every theme. + +## File map + +| File | Purpose | +|---|---| +| `Curator.html` | Entry point. Defaults, theme, and orchestration. | +| `shell.jsx` | Sidebar, ProgressMeter | +| `components.jsx` | Glyph, ItemRow, ExpandPanel, MonthStrip, MonthGrid, Pill, etc. | +| `tabs.jsx` | HomeTab, CategoryTab, StatsTab | +| `addons.jsx` | **NEW** — TownManager drawer + GlobalSearchDropdown | +| `data.js` | Mock data (fish/bugs/fossils/art/sea, towns, recent activity) | +| `theme.js` | 4 themes — Meadow (default), Parchment, Midnight, Sakura | +| `styles.css` | Core styles | +| `addons-styles.css` | **NEW** — drawer + dropdown styles | +| `tweaks-panel.jsx` | In-page Tweaks panel (skip in production) | + +## Migration notes (delta from v0.9) + +- `useMuseumData` already takes a `gameId` — no change. Continue gating Sea on `gameId in {acnl, acnh}`. +- The Town Manager drawer should mount at the **layout level** (above the router), not inside `ACCanvas`. This unblocks the v0.8.1 stopgap where Edit/Create buttons were greyed out on category tabs. +- `GlobalSearchDropdown` should hook into the existing `useSearch` hook for debounce + history. Replace `SearchHistoryPopover` entirely; recent-search rendering moves into the dropdown's empty state. +- All existing route paths preserved. + +## Themes + +Default: **Meadow**. Other three are progressive enhancement. + +| token | meadow | parchment | +|---|---|---| +| `accent` | `oklch(0.55 0.09 150)` (moss) | `#3CA370` (leaf) | +| `chip-fish` | `oklch(0.62 0.08 230)` | `#3F6FA8` | +| `chip-bugs` | `oklch(0.6 0.1 130)` | `#7B9C3A` | +| `chip-fossils` | `oklch(0.55 0.06 60)` | `#7B5E3B` | +| `chip-art` | `oklch(0.58 0.08 320)` | `#8B5E94` | +| `chip-sea` | `oklch(0.58 0.09 200)` | `#3D8B96` | + +Category chip colors are **identity tokens** — they should remain consistent across all themes. Only `accent`, `bg`, `surface`, and `ink` shift between themes. + +## Fonts + +- Display: **Fraunces** (500/italic) — headers, titles, hero, monogram glyphs +- UI: **Inter** (400/500/600) — everything else +- Parchment theme overrides both to **Varela Round** to match the existing app + +## See also + +The v0.9 README in `design_handoff_v0.9_curator/README.md` has the full screen-by-screen spec — layouts, spacing, interaction notes. This bundle's components are the same shape; treat the v0.9 README as the canonical spec and this one as the diff. diff --git a/docs/design-handoffs/v0.9.1_curator/addons-styles.css b/docs/design-handoffs/v0.9.1_curator/addons-styles.css new file mode 100644 index 0000000..5a332e5 --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/addons-styles.css @@ -0,0 +1,280 @@ +/* ── Town Manager drawer ── */ +.ac-tm-scrim { + position: fixed; inset: 0; z-index: 9990; + background: rgba(20,18,12,0.32); + -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); + display: flex; justify-content: flex-end; + animation: ac-fade 0.18s ease; +} +@keyframes ac-fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes ac-slide { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } +.ac-tm-drawer { + width: 420px; max-width: 100vw; + background: var(--bg); + border-left: 1px solid var(--border); + display: flex; flex-direction: column; + height: 100vh; + animation: ac-slide 0.22s ease; + box-shadow: -20px 0 60px rgba(0,0,0,0.08); +} +.ac-tm-head { + display: flex; justify-content: space-between; align-items: flex-start; + padding: 22px 24px 18px; + border-bottom: 1px solid var(--border); +} +.ac-tm-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); } +.ac-tm-title { font-family: var(--font-display); font-weight: 500; font-size: 22px; margin: 4px 0 0; letter-spacing: -0.01em; max-width: 280px; } +.ac-tm-close { + width: 32px; height: 32px; border-radius: 8px; + font-size: 22px; line-height: 1; color: var(--ink-muted); +} +.ac-tm-close:hover { background: var(--surface-alt); color: var(--ink); } + +.ac-tm-list { flex: 1; overflow-y: auto; padding: 14px 18px; display: flex; flex-direction: column; gap: 6px; } + +.ac-tm-row { + display: flex; align-items: stretch; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + transition: border-color 0.12s; +} +.ac-tm-row:hover { border-color: var(--border-strong); } +.ac-tm-row-active { border-color: var(--accent); background: var(--accent-soft); } +.ac-tm-row-main { + flex: 1; display: flex; gap: 14px; align-items: center; + padding: 14px 16px; text-align: left; min-width: 0; +} +.ac-tm-row-mark { + width: 18px; height: 18px; border-radius: 50%; + border: 1.5px solid; + display: grid; place-items: center; + flex: none; +} +.ac-tm-row-tick { color: var(--accent); font-size: 9px; } +.ac-tm-row-text { flex: 1; min-width: 0; } +.ac-tm-row-name { font-family: var(--font-display); font-weight: 500; font-size: 18px; letter-spacing: -0.01em; } +.ac-tm-row-meta { + display: flex; gap: 6px; align-items: center; + font-size: 12px; color: var(--ink-soft); margin-top: 2px; + flex-wrap: wrap; +} +.ac-tm-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: var(--surface-alt); + border: 1px solid var(--border); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--ink-soft); +} +.ac-tm-row-active .ac-tm-badge { background: var(--surface); } + +.ac-tm-row-edit { + padding: 0 16px; + color: var(--ink-muted); + border-left: 1px solid var(--border); + display: grid; place-items: center; + transition: background 0.12s, color 0.12s; +} +.ac-tm-row-edit:hover { background: var(--surface-alt); color: var(--ink); } +.ac-tm-row-active .ac-tm-row-edit { border-left-color: var(--accent); } + +/* Editing state */ +.ac-tm-row-editing { + flex-direction: column; + background: var(--surface); + border-color: var(--border-strong); + padding: 18px; + gap: 14px; +} +.ac-tm-form { display: flex; flex-direction: column; gap: 12px; } +.ac-tm-field { display: flex; flex-direction: column; gap: 4px; } +.ac-tm-field-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-tm-input { + font: inherit; color: var(--ink); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + outline: none; +} +.ac-tm-input:focus { border-color: var(--accent); } + +.ac-tm-seg { + display: flex; gap: 4px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px; +} +.ac-tm-seg button { + flex: 1; padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: var(--ink-soft); + transition: background 0.12s, color 0.12s; +} +.ac-tm-seg button:hover { color: var(--ink); } +.ac-tm-seg button.ac-tm-seg-on { + background: var(--surface); + color: var(--accent-ink); + font-weight: 500; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.ac-tm-row-actions { + display: flex; justify-content: space-between; gap: 8px; + padding-top: 8px; border-top: 1px solid var(--border); +} +.ac-tm-row-actions-right { display: flex; gap: 8px; } +.ac-tm-ghost, .ac-tm-primary, .ac-tm-danger { + padding: 7px 14px; border-radius: 8px; + font-size: 13px; font-weight: 500; + transition: background 0.12s, opacity 0.12s; +} +.ac-tm-ghost { color: var(--ink-soft); } +.ac-tm-ghost:hover { background: var(--surface-alt); color: var(--ink); } +.ac-tm-primary { background: var(--accent); color: var(--surface); } +.ac-tm-primary:hover { opacity: 0.88; } +.ac-tm-primary:disabled { opacity: 0.4; cursor: not-allowed; } +.ac-tm-danger { color: var(--warn); } +.ac-tm-danger:hover { background: var(--warn-soft); } + +.ac-tm-empty { padding: 60px 20px; text-align: center; color: var(--ink-muted); } +.ac-tm-empty-glyph { + width: 56px; height: 56px; border-radius: 50%; + border: 1.5px dashed var(--border-strong); + margin: 0 auto 14px; + display: grid; place-items: center; + color: var(--ink-muted); + font-size: 24px; +} +.ac-tm-empty-title { font-family: var(--font-display); font-size: 18px; color: var(--ink); margin-bottom: 4px; } +.ac-tm-empty-sub { font-size: 13px; } + +.ac-tm-foot { padding: 14px 18px 22px; border-top: 1px solid var(--border); } +.ac-tm-cta { + width: 100%; + display: flex; align-items: center; justify-content: center; gap: 8px; + padding: 12px; + background: var(--surface); + border: 1.5px dashed var(--border-strong); + border-radius: 12px; + color: var(--ink-soft); + font-weight: 500; + font-size: 14px; + transition: border-color 0.12s, color 0.12s, background 0.12s; +} +.ac-tm-cta:hover { border-color: var(--accent); color: var(--accent-ink); background: var(--accent-soft); } +.ac-tm-cta-plus { font-size: 18px; line-height: 1; } +.ac-tm-newform { display: flex; flex-direction: column; gap: 8px; } +.ac-tm-newform-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } + +/* Mobile: bottom sheet */ +@media (max-width: 720px) { + .ac-tm-scrim { align-items: flex-end; justify-content: center; } + .ac-tm-drawer { + width: 100%; height: 88vh; + border-left: none; border-top: 1px solid var(--border); + border-radius: 16px 16px 0 0; + animation: ac-slide-up 0.22s ease; + } + @keyframes ac-slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +} + +/* ── Global Search dropdown ── */ +.ac-search-wrap { position: relative; flex: 1; max-width: 380px; } +.ac-search-wrap .ac-search { max-width: none; } +.ac-gs-panel { + position: absolute; top: calc(100% + 6px); left: 0; right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(20,18,12,0.10); + z-index: 50; + max-height: 420px; + overflow-y: auto; + padding: 8px; + animation: ac-fade 0.12s ease; +} +.ac-gs-empty { padding: 22px 14px; text-align: center; } +.ac-gs-empty-title { font-family: var(--font-display); font-size: 16px; color: var(--ink); margin-bottom: 4px; } +.ac-gs-empty-title em { font-style: italic; color: var(--accent-ink); } +.ac-gs-empty-sub { font-size: 12px; color: var(--ink-muted); } +.ac-gs-hint { margin-top: 14px; font-size: 11px; color: var(--ink-muted); } + +.ac-gs-section-head { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 10px 6px; +} +.ac-gs-eyebrow { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; font-weight: 600; color: var(--ink-muted); } +.ac-gs-clear { font-size: 11px; color: var(--ink-muted); padding: 2px 6px; border-radius: 4px; } +.ac-gs-clear:hover { background: var(--surface-alt); color: var(--ink); } + +.ac-gs-history { display: flex; flex-direction: column; gap: 1px; } +.ac-gs-history-row { + display: flex; gap: 10px; align-items: center; + padding: 8px 12px; border-radius: 8px; + font-size: 13px; color: var(--ink-soft); text-align: left; +} +.ac-gs-history-row:hover { background: var(--surface-alt); color: var(--ink); } +.ac-gs-history-icon { color: var(--ink-muted); font-size: 12px; } + +.ac-gs-group { margin-bottom: 6px; } +.ac-gs-group-head { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px 4px; +} +.ac-gs-group-dot { width: 6px; height: 6px; border-radius: 50%; } +.ac-gs-group-count { margin-left: auto; font-size: 11px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } + +.ac-gs-row { + width: 100%; + display: flex; gap: 12px; align-items: center; + padding: 8px 10px; border-radius: 8px; + text-align: left; + transition: background 0.1s; +} +.ac-gs-row:hover { background: var(--surface-alt); } +.ac-gs-row-active { background: var(--accent-soft); } +.ac-gs-row-glyph { + width: 28px; height: 28px; + border: 1.5px solid; + border-radius: 6px; + display: grid; place-items: center; + font-family: var(--font-display); font-size: 10px; font-weight: 500; + flex: none; +} +.ac-gs-row-text { flex: 1; min-width: 0; } +.ac-gs-row-name { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--ink); font-weight: 500; +} +.ac-gs-row-donated { + font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; + color: var(--accent); font-weight: 600; +} +.ac-gs-row-meta { font-size: 11px; color: var(--ink-muted); margin-top: 1px; text-transform: capitalize; } +.ac-gs-row-arrow { color: var(--ink-muted); font-size: 11px; opacity: 0; } +.ac-gs-row-active .ac-gs-row-arrow { opacity: 1; } + +.ac-gs-foot { + display: flex; gap: 14px; justify-content: center; + padding: 10px 8px 4px; + border-top: 1px solid var(--border); + margin-top: 6px; + font-size: 10px; color: var(--ink-muted); +} +kbd { + font-family: ui-monospace, monospace; + font-size: 10px; + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 4px; + margin-right: 4px; +} diff --git a/docs/design-handoffs/v0.9.1_curator/addons.jsx b/docs/design-handoffs/v0.9.1_curator/addons.jsx new file mode 100644 index 0000000..45a4e81 --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/addons.jsx @@ -0,0 +1,327 @@ +/* global React, ACComponents */ +const { useState, useEffect, useRef, useMemo } = React; + +// ── Town Manager: right-side drawer with switch / edit / create ── +function TownManager({ open, onClose, towns, activeTownId, onActivate, onUpdate, onCreate, onDelete }) { + const [editingId, setEditingId] = useState(null); + const [creating, setCreating] = useState(false); + const drawerRef = useRef(null); + + useEffect(() => { + if (!open) { setEditingId(null); setCreating(false); } + }, [open]); + + // ESC to close + useEffect(() => { + if (!open) return; + const onKey = (e) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ +
+ ); +} + +const GAMES = [ + { id: "acgcn", label: "GameCube", short: "GCN" }, + { id: "acww", label: "Wild World", short: "WW" }, + { id: "accf", label: "City Folk", short: "CF" }, + { id: "acnl", label: "New Leaf", short: "NL" }, + { id: "acnh", label: "New Horizons", short: "NH" }, +]; + +function TownRow({ town, active, editing, onActivate, onEditStart, onEditCancel, onSave, onDelete, canDelete }) { + const [name, setName] = useState(town.name); + const [gameId, setGameId] = useState(town.gameId); + const [hemisphere, setHemisphere] = useState(town.hemisphere || "NH"); + useEffect(() => { setName(town.name); setGameId(town.gameId); setHemisphere(town.hemisphere || "NH"); }, [town, editing]); + + const game = GAMES.find(g => g.id === town.gameId); + + if (editing) { + return ( +
+
+ + + {gameId === "acnh" && ( + + )} +
+
+ {canDelete && ( + + )} +
+ + +
+
+
+ ); + } + + return ( +
+ + +
+ ); +} + +function NewTownForm({ onCancel, onCreate }) { + const [name, setName] = useState(""); + const [gameId, setGameId] = useState("acnh"); + const [hemisphere, setHemisphere] = useState("NH"); + return ( +
+ setName(e.target.value)} /> + + {gameId === "acnh" && ( +
+ + +
+ )} +
+ + +
+
+ ); +} + +// ── Global Search Dropdown ── +const SEARCH_HISTORY_KEY = "ac-curator-search-history"; +function loadHistory() { try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || "[]"); } catch { return []; } } +function saveHistory(arr) { try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(arr.slice(0, 8))); } catch {} } + +function GlobalSearchDropdown({ query, data, donated, onJump, onClose }) { + const [history, setHistory] = useState(loadHistory()); + const [activeIdx, setActiveIdx] = useState(0); + + const grouped = useMemo(() => { + if (!query.trim()) return null; + const q = query.trim().toLowerCase(); + const g = { fish:[], bugs:[], fossils:[], art:[] }; + for (const cat of Object.keys(g)) { + g[cat] = data[cat].filter(it => + it.name.toLowerCase().includes(q) || + (it.basedOn||"").toLowerCase().includes(q) + ).slice(0, 5); + } + return g; + }, [query, data]); + + const flatList = useMemo(() => { + if (!grouped) return []; + return ["fish","bugs","fossils","art"].flatMap(cat => grouped[cat].map(it => ({...it, _cat:cat}))); + }, [grouped]); + + useEffect(() => { setActiveIdx(0); }, [query]); + + useEffect(() => { + const onKey = (e) => { + if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx(i => Math.min(i+1, flatList.length-1)); } + else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx(i => Math.max(i-1, 0)); } + else if (e.key === "Enter" && flatList[activeIdx]) { + const it = flatList[activeIdx]; + commitSearch(it.name); + onJump(it._cat, it.id); + } else if (e.key === "Escape") { onClose(); } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [flatList, activeIdx, onJump, onClose]); + + function commitSearch(term) { + const next = [term, ...history.filter(h => h !== term)].slice(0, 8); + setHistory(next); saveHistory(next); + } + + function clearHistory() { setHistory([]); saveHistory([]); } + + // Empty-input state: recent searches + if (!query.trim()) { + if (history.length === 0) { + return ( +
+
+
Search across categories
+
Type a name to find fish, bugs, fossils, or art at once.
+
↑↓ navigate · open · esc close
+
+
+ ); + } + return ( +
+
+ Recent searches + +
+
+ {history.map((h,i) => ( + + ))} +
+
+ ); + } + + // No matches + const total = flatList.length; + if (total === 0) { + return ( +
+
+
No matches for "{query}"
+
Try a shorter term or check the spelling.
+
+
+ ); + } + + // Grouped results + let rowIdx = -1; + return ( +
+ {["fish","bugs","fossils","art"].map(cat => { + const items = grouped[cat]; + if (items.length === 0) return null; + return ( +
+
+ + {cat} + {items.length} +
+ {items.map(it => { + rowIdx++; + const isActive = rowIdx === activeIdx; + const isDonated = donated[cat].has(it.id); + return ( + + ); + })} +
+ ); + })} +
+ ↑↓ navigate + open + esc close +
+
+ ); +} + +window.ACAddOns = { TownManager, GlobalSearchDropdown, GAMES }; diff --git a/docs/design-handoffs/v0.9.1_curator/components.jsx b/docs/design-handoffs/v0.9.1_curator/components.jsx new file mode 100644 index 0000000..fad2e6d --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/components.jsx @@ -0,0 +1,127 @@ +/* global React */ +const { useState, useMemo } = React; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +// ── Glyph: a simple monogram tile, category-tinted. No copyrighted sprites. ── +function Glyph({ name, category, donated }) { + const initials = name.split(/\s|-/).filter(Boolean).slice(0, 2).map((s) => s[0]).join("").toUpperCase(); + const tint = `var(--chip-${category})`; + return ( +
+ {initials} +
); + +} + +function Pill({ children, tone = "default", size = "sm" }) { + return {children}; +} + +function MonthDots({ months, current }) { + return ( +
+ {Array.from({ length: 12 }, (_, i) => { + const m = i + 1; + const on = months.includes(m); + const here = m === current; + return ; + })} +
); + +} + +function MonthGrid({ months, current }) { + return ( +
+ {MONTHS.map((m, i) => { + const on = months.includes(i + 1); + const here = i + 1 === current; + return ( +
+ {m} +
); + + })} +
); + +} + +// ── Item row with inline expand panel ── +function ItemRow({ item, category, donated, onToggle, currentMonth, expanded, onExpand }) { + const leavingSoon = item.months && item.months.includes(currentMonth) && !item.months.includes(currentMonth === 12 ? 1 : currentMonth + 1); + const newThisMonth = item.months && item.months.includes(currentMonth) && !item.months.includes(currentMonth === 1 ? 12 : currentMonth - 1); + + return ( +
+ + {expanded && +
+ {item.months && +
+
Available in
+ +
+ } +
+ {item.value != null && +
+
{item.value.toLocaleString()}
+
bells · sell value
+
+ } + {item.shadow && +
+
{item.shadow}
+
shadow size
+
+ } + {item.time && +
+
{item.time}
+
active hours
+
+ } + {item.notes && +
{item.notes}
+ } + +
+
+ } +
); + +} + +window.ACComponents = { Glyph, Pill, MonthDots, MonthGrid, ItemRow, MONTHS, MONTHS_LONG }; \ No newline at end of file diff --git a/docs/design-handoffs/v0.9.1_curator/data.js b/docs/design-handoffs/v0.9.1_curator/data.js new file mode 100644 index 0000000..4cf64aa --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/data.js @@ -0,0 +1,121 @@ +// Sample museum data inspired by AC GCN/NH species lists. +// Real-feeling enough for a prototype; no copyrighted sprite assets. +window.MUSEUM_DATA = { + meta: { + townName: "Marigold", + playerName: "Bea", + game: "New Horizons", + hemisphere: "NH", + currentMonth: 5, // May + currentDay: 2, + }, + fish: [ + { id: "loach", name: "Loach", value: 400, habitat: "river", shadow: "small", time: "all day", months: [3,4,5] }, + { id: "angelfish", name: "Angelfish", value: 3000, habitat: "river", shadow: "small", time: "4pm–9am", months: [5,6,7,8,9,10] }, + { id: "cherry-salmon", name: "Cherry Salmon", value: 1000, habitat: "river", shadow: "medium", time: "4pm–9am", months: [3,4,5,6,9,10,11] }, + { id: "barbel-steed", name: "Barbel Steed", value: 200, habitat: "river", shadow: "medium", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "barred-knifejaw", name: "Barred Knifejaw", value: 5000, habitat: "ocean", shadow: "medium", time: "all day", months: [3,4,5,6,7,8,9,10,11] }, + { id: "bass", name: "Black Bass", value: 400, habitat: "river", shadow: "large", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "bluegill", name: "Bluegill", value: 180, habitat: "river", shadow: "small", time: "9am–4pm", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "brook-trout", name: "Brook Trout", value: 150, habitat: "river-clifftop", shadow: "small", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "carp", name: "Carp", value: 300, habitat: "pond", shadow: "medium", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "cherry-shrimp", name: "Cherry Shrimp", value: 600, habitat: "pond", shadow: "tiny", time: "all day", months: [4,5,6,7,8,9] }, + { id: "clouded-cichlid", name: "Clouded Cichlid", value: 800, habitat: "river", shadow: "medium", time: "all day", months: [4,5,6,7,8,9,10] }, + { id: "coelacanth", name: "Coelacanth", value: 15000, habitat: "ocean", shadow: "huge", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12], notes: "Only when raining or snowing" }, + { id: "crawfish", name: "Crawfish", value: 200, habitat: "pond", shadow: "small", time: "all day", months: [4,5,6,7,8,9] }, + { id: "crucian-carp", name: "Crucian Carp", value: 160, habitat: "river", shadow: "small", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "dace", name: "Dace", value: 240, habitat: "river", shadow: "medium", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "freshwater-goby", name: "Freshwater Goby", value: 400, habitat: "river", shadow: "small", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "frog", name: "Frog", value: 120, habitat: "pond", shadow: "small", time: "all day", months: [5,6,7,8] }, + { id: "goldfish", name: "Goldfish", value: 1300, habitat: "pond", shadow: "tiny", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "guppy", name: "Guppy", value: 1300, habitat: "river", shadow: "tiny", time: "9am–4pm", months: [4,5,6,7,8,9,10,11] }, + { id: "killifish", name: "Killifish", value: 300, habitat: "pond", shadow: "tiny", time: "all day", months: [4,5,6,7,8] }, + { id: "koi", name: "Koi", value: 4000, habitat: "pond", shadow: "medium", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "ladybug", name: "Ladybug", value: 200, habitat: "pond", shadow: "tiny", time: "8am–5pm", months: [3,4,5,6,10] }, + { id: "rainbow-trout", name: "Rainbow Trout", value: 800, habitat: "river-clifftop", shadow: "medium", time: "4pm–9am", months: [3,4,5,6,9,10,11] }, + { id: "sea-bass", name: "Sea Bass", value: 400, habitat: "ocean", shadow: "large", time: "all day", months: [1,2,3,4,5,6,7,9,10,11,12] }, + { id: "stringfish", name: "Stringfish", value: 15000, habitat: "river-clifftop", shadow: "huge", time: "4pm–9am", months: [12,1,2,3] }, + ], + bugs: [ + { id: "mole-cricket", name: "Mole Cricket", value: 500, location: "underground", time: "all day", months: [11,12,1,2,3,4,5] }, + { id: "ant", name: "Ant", value: 80, location: "rotten food", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "bee", name: "Bee", value: 2500, location: "shaking trees", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "common-butterfly", name: "Common Butterfly", value: 160, location: "flying", time: "4am–7pm", months: [1,2,3,4,5,6,9,10,11,12] }, + { id: "common-dragonfly", name: "Common Dragonfly", value: 180, location: "flying", time: "8am–7pm", months: [4,5,6,7,8,9,10] }, + { id: "clouded-yellow", name: "Clouded Yellow Butterfly", value: 160, location: "flying", time: "4am–7pm", months: [3,4,5,6,9,10] }, + { id: "cockroach", name: "Cockroach", value: 5, location: "underground", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "cricket", name: "Cricket", value: 130, location: "ground", time: "5pm–8am", months: [9,10,11] }, + { id: "darner-dragonfly", name: "Darner Dragonfly", value: 230, location: "flying", time: "8am–5pm", months: [4,5,6,7,8,9,10] }, + { id: "diving-beetle", name: "Diving Beetle", value: 800, location: "ponds", time: "8am–7pm", months: [5,6,7,8,9] }, + { id: "earth-boring-beetle", name: "Earth-Boring Beetle", value: 300, location: "ground", time: "all day", months: [7,8,9,10,11] }, + { id: "hermit-crab", name: "Hermit Crab", value: 1000, location: "beach", time: "7pm–8am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "honeybee", name: "Honeybee", value: 200, location: "flying", time: "8am–5pm", months: [3,4,5,6,7] }, + ], + fossils: [ + { id: "ammonite", name: "Ammonite", value: 1100 }, + { id: "ankylo-skull", name: "Ankylo Skull", value: 6000 }, + { id: "ankylo-tail", name: "Ankylo Tail", value: 2500 }, + { id: "ankylo-torso", name: "Ankylo Torso", value: 5500 }, + { id: "archaeopteryx", name: "Archaeopteryx", value: 1300 }, + { id: "archelon-skull", name: "Archelon Skull", value: 4000 }, + { id: "archelon-tail", name: "Archelon Tail", value: 3500 }, + { id: "australopith", name: "Australopith", value: 1100 }, + { id: "brachio-chest", name: "Brachio Chest", value: 5500 }, + { id: "brachio-pelvis", name: "Brachio Pelvis", value: 5500 }, + { id: "brachio-skull", name: "Brachio Skull", value: 6000 }, + { id: "brachio-tail", name: "Brachio Tail", value: 5500 }, + { id: "deinony-tail", name: "Deinony Tail", value: 2500 }, + { id: "deinony-torso", name: "Deinony Torso", value: 5500 }, + { id: "diplo-chest", name: "Diplo Chest", value: 5000 }, + { id: "diplo-neck", name: "Diplo Neck", value: 2500 }, + { id: "diplo-pelvis", name: "Diplo Pelvis", value: 4500 }, + { id: "diplo-skull", name: "Diplo Skull", value: 5000 }, + { id: "diplo-tail", name: "Diplo Tail", value: 2500 }, + ], + art: [ + { id: "academic", name: "Academic Painting", basedOn: "Vitruvian Man by Leonardo da Vinci", hasFake: true }, + { id: "amazing", name: "Amazing Painting", basedOn: "The Night Watch by Rembrandt", hasFake: true }, + { id: "basic", name: "Basic Painting", basedOn: "The Blue Boy by Thomas Gainsborough", hasFake: false }, + { id: "calm", name: "Calm Painting", basedOn: "A Sunday Afternoon on La Grande Jatte by Georges Seurat", hasFake: false }, + { id: "classic", name: "Classic Painting", basedOn: "Washington Crossing the Delaware by Emanuel Leutze", hasFake: true }, + { id: "common", name: "Common Painting", basedOn: "The Gleaners by Jean-François Millet", hasFake: false }, + { id: "dainty", name: "Dainty Painting", basedOn: "The Star — Dancer on Stage by Edgar Degas", hasFake: true }, + { id: "famous", name: "Famous Painting", basedOn: "Mona Lisa by Leonardo da Vinci", hasFake: true }, + { id: "flowery", name: "Flowery Painting", basedOn: "Sunflowers by Vincent van Gogh", hasFake: false }, + { id: "graceful", name: "Graceful Painting", basedOn: "Beauty Looking Back by Hishikawa Moronobu", hasFake: true }, + { id: "moving", name: "Moving Painting", basedOn: "The Birth of Venus by Sandro Botticelli", hasFake: true }, + { id: "perfect", name: "Perfect Painting", basedOn: "The Apotheosis of Homer by Ingres", hasFake: false }, + { id: "scary", name: "Scary Painting", basedOn: "The Great Wave off Kanagawa by Hokusai", hasFake: false }, + { id: "warm", name: "Warm Painting", basedOn: "Et in Arcadia ego by Nicolas Poussin", hasFake: false }, + ], + // Pre-seed donation state for realism + donated: { + fish: ["barbel-steed","bass","bluegill","brook-trout","carp","crucian-carp","dace","frog","goldfish","koi"], + bugs: ["ant","cockroach","common-butterfly","clouded-yellow","honeybee"], + fossils: ["ammonite","ankylo-skull","ankylo-tail","ankylo-torso","archaeopteryx","brachio-chest","brachio-pelvis","brachio-skull","brachio-tail","deinony-tail","diplo-chest","diplo-pelvis","diplo-skull"], + art: ["basic","calm","common","flowery","perfect","scary","warm"], + sea: ["seaweed","sea-anemone","scallop"], + }, + towns: [ + { id: "marigold", name: "Marigold", gameId: "acnh", hemisphere: "NH", itemCount: 35 }, + { id: "saffron", name: "Saffron", gameId: "acgcn", hemisphere: null, itemCount: 12 }, + { id: "willow", name: "Willow", gameId: "acnl", hemisphere: null, itemCount: 28 }, + ], + sea: [ + { id: "seaweed", name: "Seaweed", value: 600, shadow: "tiny", time: "all day", months: [10,11,12,1,2,3,4,5,6,7] }, + { id: "sea-grapes", name: "Sea Grapes", value: 900, shadow: "tiny", time: "all day", months: [6,7,8,9] }, + { id: "sea-anemone", name: "Sea Anemone", value: 500, shadow: "small", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "sea-pineapple", name: "Sea Pineapple", value: 1500, shadow: "small", time: "all day", months: [4,5,6,7,8] }, + { id: "sea-cucumber", name: "Sea Cucumber", value: 500, shadow: "small", time: "all day", months: [11,12,1,2,3,4,5] }, + { id: "spider-crab", name: "Spider Crab", value: 12000, shadow: "huge", time: "all day", months: [3,4] }, + { id: "spotted-garden-eel", name: "Spotted Garden Eel", value: 1100, shadow: "small", time: "4am–9pm", months: [5,6,7,8,9,10] }, + { id: "scallop", name: "Scallop", value: 1200, shadow: "medium", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + ], + recentActivity: [ + { item: "Honeybee", category: "bugs", when: "2h ago" }, + { item: "Brook Trout", category: "fish", when: "yesterday"}, + { item: "Brachio Chest", category: "fossils", when: "yesterday"}, + { item: "Warm Painting", category: "art", when: "3d ago" }, + { item: "Carp", category: "fish", when: "5d ago" }, + ], +}; diff --git a/docs/design-handoffs/v0.9.1_curator/shell.jsx b/docs/design-handoffs/v0.9.1_curator/shell.jsx new file mode 100644 index 0000000..3b6d0f7 --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/shell.jsx @@ -0,0 +1,117 @@ +/* global React, ACComponents */ +const { useState, useMemo } = React; +const { Glyph, Pill, MonthDots, MonthGrid, ItemRow, MONTHS, MONTHS_LONG } = ACComponents; + +// ── Header / sidebar / shell ── +function Sidebar({ town, currentTab, onTab, stats, onOpenTowns }) { + const hasSea = town.gameId === "acnh" || town.gameId === "acnl"; + const tabs = [ + { id: "home", label: "Home" }, + { id: "fish", label: "Fish", n: stats.fish }, + { id: "bugs", label: "Bugs", n: stats.bugs }, + { id: "fossils", label: "Fossils", n: stats.fossils }, + { id: "art", label: "Art", n: stats.art }, + ...(hasSea ? [{ id: "sea", label: "Sea", n: stats.sea }] : []), + { id: "stats", label: "Stats" }, + ]; + return ( + + ); +} + +function MonthStrip({ current }) { + return ( +
+ {MONTHS.map((m,i) => ( +
+ {String(i+1).padStart(2,"0")} + {m} +
+ ))} +
+ ); +} + +function ProgressMeter({ stats }) { + const total = stats.fish.total + stats.bugs.total + stats.fossils.total + stats.art.total; + const done = stats.fish.donated + stats.bugs.donated + stats.fossils.donated + stats.art.donated; + const pct = Math.round((done/total)*100); + return ( +
+
+
+
Museum progress
+
+ {done} + / {total} +
+
+
{pct}%
+
+
+ {["fish","bugs","fossils","art"].map(k => { + const seg = stats[k]; + const frac = seg.total/total; + return ( +
+
+
+ + {k} + {seg.donated}/{seg.total} +
+
+ ); + })} +
+
+ ); +} + +window.ACShell = { Sidebar, MonthStrip, ProgressMeter }; diff --git a/docs/design-handoffs/v0.9.1_curator/styles.css b/docs/design-handoffs/v0.9.1_curator/styles.css new file mode 100644 index 0000000..4399b42 --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/styles.css @@ -0,0 +1,554 @@ +/* AC Curator — soft museum aesthetic */ + +@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,500&family=Inter:wght@400;500;600;700&family=Varela+Round&display=swap'); + +:root { + --bg: #F4EFE3; + --surface: #FFFDF7; + --surface-alt: #F8F2E2; + --ink: #23241F; + --ink-soft: #5C5848; + --ink-muted: #8A8470; + --border: #E2D9C3; + --border-strong: #CFC4A8; + --accent: oklch(0.55 0.09 150); + --accent-soft: oklch(0.55 0.09 150 / 0.12); + --accent-ink: oklch(0.32 0.06 150); + --warn: oklch(0.62 0.12 50); + --warn-soft: oklch(0.62 0.12 50 / 0.14); + --chip-fish: oklch(0.62 0.08 230); + --chip-bugs: oklch(0.6 0.1 130); + --chip-fossils: oklch(0.55 0.06 60); + --chip-art: oklch(0.58 0.08 320); + --chip-sea: oklch(0.58 0.09 200); + --font-display: 'Fraunces', Georgia, serif; + --font-ui: 'Inter', system-ui, sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font-ui); + background: var(--bg); + color: var(--ink); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + letter-spacing: -0.005em; +} +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; } + +/* ── App shell ── */ +.ac-app { + display: grid; + grid-template-columns: 280px 1fr; + min-height: 100vh; + max-width: 1440px; + margin: 0 auto; +} + +/* ── Sidebar ── */ +.ac-sidebar { + border-right: 1px solid var(--border); + padding: 28px 22px; + display: flex; + flex-direction: column; + gap: 22px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} +.ac-brand { display: flex; gap: 12px; align-items: center; color: var(--accent); } +.ac-brand-mark { + width: 38px; height: 38px; + border-radius: 50%; + border: 1.5px solid currentColor; + display: grid; place-items: center; +} +.ac-brand-name { font-family: var(--font-display); font-size: 20px; font-weight: 600; color: var(--ink); letter-spacing: -0.02em; } +.ac-brand-sub { font-size: 11px; color: var(--ink-muted); font-style: italic; font-family: var(--font-display); } + +.ac-town-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px 16px; +} +.ac-town-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-town-name { font-family: var(--font-display); font-size: 22px; font-weight: 500; margin-top: 2px; letter-spacing: -0.01em; } +.ac-town-meta { font-size: 12px; color: var(--ink-soft); margin-top: 2px; display: flex; gap: 6px; align-items: center; } +.ac-dot-sep { color: var(--border-strong); } +.ac-town-switch { + font-size: 12px; color: var(--accent-ink); margin-top: 10px; + padding: 0; text-align: left; font-weight: 500; +} +.ac-town-switch:hover { text-decoration: underline; } + +.ac-nav { display: flex; flex-direction: column; gap: 1px; } +.ac-nav-item { + display: flex; justify-content: space-between; align-items: baseline; + padding: 10px 14px; border-radius: 9px; + font-size: 14px; color: var(--ink-soft); text-align: left; + transition: background 0.12s; +} +.ac-nav-item:hover { background: var(--surface-alt); color: var(--ink); } +.ac-nav-item-active { background: var(--accent-soft); color: var(--accent-ink); font-weight: 500; } +.ac-nav-count { font-size: 11px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } +.ac-nav-count-slash { opacity: 0.5; margin: 0 1px; } +.ac-nav-item-active .ac-nav-count { color: var(--accent-ink); } + +.ac-sidebar-foot { margin-top: auto; display: flex; flex-direction: column; gap: 4px; padding-top: 14px; border-top: 1px solid var(--border); } +.ac-foot-link { padding: 6px 14px; font-size: 12px; color: var(--ink-muted); text-align: left; } +.ac-foot-link:hover { color: var(--ink); } + +/* ── Main column ── */ +.ac-main { + padding: 32px 48px 80px; + min-width: 0; +} +.ac-topbar { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 28px; gap: 16px; +} +.ac-search { + flex: 1; max-width: 380px; + display: flex; align-items: center; gap: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 12px; +} +.ac-search:focus-within { border-color: var(--border-strong); } +.ac-search input { border: none; background: none; outline: none; font: inherit; flex: 1; color: var(--ink); } +.ac-search input::placeholder { color: var(--ink-muted); } +.ac-search-icon { color: var(--ink-muted); } + +.ac-topbar-actions { display: flex; gap: 8px; align-items: center; } +.ac-date-chip { + display: flex; align-items: baseline; gap: 6px; + padding: 6px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + color: var(--ink-soft); +} +.ac-date-chip strong { font-family: var(--font-display); font-weight: 500; color: var(--ink); } + +/* ── Hero ── */ +.ac-hero { margin-bottom: 36px; } +.ac-hero-eyebrow { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--ink-muted); margin-bottom: 8px; +} +.ac-hero-title { + font-family: var(--font-display); + font-weight: 400; + font-size: 38px; + line-height: 1.15; + letter-spacing: -0.02em; + margin: 0 0 24px; + text-wrap: pretty; + max-width: 720px; +} +.ac-hero-title em { font-style: italic; color: var(--accent-ink); font-weight: 500; } +.ac-hero-aside { color: var(--warn); font-style: italic; font-size: 0.85em; } + +/* ── Month strip ── */ +.ac-monthstrip { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 8px; +} +.ac-monthstrip-cell { + display: flex; flex-direction: column; align-items: center; + padding: 10px 4px; + border-radius: 8px; + position: relative; +} +.ac-monthstrip-num { + font-size: 10px; color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} +.ac-monthstrip-name { + font-family: var(--font-display); + font-size: 14px; + margin-top: 2px; + color: var(--ink-soft); +} +.ac-monthstrip-cell.is-now { + background: var(--accent-soft); +} +.ac-monthstrip-cell.is-now .ac-monthstrip-num { color: var(--accent-ink); } +.ac-monthstrip-cell.is-now .ac-monthstrip-name { color: var(--accent-ink); font-weight: 500; } + +/* ── Progress meter ── */ +.ac-meter { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 18px 22px; + margin-bottom: 36px; +} +.ac-meter-head { + display: flex; justify-content: space-between; align-items: flex-start; + margin-bottom: 18px; +} +.ac-meter-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); } +.ac-meter-num { font-family: var(--font-display); font-size: 32px; line-height: 1; margin-top: 4px; letter-spacing: -0.02em; } +.ac-meter-done { font-weight: 500; } +.ac-meter-of { color: var(--ink-muted); font-weight: 400; } +.ac-meter-pct { font-family: var(--font-display); font-size: 44px; font-weight: 500; color: var(--accent-ink); letter-spacing: -0.03em; line-height: 1; } +.ac-meter-pct-sym { font-size: 0.5em; vertical-align: top; margin-left: 2px; opacity: 0.7; } + +.ac-meter-bar { + display: flex; + gap: 10px; +} +.ac-meter-seg { + background: var(--surface-alt); + border-radius: 8px; + padding: 10px 12px; + position: relative; + overflow: hidden; + min-width: 0; +} +.ac-meter-seg-fill { + position: absolute; left: 0; top: 0; bottom: 0; + opacity: 0.18; + border-right: 2px solid currentColor; + z-index: 0; +} +.ac-meter-seg-label { + position: relative; z-index: 1; + display: flex; align-items: center; gap: 6px; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--ink-soft); + white-space: nowrap; +} +.ac-meter-seg-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; } +.ac-meter-seg-frac { margin-left: auto; color: var(--ink-muted); font-variant-numeric: tabular-nums; letter-spacing: 0.04em; } + +/* ── Shelf (cards row) ── */ +.ac-shelf { margin-bottom: 36px; } +.ac-shelf-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; } +.ac-shelf-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); } +.ac-shelf-eyebrow-warn { color: var(--warn); } +.ac-shelf-title { font-family: var(--font-display); font-weight: 500; font-size: 24px; margin: 4px 0 0; letter-spacing: -0.01em; } +.ac-shelf-count { font-family: var(--font-display); font-size: 28px; color: var(--ink-muted); font-weight: 400; } + +.ac-cards { + display: grid; grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +.ac-card { + display: flex; gap: 14px; align-items: flex-start; + padding: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + text-align: left; + transition: border-color 0.12s, transform 0.12s; +} +.ac-card:hover { border-color: var(--border-strong); transform: translateY(-1px); } +.ac-card-glyph { + width: 44px; height: 44px; + border: 1.5px solid; + border-radius: 10px; + display: grid; place-items: center; + font-family: var(--font-display); font-size: 14px; font-weight: 500; + flex: none; + background: repeating-linear-gradient(135deg, transparent 0 4px, currentColor 4px 5px); + background-blend-mode: overlay; +} +.ac-card-glyph::before { + content: attr(data-i); +} +.ac-card-body { flex: 1; min-width: 0; } +.ac-card-name { font-weight: 500; color: var(--ink); margin-bottom: 2px; } +.ac-card-meta { font-size: 12px; color: var(--ink-soft); margin-bottom: 8px; text-transform: capitalize; } +.ac-card-warn { color: var(--warn); font-size: 18px; } + +/* ── Month dots (in cards) ── */ +.ac-monthdots { display: flex; gap: 3px; } +.ac-monthdot { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--surface-alt); + border: 1px solid var(--border); +} +.ac-monthdot.on { background: var(--accent); border-color: var(--accent); opacity: 0.6; } +.ac-monthdot.here.on { opacity: 1; box-shadow: 0 0 0 1.5px var(--accent-soft); } +.ac-monthdot.here:not(.on) { border-color: var(--ink-muted); } + +/* ── Activity ── */ +.ac-activity { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.ac-activity-row { + display: grid; + grid-template-columns: 12px 1fr auto auto; + gap: 12px; align-items: center; + padding: 12px 18px; + border-bottom: 1px solid var(--border); +} +.ac-activity-row:last-child { border-bottom: none; } +.ac-activity-dot { width: 8px; height: 8px; border-radius: 50%; } +.ac-activity-name { color: var(--ink); } +.ac-activity-cat { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-activity-when { font-size: 12px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } + +/* ── Category groups ── */ +.ac-category-head { + display: flex; justify-content: space-between; align-items: flex-end; + margin-bottom: 24px; padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.ac-category-title { + font-family: var(--font-display); font-weight: 400; + font-size: 44px; letter-spacing: -0.02em; margin: 0; +} +.ac-category-title em { font-style: italic; color: var(--accent-ink); } +.ac-category-meta { font-size: 13px; color: var(--ink-soft); text-align: right; } +.ac-category-meta strong { font-family: var(--font-display); font-weight: 500; color: var(--ink); font-size: 18px; display: block; } + +.ac-group { margin-bottom: 28px; } +.ac-group-head { + display: flex; align-items: baseline; gap: 12px; + margin-bottom: 8px; + padding: 0 4px; +} +.ac-group-title { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; + font-weight: 600; + margin: 0; + color: var(--ink-soft); +} +.ac-group-warn .ac-group-title { color: var(--warn); } +.ac-group-accent .ac-group-title { color: var(--accent-ink); } +.ac-group-done .ac-group-title { color: var(--ink-muted); } +.ac-group-count { font-size: 12px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } + +.ac-list { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} + +/* ── Item row ── */ +.ac-row { border-bottom: 1px solid var(--border); } +.ac-row:last-child { border-bottom: none; } +.ac-row-main { + display: flex; gap: 14px; align-items: center; + width: 100%; + padding: 12px 18px; + text-align: left; + transition: background 0.12s; +} +.ac-row-main:hover { background: var(--surface-alt); } +.ac-row-expanded > .ac-row-main { background: var(--surface-alt); } +.ac-row-donated .ac-row-name { color: var(--ink-muted); } +.ac-row-donated .ac-row-name span:first-child { text-decoration: line-through; text-decoration-color: var(--border-strong); text-decoration-thickness: 1px; } + +.ac-glyph { + width: 32px; height: 32px; + border: 1.5px solid; + border-radius: 8px; + display: grid; place-items: center; + font-family: var(--font-display); + font-size: 11px; font-weight: 500; + flex: none; +} +.ac-row-text { flex: 1; min-width: 0; } +.ac-row-name { + display: flex; align-items: center; gap: 8px; + font-weight: 500; +} +.ac-row-checkmark { color: var(--accent); font-size: 8px; } +.ac-row-meta { + display: flex; gap: 0; font-size: 12px; color: var(--ink-muted); + margin-top: 2px; + flex-wrap: wrap; +} +.ac-row-meta-bit:not(:last-child)::after { content: "·"; margin: 0 8px; color: var(--border-strong); } +.ac-row-meta-italic { font-style: italic; font-family: var(--font-display); } +.ac-row-meta-bells { color: var(--ink-soft); font-variant-numeric: tabular-nums; } +.ac-row-side { display: flex; align-items: center; gap: 10px; flex: none; } +.ac-row-time { font-size: 11px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } +.ac-chevron { color: var(--ink-muted); font-size: 18px; transition: transform 0.18s; line-height: 1; } +.ac-chevron-open { transform: rotate(90deg); color: var(--ink); } + +/* ── Pills ── */ +.ac-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.ac-pill-warn { background: var(--warn-soft); color: var(--warn); } +.ac-pill-accent { background: var(--accent-soft); color: var(--accent-ink); } + +/* ── Expand panel ── */ +.ac-expand { + display: grid; + grid-template-columns: 1fr 240px; + gap: 28px; + padding: 4px 18px 22px 64px; + background: var(--surface-alt); + border-top: 1px solid var(--border); +} +.ac-expand-section { } +.ac-expand-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--ink-muted); margin-bottom: 10px; margin-top: 14px; +} +.ac-monthgrid { + display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; +} +.ac-monthcell { + aspect-ratio: 1; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + display: grid; place-items: center; + position: relative; +} +.ac-monthcell-label { + font-size: 10px; color: var(--ink-muted); + font-family: var(--font-display); +} +.ac-monthcell.on { background: var(--accent-soft); border-color: var(--accent); } +.ac-monthcell.on .ac-monthcell-label { color: var(--accent-ink); } +.ac-monthcell.here { box-shadow: inset 0 0 0 1.5px var(--accent); } +.ac-monthcell.here .ac-monthcell-label { font-weight: 600; } + +.ac-expand-side { + display: flex; flex-direction: column; gap: 10px; + padding-top: 14px; +} +.ac-stat { + display: flex; flex-direction: column; gap: 1px; +} +.ac-stat-num { font-family: var(--font-display); font-size: 22px; font-weight: 500; letter-spacing: -0.01em; } +.ac-stat-num-text { font-size: 16px; } +.ac-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-note { + font-size: 12px; color: var(--ink-soft); font-style: italic; + font-family: var(--font-display); + padding: 8px 10px; + background: var(--surface); + border-left: 2px solid var(--accent); + border-radius: 4px; +} +.ac-donate-btn { + margin-top: auto; + padding: 10px 14px; + border-radius: 8px; + background: var(--accent); + color: var(--surface); + font-weight: 500; + font-size: 13px; + transition: opacity 0.12s; +} +.ac-donate-btn:hover { opacity: 0.88; } +.ac-donate-btn-on { background: var(--surface); color: var(--accent-ink); border: 1px solid var(--border-strong); } + +.ac-empty { + padding: 60px 0; text-align: center; + color: var(--ink-muted); + font-style: italic; + font-family: var(--font-display); +} + +/* ── Stats ── */ +.ac-stats-grid { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; + margin-bottom: 36px; +} +.ac-statcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; +} +.ac-statcard-cat { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; } +.ac-statcard-num { font-family: var(--font-display); font-size: 32px; margin-top: 4px; letter-spacing: -0.02em; } +.ac-statcard-of { color: var(--ink-muted); font-size: 0.6em; margin-left: 4px; } +.ac-statcard-bar { height: 4px; background: var(--surface-alt); border-radius: 2px; overflow: hidden; margin-top: 10px; } +.ac-statcard-fill { height: 100%; } +.ac-statcard-pct { font-size: 11px; color: var(--ink-muted); margin-top: 6px; } + +.ac-chartcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 22px; +} +.ac-chart { + display: grid; grid-template-columns: repeat(12, 1fr); + gap: 6px; + height: 180px; + margin-top: 18px; + align-items: end; +} +.ac-chart-col { display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; } +.ac-chart-bar { flex: 1; width: 100%; display: flex; align-items: flex-end; } +.ac-chart-bar-bg { + width: 100%; + background: var(--surface-alt); + border-radius: 6px 6px 0 0; + position: relative; + overflow: hidden; + border: 1px solid var(--border); + border-bottom: none; +} +.ac-chart-bar-fill { + position: absolute; left: 0; right: 0; bottom: 0; + background: var(--accent); + opacity: 0.5; +} +.ac-chart-num { font-size: 11px; color: var(--ink-soft); font-variant-numeric: tabular-nums; } +.ac-chart-month { font-size: 11px; color: var(--ink-muted); font-family: var(--font-display); } +.ac-chart-col.is-now .ac-chart-bar-bg { border-color: var(--accent); } +.ac-chart-col.is-now .ac-chart-month { color: var(--accent-ink); font-weight: 600; } +.ac-chart-legend { + display: flex; gap: 18px; + font-size: 11px; color: var(--ink-muted); + margin-top: 14px; padding-top: 14px; + border-top: 1px solid var(--border); +} +.ac-chart-legend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; vertical-align: middle; } +.ac-chart-legend-dot-bg { background: var(--surface-alt); border: 1px solid var(--border); } +.ac-chart-legend-dot-fill { background: var(--accent); opacity: 0.5; } + +/* ── Theme: parchment forces Varela ── */ +[data-theme="parchment"] .ac-hero-title em, +[data-theme="parchment"] .ac-category-title em { + font-style: normal; +} + +/* ── Responsive ── */ +@media (max-width: 980px) { + .ac-app { grid-template-columns: 1fr; } + .ac-sidebar { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); } + .ac-main { padding: 24px 20px 60px; } + .ac-cards { grid-template-columns: 1fr; } + .ac-stats-grid { grid-template-columns: repeat(2, 1fr); } + .ac-meter-bar { flex-wrap: wrap; } + .ac-expand { grid-template-columns: 1fr; padding-left: 18px; } + .ac-hero-title { font-size: 28px; } + .ac-monthstrip-name { font-size: 12px; } +} diff --git a/docs/design-handoffs/v0.9.1_curator/tabs.jsx b/docs/design-handoffs/v0.9.1_curator/tabs.jsx new file mode 100644 index 0000000..8f311e2 --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/tabs.jsx @@ -0,0 +1,265 @@ +/* global React, ACComponents, ACShell */ +const { useState, useMemo } = React; +const { ItemRow, Pill, MonthDots, MONTHS, MONTHS_LONG } = ACComponents; +const { MonthStrip, ProgressMeter } = ACShell; + +// ── Home tab ── +function HomeTab({ data, donated, currentMonth, onJump, onToggle, showLeavingShelf = true, showNewShelf = true }) { + const allItems = useMemo(() => [ + ...data.fish.map(x=>({...x, _cat:"fish"})), + ...data.bugs.map(x=>({...x, _cat:"bugs"})), + ], [data]); + + const leavingSoon = allItems.filter(it => + it.months && it.months.includes(currentMonth) && + !it.months.includes(currentMonth === 12 ? 1 : currentMonth+1) && + !donated[it._cat].has(it.id) + ); + + const newThisMonth = allItems.filter(it => + it.months && it.months.includes(currentMonth) && + !it.months.includes(currentMonth === 1 ? 12 : currentMonth-1) && + !donated[it._cat].has(it.id) + ); + + const stillNeeded = allItems.filter(it => + it.months && it.months.includes(currentMonth) && !donated[it._cat].has(it.id) + ).length; + + return ( +
+
+
Available in {MONTHS_LONG[currentMonth-1]}
+

+ {stillNeeded} creatures still to donate this month. + {leavingSoon.length > 0 && <>
{leavingSoon.length} are leaving soon.} +

+ +
+ + + + {showLeavingShelf && leavingSoon.length > 0 && ( +
+
+
+
Leaving end of month
+

Catch these before {MONTHS_LONG[currentMonth-1]} ends

+
+ {leavingSoon.length} +
+
+ {leavingSoon.slice(0,6).map(it => ( + + ))} +
+
+ )} + + {showNewShelf && newThisMonth.length > 0 && ( +
+
+
+
Just arrived
+

New this month

+
+ {newThisMonth.length} +
+
+ {newThisMonth.slice(0,6).map(it => ( + + ))} +
+
+ )} + +
+
+
+
Recent
+

Latest donations

+
+
+
+ {data.recentActivity.map((a,i) => ( +
+
+
{a.item}
+
{a.category}
+
{a.when}
+
+ ))} +
+
+
+ ); +} + +// ── Category tab (sectioned list) ── +function CategoryTab({ category, items, donated, onToggle, currentMonth, search }) { + const [expanded, setExpanded] = useState(null); + + const lowered = search.trim().toLowerCase(); + const filtered = lowered + ? items.filter(i => i.name.toLowerCase().includes(lowered) || (i.basedOn||"").toLowerCase().includes(lowered)) + : items; + + const isAvail = (it) => it.months ? it.months.includes(currentMonth) : true; + const isLeavingSoon = (it) => it.months && it.months.includes(currentMonth) && + !it.months.includes(currentMonth===12?1:currentMonth+1); + + const groups = useMemo(() => { + const leaving = [], avail = [], done = [], locked = []; + for (const it of filtered) { + const isDonated = donated.has(it.id); + if (isDonated) done.push(it); + else if (isLeavingSoon(it)) leaving.push(it); + else if (isAvail(it)) avail.push(it); + else locked.push(it); + } + const byName = (a,b)=>a.name.localeCompare(b.name); + return [ + { id: "leaving", label: "Leaving this month", items: leaving.sort(byName), tone: "warn" }, + { id: "avail", label: "Available now", items: avail.sort(byName), tone: "accent" }, + { id: "locked", label: "Out of season", items: locked.sort(byName), tone: "muted" }, + { id: "done", label: "Already donated", items: done.sort(byName), tone: "done" }, + ].filter(g => g.items.length > 0); + }, [filtered, donated, currentMonth]); + + return ( +
+ {groups.map(g => ( +
+
+

{g.label}

+ {g.items.length} +
+
+ {g.items.map(it => ( + onToggle(it.id)} + currentMonth={currentMonth} + expanded={expanded === it.id} + onExpand={()=>setExpanded(expanded===it.id?null:it.id)} + /> + ))} +
+
+ ))} + {groups.length === 0 && ( +
No matches for "{search}"
+ )} +
+ ); +} + +// ── Stats tab ── +function StatsTab({ data, donated, currentMonth }) { + const counts = { + fish: { donated: donated.fish.size, total: data.fish.length }, + bugs: { donated: donated.bugs.size, total: data.bugs.length }, + fossils: { donated: donated.fossils.size, total: data.fossils.length }, + art: { donated: donated.art.size, total: data.art.length }, + }; + + // monthly bars: how many fish+bugs available per month + const monthlyAvail = MONTHS.map((_, i) => { + const m = i+1; + let avail = 0, donatedCount = 0; + for (const cat of ["fish","bugs"]) { + for (const it of data[cat]) { + if (it.months && it.months.includes(m)) { + avail++; + if (donated[cat].has(it.id)) donatedCount++; + } + } + } + return { avail, donatedCount, m }; + }); + const maxAvail = Math.max(...monthlyAvail.map(x=>x.avail)); + + return ( +
+
+ {Object.entries(counts).map(([k,v]) => ( +
+
{k}
+
+ {v.donated} + / {v.total} +
+
+
+
+
{Math.round((v.donated/v.total)*100)}% complete
+
+ ))} +
+ +
+
+
+
Yearly rhythm
+

Fish & bug availability by month

+
+
+
+ {monthlyAvail.map(({avail, donatedCount, m}, i) => ( +
+
+
+
+
+
+
{avail}
+
{MONTHS[i]}
+
+ ))} +
+
+ Available + Already donated +
+
+
+ ); +} + +window.ACTabs = { HomeTab, CategoryTab, StatsTab }; diff --git a/docs/design-handoffs/v0.9.1_curator/theme.js b/docs/design-handoffs/v0.9.1_curator/theme.js new file mode 100644 index 0000000..3d903b1 --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/theme.js @@ -0,0 +1,105 @@ +// Theme tokens. Exposed as CSS variables on :root via setTheme(). +window.THEMES = { + meadow: { + label: "Meadow", + bg: "#F4EFE3", + surface: "#FFFDF7", + surfaceAlt: "#F8F2E2", + ink: "#23241F", + inkSoft: "#5C5848", + inkMuted: "#8A8470", + border: "#E2D9C3", + borderStrong: "#CFC4A8", + accent: "oklch(0.55 0.09 150)", // moss + accentSoft: "oklch(0.55 0.09 150 / 0.12)", + accentInk: "oklch(0.32 0.06 150)", + warn: "oklch(0.62 0.12 50)", // clay + warnSoft: "oklch(0.62 0.12 50 / 0.14)", + chipFish: "oklch(0.62 0.08 230)", + chipBugs: "oklch(0.6 0.1 130)", + chipFossils: "oklch(0.55 0.06 60)", + chipArt: "oklch(0.58 0.08 320)", + chipSea: "oklch(0.58 0.09 200)", + fontDisplay: "'Fraunces', 'Playfair Display', Georgia, serif", + fontUi: "'Inter', system-ui, -apple-system, sans-serif", + }, + parchment: { + label: "Parchment (current)", + bg: "#EFE6D0", + surface: "#F5E9D4", + surfaceAlt: "#EADCBE", + ink: "#2A2A2A", + inkSoft: "#5a4a35", + inkMuted: "#8B7A5C", + border: "#E0D2B0", + borderStrong: "#C9B98D", + accent: "#3CA370", + accentSoft: "rgba(60,163,112,0.14)", + accentInk: "#1F5A3C", + warn: "#C76B3F", + warnSoft: "rgba(199,107,63,0.16)", + chipFish: "#3F6FA8", + chipBugs: "#7B9C3A", + chipFossils: "#7B5E3B", + chipArt: "#8B5E94", + chipSea: "#3D8B96", + fontDisplay: "'Varela Round', system-ui, sans-serif", + fontUi: "'Varela Round', system-ui, sans-serif", + }, + midnight: { + label: "Midnight", + bg: "#16181F", + surface: "#1E212A", + surfaceAlt: "#262A35", + ink: "#EFEAE0", + inkSoft: "#B5AE9E", + inkMuted: "#7E7866", + border: "#2F3340", + borderStrong: "#3D4250", + accent: "oklch(0.7 0.09 150)", + accentSoft: "oklch(0.7 0.09 150 / 0.16)", + accentInk: "oklch(0.85 0.06 150)", + warn: "oklch(0.72 0.12 50)", + warnSoft: "oklch(0.72 0.12 50 / 0.18)", + chipFish: "oklch(0.72 0.09 230)", + chipBugs: "oklch(0.74 0.09 130)", + chipFossils: "oklch(0.7 0.06 60)", + chipArt: "oklch(0.72 0.08 320)", + chipSea: "oklch(0.74 0.08 200)", + fontDisplay: "'Fraunces', Georgia, serif", + fontUi: "'Inter', system-ui, sans-serif", + }, + sakura: { + label: "Sakura", + bg: "#FBF0EE", + surface: "#FFFCFB", + surfaceAlt: "#F7E5E1", + ink: "#2C2228", + inkSoft: "#705661", + inkMuted: "#9C8590", + border: "#EFD9D3", + borderStrong: "#DEBDB4", + accent: "oklch(0.6 0.11 10)", // rose + accentSoft: "oklch(0.6 0.11 10 / 0.12)", + accentInk: "oklch(0.4 0.09 10)", + warn: "oklch(0.6 0.13 50)", + warnSoft: "oklch(0.6 0.13 50 / 0.14)", + chipFish: "oklch(0.6 0.08 230)", + chipBugs: "oklch(0.6 0.09 130)", + chipFossils: "oklch(0.55 0.06 60)", + chipArt: "oklch(0.58 0.08 320)", + chipSea: "oklch(0.6 0.08 200)", + fontDisplay: "'Fraunces', Georgia, serif", + fontUi: "'Inter', system-ui, sans-serif", + }, +}; + +window.applyTheme = function applyTheme(name) { + const t = window.THEMES[name] || window.THEMES.meadow; + const root = document.documentElement; + for (const [k, v] of Object.entries(t)) { + if (k === "label") continue; + root.style.setProperty(`--${k.replace(/([A-Z])/g, "-$1").toLowerCase()}`, v); + } + root.dataset.theme = name; +}; diff --git a/docs/design-handoffs/v0.9.1_curator/tweaks-panel.jsx b/docs/design-handoffs/v0.9.1_curator/tweaks-panel.jsx new file mode 100644 index 0000000..5f8f95a --- /dev/null +++ b/docs/design-handoffs/v0.9.1_curator/tweaks-panel.jsx @@ -0,0 +1,425 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/docs/design-handoffs/v0.9.2_curator/Curator.html b/docs/design-handoffs/v0.9.2_curator/Curator.html new file mode 100644 index 0000000..dabe78c --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/Curator.html @@ -0,0 +1,296 @@ + + + + + +Curator — AC Museum Tracker + + + + +
+ + + + + + + + + + + + + + + + + + diff --git a/docs/design-handoffs/v0.9.2_curator/README.md b/docs/design-handoffs/v0.9.2_curator/README.md new file mode 100644 index 0000000..6e99f6e --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/README.md @@ -0,0 +1,147 @@ +# Curator v0.9.2 — pass 3 delta + +Three design items resolved on top of v0.9.1. **Read v0.9 + v0.9.1 READMEs first** for the foundational design language; this doc only covers what's new. + +--- + +## 1. ProgressMeter — 5-segment for ACNL/ACNH + +**File:** `shell.jsx` → `ProgressMeter` + +Now derives its segment list from `gameId`: +- ACGCN / ACWW / ACCF → 4 segments (fish, bugs, fossils, art) +- ACNL / ACNH → **5 segments** (+ sea, color `--chip-sea`) + +Both the segment fill **and** the label-row dot use `--chip-sea` — confirmed. + +### Responsive behavior + +The 5-segment bar is tighter, so we shed details progressively as width drops. Container width here = the main content column (sidebar adds ~248px). + +| Viewport | 5-seg behavior | +|---|---| +| ≥1180px | Full labels: dot · name · `n/total` fraction. 8px gap (vs. 10px on 4-seg). | +| 980–1180px | Drop the fractions; keep dot + name. Tighter padding (7px 8px). | +| ≤980px | **Wrap to 2 rows**: 2.5 segments per row at `flex-basis: calc(50% - 3px)`. Fractions return because there's room again. | + +Implemented as `.ac-meter-5` modifier class; 4-seg is unchanged. + +### What to verify + +- [ ] At ~1280px viewport: 5 labels read cleanly, sea dot uses `--chip-sea`. +- [ ] At ~1100px: fractions hidden, names + dots stay legible. +- [ ] At mobile (~390px): 5 segments wrap to 2 rows, visually balanced. + +--- + +## 2. Scroll-to + highlight on jump + +**Files:** `components.jsx` (`ItemRow`), `tabs.jsx` (`CategoryTab`), `Curator.html` (App) + +### Spec + +When a Home shelf card or global search result is clicked, the target row is: +1. **Scrolled into view** — `scrollIntoView({ behavior: 'smooth', block: 'center' })` +2. **Auto-expanded** — the inline detail panel opens, so the user sees full context (months, value, donate button) without a second click. +3. **Highlighted** — `accent-soft` background pulse via the `.ac-row-pulse` keyframe, **1.4s ease-out**, then fades to the row's normal expanded background (`--surface-alt`). + +Behavior is identical on mobile (≤980px). Smooth scroll + 1.4s pulse work fine at small widths; no separate codepath needed. + +### Wiring + +- `App` owns `highlightId` state. +- Setters: `jumpTo(cat, id)` (Home cards) and `onJump` in `GlobalSearchDropdown` both `setTab(cat) + setHighlightId(id)`. +- `CategoryTab` receives `highlightId` + `onHighlightConsumed`. On change, it sets `expanded = highlightId`, waits a frame for the row to render, then queries `[data-row-id="…"]`, scrolls + adds `.ac-row-pulse`, and clears the parent state via `onHighlightConsumed` so re-clicking the same item triggers the effect again. +- `ItemRow` now stamps `data-row-id={item.id}` on its outer div. + +### CSS + +```css +.ac-row-pulse > .ac-row-main { animation: ac-row-pulse 1.4s ease-out; } +@keyframes ac-row-pulse { + 0% { background: var(--accent-soft); } + 60% { background: color-mix(in oklch, var(--accent) 16%, transparent); } + 100% { background: var(--surface-alt); } +} +``` + +--- + +## 3. Settings page + +**File:** `addons.jsx` → `SettingsPage`. Styles in `addons-styles.css`. + +### Navigation pattern + +**Full-page route inside the main column** — not a modal, not a drawer. When `settingsOpen=true`, the main column renders `` instead of the active tab. The sidebar stays in place (active town card + nav still visible), and any tab click closes settings via `onTab` in `Curator.html`. + +Why full-page over modal/drawer: +- Theme cards need horizontal real estate (4-up grid at desktop). +- Danger zone needs breathing room — destructive actions deserve calm typography, not a cramped modal footer. +- About / version info reads naturally as a top-level "page" the same way Stats does. +- `Esc` still closes (consistent with modal expectation), bound inside `SettingsPage`. + +Activated by the **Settings** link in the sidebar footer. + +### Sections (in order) + +**1. Appearance — Theme switcher** + +Visual treatment: **swatch cards** (4-up auto-fill grid). Each card: +- 76px swatch row showing `surface · bg · accent · chipFish` to give a real preview. +- Name + 1-line subtitle ("Default — moss + cream", "Cozy paper + wood", etc.). +- Active state: `--accent` border + 3px `--accent-soft` halo + `●` checkmark next to the name. + +Selection persists via the existing Tweaks `theme` key — `onThemeChange={(v) => setTweak("theme", v)}`. No new persistence layer. + +I picked swatch cards over a segmented control or radio list because: +- Themes are visual; a list of words ("Meadow / Parchment / …") doesn't preview the change. +- Cards scale gracefully — adding a 5th theme later doesn't blow up the layout. +- Matches Curator's "cards on a soft surface" language elsewhere (shelf cards, town cards, stat cards). + +**2. About** + +Plain dl/dt/dd list inside a single card: +- Version (v0.9.2-alpha · Curator) +- Source (GitHub link, opens in new tab) +- Storage (live: town count + total donations) +- Credits ("Companion app for the Animal Crossing series. Not affiliated with Nintendo.") + +**3. Danger zone** + +Red-tinted card (`oklch(0.62 0.12 25)` family). Two actions stacked: +- **Reset donations for active town** — quiet ghost button. Confirms via native `confirm()`. Real implementation should use a styled confirm dialog. +- **Reset everything** — solid red button. Wipes towns, donations, and search history. + +Both actions in the demo go through `confirm()` placeholders; production should wire a proper styled confirm dialog (out of scope for this design pass). + +### Responsive + +- Theme grid auto-fills `minmax(200px, 1fr)`, collapses cleanly on phones. +- About list switches to single column at ≤700px. +- Danger rows stack with full-width buttons at ≤700px. +- Title shrinks 56→40px. + +--- + +## Confirmations applied + +- **GlobalSearchDropdown** now includes a 5th `sea` group, same row treatment as the others. Empty group is hidden, like before. +- **`basedOn` matching** confirmed in the existing search filter (`it.basedOn.toLowerCase().includes(q)`) — "Leonardo" surfaces *Famous Painting*. No change needed. +- **`playerName`** — was never referenced in the v0.9 design. Safe to deprecate from the Town type without touching the design files. + +--- + +## Files changed in this pass + +| File | What changed | +|---|---| +| `shell.jsx` | `ProgressMeter` now takes `gameId`, renders 4 or 5 segments accordingly | +| `tabs.jsx` | `HomeTab` accepts `gameId` and passes through; `CategoryTab` accepts `highlightId` + handles scroll/expand/pulse effect; sea is included in shelves for ACNH/ACNL | +| `components.jsx` | `ItemRow` stamps `data-row-id` for scroll targeting | +| `addons.jsx` | `GlobalSearchDropdown` extended to include sea; new `SettingsPage` component | +| `Curator.html` | App owns `highlightId` + `settingsOpen` state; wires `jumpTo` + sidebar Settings link | +| `styles.css` | `.ac-meter-5` responsive rules; `.ac-row-pulse` keyframe | +| `addons-styles.css` | All Settings page styles | + +No data shape changes. No new dependencies. diff --git a/docs/design-handoffs/v0.9.2_curator/addons-styles.css b/docs/design-handoffs/v0.9.2_curator/addons-styles.css new file mode 100644 index 0000000..4a9927b --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/addons-styles.css @@ -0,0 +1,409 @@ +/* ── Town Manager drawer ── */ +.ac-tm-scrim { + position: fixed; inset: 0; z-index: 9990; + background: rgba(20,18,12,0.32); + -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); + display: flex; justify-content: flex-end; + animation: ac-fade 0.18s ease; +} +@keyframes ac-fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes ac-slide { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } +.ac-tm-drawer { + width: 420px; max-width: 100vw; + background: var(--bg); + border-left: 1px solid var(--border); + display: flex; flex-direction: column; + height: 100vh; + animation: ac-slide 0.22s ease; + box-shadow: -20px 0 60px rgba(0,0,0,0.08); +} +.ac-tm-head { + display: flex; justify-content: space-between; align-items: flex-start; + padding: 22px 24px 18px; + border-bottom: 1px solid var(--border); +} +.ac-tm-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); } +.ac-tm-title { font-family: var(--font-display); font-weight: 500; font-size: 22px; margin: 4px 0 0; letter-spacing: -0.01em; max-width: 280px; } +.ac-tm-close { + width: 32px; height: 32px; border-radius: 8px; + font-size: 22px; line-height: 1; color: var(--ink-muted); +} +.ac-tm-close:hover { background: var(--surface-alt); color: var(--ink); } + +.ac-tm-list { flex: 1; overflow-y: auto; padding: 14px 18px; display: flex; flex-direction: column; gap: 6px; } + +.ac-tm-row { + display: flex; align-items: stretch; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + transition: border-color 0.12s; +} +.ac-tm-row:hover { border-color: var(--border-strong); } +.ac-tm-row-active { border-color: var(--accent); background: var(--accent-soft); } +.ac-tm-row-main { + flex: 1; display: flex; gap: 14px; align-items: center; + padding: 14px 16px; text-align: left; min-width: 0; +} +.ac-tm-row-mark { + width: 18px; height: 18px; border-radius: 50%; + border: 1.5px solid; + display: grid; place-items: center; + flex: none; +} +.ac-tm-row-tick { color: var(--accent); font-size: 9px; } +.ac-tm-row-text { flex: 1; min-width: 0; } +.ac-tm-row-name { font-family: var(--font-display); font-weight: 500; font-size: 18px; letter-spacing: -0.01em; } +.ac-tm-row-meta { + display: flex; gap: 6px; align-items: center; + font-size: 12px; color: var(--ink-soft); margin-top: 2px; + flex-wrap: wrap; +} +.ac-tm-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: var(--surface-alt); + border: 1px solid var(--border); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--ink-soft); +} +.ac-tm-row-active .ac-tm-badge { background: var(--surface); } + +.ac-tm-row-edit { + padding: 0 16px; + color: var(--ink-muted); + border-left: 1px solid var(--border); + display: grid; place-items: center; + transition: background 0.12s, color 0.12s; +} +.ac-tm-row-edit:hover { background: var(--surface-alt); color: var(--ink); } +.ac-tm-row-active .ac-tm-row-edit { border-left-color: var(--accent); } + +/* Editing state */ +.ac-tm-row-editing { + flex-direction: column; + background: var(--surface); + border-color: var(--border-strong); + padding: 18px; + gap: 14px; +} +.ac-tm-form { display: flex; flex-direction: column; gap: 12px; } +.ac-tm-field { display: flex; flex-direction: column; gap: 4px; } +.ac-tm-field-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-tm-input { + font: inherit; color: var(--ink); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + outline: none; +} +.ac-tm-input:focus { border-color: var(--accent); } + +.ac-tm-seg { + display: flex; gap: 4px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px; +} +.ac-tm-seg button { + flex: 1; padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: var(--ink-soft); + transition: background 0.12s, color 0.12s; +} +.ac-tm-seg button:hover { color: var(--ink); } +.ac-tm-seg button.ac-tm-seg-on { + background: var(--surface); + color: var(--accent-ink); + font-weight: 500; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.ac-tm-row-actions { + display: flex; justify-content: space-between; gap: 8px; + padding-top: 8px; border-top: 1px solid var(--border); +} +.ac-tm-row-actions-right { display: flex; gap: 8px; } +.ac-tm-ghost, .ac-tm-primary, .ac-tm-danger { + padding: 7px 14px; border-radius: 8px; + font-size: 13px; font-weight: 500; + transition: background 0.12s, opacity 0.12s; +} +.ac-tm-ghost { color: var(--ink-soft); } +.ac-tm-ghost:hover { background: var(--surface-alt); color: var(--ink); } +.ac-tm-primary { background: var(--accent); color: var(--surface); } +.ac-tm-primary:hover { opacity: 0.88; } +.ac-tm-primary:disabled { opacity: 0.4; cursor: not-allowed; } +.ac-tm-danger { color: var(--warn); } +.ac-tm-danger:hover { background: var(--warn-soft); } + +.ac-tm-empty { padding: 60px 20px; text-align: center; color: var(--ink-muted); } +.ac-tm-empty-glyph { + width: 56px; height: 56px; border-radius: 50%; + border: 1.5px dashed var(--border-strong); + margin: 0 auto 14px; + display: grid; place-items: center; + color: var(--ink-muted); + font-size: 24px; +} +.ac-tm-empty-title { font-family: var(--font-display); font-size: 18px; color: var(--ink); margin-bottom: 4px; } +.ac-tm-empty-sub { font-size: 13px; } + +.ac-tm-foot { padding: 14px 18px 22px; border-top: 1px solid var(--border); } +.ac-tm-cta { + width: 100%; + display: flex; align-items: center; justify-content: center; gap: 8px; + padding: 12px; + background: var(--surface); + border: 1.5px dashed var(--border-strong); + border-radius: 12px; + color: var(--ink-soft); + font-weight: 500; + font-size: 14px; + transition: border-color 0.12s, color 0.12s, background 0.12s; +} +.ac-tm-cta:hover { border-color: var(--accent); color: var(--accent-ink); background: var(--accent-soft); } +.ac-tm-cta-plus { font-size: 18px; line-height: 1; } +.ac-tm-newform { display: flex; flex-direction: column; gap: 8px; } +.ac-tm-newform-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } + +/* Mobile: bottom sheet */ +@media (max-width: 720px) { + .ac-tm-scrim { align-items: flex-end; justify-content: center; } + .ac-tm-drawer { + width: 100%; height: 88vh; + border-left: none; border-top: 1px solid var(--border); + border-radius: 16px 16px 0 0; + animation: ac-slide-up 0.22s ease; + } + @keyframes ac-slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +} + +/* ── Global Search dropdown ── */ +.ac-search-wrap { position: relative; flex: 1; max-width: 380px; } +.ac-search-wrap .ac-search { max-width: none; } +.ac-gs-panel { + position: absolute; top: calc(100% + 6px); left: 0; right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(20,18,12,0.10); + z-index: 50; + max-height: 420px; + overflow-y: auto; + padding: 8px; + animation: ac-fade 0.12s ease; +} +.ac-gs-empty { padding: 22px 14px; text-align: center; } +.ac-gs-empty-title { font-family: var(--font-display); font-size: 16px; color: var(--ink); margin-bottom: 4px; } +.ac-gs-empty-title em { font-style: italic; color: var(--accent-ink); } +.ac-gs-empty-sub { font-size: 12px; color: var(--ink-muted); } +.ac-gs-hint { margin-top: 14px; font-size: 11px; color: var(--ink-muted); } + +.ac-gs-section-head { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 10px 6px; +} +.ac-gs-eyebrow { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; font-weight: 600; color: var(--ink-muted); } +.ac-gs-clear { font-size: 11px; color: var(--ink-muted); padding: 2px 6px; border-radius: 4px; } +.ac-gs-clear:hover { background: var(--surface-alt); color: var(--ink); } + +.ac-gs-history { display: flex; flex-direction: column; gap: 1px; } +.ac-gs-history-row { + display: flex; gap: 10px; align-items: center; + padding: 8px 12px; border-radius: 8px; + font-size: 13px; color: var(--ink-soft); text-align: left; +} +.ac-gs-history-row:hover { background: var(--surface-alt); color: var(--ink); } +.ac-gs-history-icon { color: var(--ink-muted); font-size: 12px; } + +.ac-gs-group { margin-bottom: 6px; } +.ac-gs-group-head { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px 4px; +} +.ac-gs-group-dot { width: 6px; height: 6px; border-radius: 50%; } +.ac-gs-group-count { margin-left: auto; font-size: 11px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } + +.ac-gs-row { + width: 100%; + display: flex; gap: 12px; align-items: center; + padding: 8px 10px; border-radius: 8px; + text-align: left; + transition: background 0.1s; +} +.ac-gs-row:hover { background: var(--surface-alt); } +.ac-gs-row-active { background: var(--accent-soft); } +.ac-gs-row-glyph { + width: 28px; height: 28px; + border: 1.5px solid; + border-radius: 6px; + display: grid; place-items: center; + font-family: var(--font-display); font-size: 10px; font-weight: 500; + flex: none; +} +.ac-gs-row-text { flex: 1; min-width: 0; } +.ac-gs-row-name { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--ink); font-weight: 500; +} +.ac-gs-row-donated { + font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; + color: var(--accent); font-weight: 600; +} +.ac-gs-row-meta { font-size: 11px; color: var(--ink-muted); margin-top: 1px; text-transform: capitalize; } +.ac-gs-row-arrow { color: var(--ink-muted); font-size: 11px; opacity: 0; } +.ac-gs-row-active .ac-gs-row-arrow { opacity: 1; } + +.ac-gs-foot { + display: flex; gap: 14px; justify-content: center; + padding: 10px 8px 4px; + border-top: 1px solid var(--border); + margin-top: 6px; + font-size: 10px; color: var(--ink-muted); +} +kbd { + font-family: ui-monospace, monospace; + font-size: 10px; + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 4px; + margin-right: 4px; +} + + +/* ── Settings page ── */ +.ac-settings { + max-width: 920px; + margin: 0 auto; + padding: 8px 0 80px; + animation: ac-fade-up 0.28s ease; +} +@keyframes ac-fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } + +.ac-settings-head { + display: flex; justify-content: space-between; align-items: flex-start; + padding-bottom: 28px; + border-bottom: 1px solid var(--border); + margin-bottom: 36px; +} +.ac-settings-eyebrow { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.16em; + color: var(--ink-muted); margin-bottom: 8px; +} +.ac-settings-title { + font-family: var(--font-display); + font-size: 56px; font-weight: 400; line-height: 1; letter-spacing: -0.02em; + margin: 0; color: var(--ink); +} +.ac-settings-title em { + font-style: italic; color: var(--accent-ink); + font-variation-settings: 'opsz' 36; +} +.ac-settings-close { + background: var(--surface); border: 1px solid var(--border); + color: var(--ink-soft); + width: 36px; height: 36px; border-radius: 50%; + font-size: 14px; cursor: pointer; + transition: all 0.15s; +} +.ac-settings-close:hover { background: var(--surface-alt); color: var(--ink); border-color: var(--border-strong); } + +.ac-settings-section { margin-bottom: 44px; } +.ac-settings-section-head { margin-bottom: 18px; } +.ac-settings-section-title { + font-family: var(--font-display); font-size: 22px; font-weight: 500; + margin: 0; color: var(--ink); letter-spacing: -0.01em; +} +.ac-settings-section-sub { + font-size: 13px; color: var(--ink-muted); margin: 4px 0 0; +} + +/* Theme cards */ +.ac-theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; +} +.ac-theme-card { + background: var(--surface); border: 1px solid var(--border); + border-radius: 14px; padding: 0; overflow: hidden; + cursor: pointer; text-align: left; + transition: all 0.18s; + font-family: inherit; +} +.ac-theme-card:hover { border-color: var(--border-strong); transform: translateY(-1px); } +.ac-theme-card-active { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft, color-mix(in oklch, var(--accent) 18%, transparent)); +} +.ac-theme-preview { + display: flex; height: 76px; +} +.ac-theme-swatch { flex: 1; } +.ac-theme-meta { padding: 12px 14px 14px; } +.ac-theme-name { + display: flex; justify-content: space-between; align-items: center; + font-weight: 500; color: var(--ink); font-size: 15px; +} +.ac-theme-check { color: var(--accent); font-size: 8px; } +.ac-theme-sub { font-size: 12px; color: var(--ink-muted); margin-top: 2px; } + +/* Settings cards (about + danger) */ +.ac-settings-card { + background: var(--surface); border: 1px solid var(--border); + border-radius: 14px; padding: 18px 22px; +} +.ac-about-list { + display: grid; gap: 14px; margin: 0; +} +.ac-about-list > div { + display: grid; grid-template-columns: 120px 1fr; gap: 18px; + font-size: 14px; align-items: baseline; +} +.ac-about-list dt { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--ink-muted); margin: 0; +} +.ac-about-list dd { margin: 0; color: var(--ink); } +.ac-about-list a { color: var(--accent-ink); text-decoration: underline; text-decoration-color: var(--accent-soft, var(--border-strong)); text-underline-offset: 3px; } + +/* Danger zone */ +.ac-settings-danger { + border-color: oklch(0.62 0.12 25 / 0.35); + background: color-mix(in oklch, oklch(0.62 0.12 25) 4%, var(--surface)); +} +.ac-danger-row { + display: flex; justify-content: space-between; align-items: center; gap: 24px; + padding: 10px 0; +} +.ac-danger-row + .ac-danger-row { border-top: 1px solid var(--border); padding-top: 16px; margin-top: 6px; } +.ac-danger-name { font-weight: 500; color: var(--ink); font-size: 14px; } +.ac-danger-sub { font-size: 12px; color: var(--ink-muted); margin-top: 2px; } +.ac-danger-btn { + background: var(--surface); border: 1px solid oklch(0.62 0.12 25 / 0.4); + color: oklch(0.5 0.14 25); + padding: 8px 16px; border-radius: 999px; font-size: 13px; font-weight: 500; + cursor: pointer; font-family: inherit; flex: none; + transition: all 0.15s; +} +.ac-danger-btn:hover { background: oklch(0.62 0.12 25 / 0.08); border-color: oklch(0.62 0.12 25 / 0.6); } +.ac-danger-btn-strong { + background: oklch(0.5 0.14 25); color: white; border-color: transparent; +} +.ac-danger-btn-strong:hover { background: oklch(0.45 0.16 25); } + +@media (max-width: 700px) { + .ac-settings-title { font-size: 40px; } + .ac-about-list > div { grid-template-columns: 1fr; gap: 2px; } + .ac-danger-row { flex-direction: column; align-items: stretch; } + .ac-danger-btn { width: 100%; } +} diff --git a/docs/design-handoffs/v0.9.2_curator/addons.jsx b/docs/design-handoffs/v0.9.2_curator/addons.jsx new file mode 100644 index 0000000..0ce7a01 --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/addons.jsx @@ -0,0 +1,428 @@ +/* global React, ACComponents */ +const { useState, useEffect, useRef, useMemo } = React; + +// ── Town Manager: right-side drawer with switch / edit / create ── +function TownManager({ open, onClose, towns, activeTownId, onActivate, onUpdate, onCreate, onDelete }) { + const [editingId, setEditingId] = useState(null); + const [creating, setCreating] = useState(false); + const drawerRef = useRef(null); + + useEffect(() => { + if (!open) { setEditingId(null); setCreating(false); } + }, [open]); + + // ESC to close + useEffect(() => { + if (!open) return; + const onKey = (e) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ +
+ ); +} + +const GAMES = [ + { id: "acgcn", label: "GameCube", short: "GCN" }, + { id: "acww", label: "Wild World", short: "WW" }, + { id: "accf", label: "City Folk", short: "CF" }, + { id: "acnl", label: "New Leaf", short: "NL" }, + { id: "acnh", label: "New Horizons", short: "NH" }, +]; + +function TownRow({ town, active, editing, onActivate, onEditStart, onEditCancel, onSave, onDelete, canDelete }) { + const [name, setName] = useState(town.name); + const [gameId, setGameId] = useState(town.gameId); + const [hemisphere, setHemisphere] = useState(town.hemisphere || "NH"); + useEffect(() => { setName(town.name); setGameId(town.gameId); setHemisphere(town.hemisphere || "NH"); }, [town, editing]); + + const game = GAMES.find(g => g.id === town.gameId); + + if (editing) { + return ( +
+
+ + + {gameId === "acnh" && ( + + )} +
+
+ {canDelete && ( + + )} +
+ + +
+
+
+ ); + } + + return ( +
+ + +
+ ); +} + +function NewTownForm({ onCancel, onCreate }) { + const [name, setName] = useState(""); + const [gameId, setGameId] = useState("acnh"); + const [hemisphere, setHemisphere] = useState("NH"); + return ( +
+ setName(e.target.value)} /> + + {gameId === "acnh" && ( +
+ + +
+ )} +
+ + +
+
+ ); +} + +// ── Global Search Dropdown ── +const SEARCH_HISTORY_KEY = "ac-curator-search-history"; +function loadHistory() { try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || "[]"); } catch { return []; } } +function saveHistory(arr) { try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(arr.slice(0, 8))); } catch {} } + +function GlobalSearchDropdown({ query, data, donated, onJump, onClose }) { + const [history, setHistory] = useState(loadHistory()); + const [activeIdx, setActiveIdx] = useState(0); + + const grouped = useMemo(() => { + if (!query.trim()) return null; + const q = query.trim().toLowerCase(); + const g = { fish:[], bugs:[], fossils:[], art:[], sea:[] }; + for (const cat of Object.keys(g)) { + if (!data[cat]) continue; + g[cat] = data[cat].filter(it => + it.name.toLowerCase().includes(q) || + (it.basedOn||"").toLowerCase().includes(q) + ).slice(0, 5); + } + return g; + }, [query, data]); + + const flatList = useMemo(() => { + if (!grouped) return []; + return ["fish","bugs","fossils","art","sea"].flatMap(cat => grouped[cat].map(it => ({...it, _cat:cat}))); + }, [grouped]); + + useEffect(() => { setActiveIdx(0); }, [query]); + + useEffect(() => { + const onKey = (e) => { + if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx(i => Math.min(i+1, flatList.length-1)); } + else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx(i => Math.max(i-1, 0)); } + else if (e.key === "Enter" && flatList[activeIdx]) { + const it = flatList[activeIdx]; + commitSearch(it.name); + onJump(it._cat, it.id); + } else if (e.key === "Escape") { onClose(); } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [flatList, activeIdx, onJump, onClose]); + + function commitSearch(term) { + const next = [term, ...history.filter(h => h !== term)].slice(0, 8); + setHistory(next); saveHistory(next); + } + + function clearHistory() { setHistory([]); saveHistory([]); } + + // Empty-input state: recent searches + if (!query.trim()) { + if (history.length === 0) { + return ( +
+
+
Search across categories
+
Type a name to find fish, bugs, fossils, or art at once.
+
↑↓ navigate · open · esc close
+
+
+ ); + } + return ( +
+
+ Recent searches + +
+
+ {history.map((h,i) => ( + + ))} +
+
+ ); + } + + // No matches + const total = flatList.length; + if (total === 0) { + return ( +
+
+
No matches for "{query}"
+
Try a shorter term or check the spelling.
+
+
+ ); + } + + // Grouped results + let rowIdx = -1; + return ( +
+ {["fish","bugs","fossils","art","sea"].map(cat => { + const items = grouped[cat]; + if (items.length === 0) return null; + return ( +
+
+ + {cat} + {items.length} +
+ {items.map(it => { + rowIdx++; + const isActive = rowIdx === activeIdx; + const isDonated = donated[cat].has(it.id); + return ( + + ); + })} +
+ ); + })} +
+ ↑↓ navigate + open + esc close +
+
+ ); +} + +// ── Settings page ── +// Full-page route inside the main column (not a modal). Curator language: large +// title, eyebrow + sectioned cards, swatch tiles for theme. Esc closes. +function SettingsPage({ open, onClose, theme, onThemeChange, onResetDonations, onResetAll, townCount, totalDonations }) { + useEffect(() => { + if (!open) return; + const onKey = (e) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + const themes = [ + { id: "meadow", label: "Meadow", sub: "Default — moss + cream", swatches: ["#FFFDF7","#F4EFE3","oklch(0.55 0.09 150)","oklch(0.62 0.08 230)"] }, + { id: "parchment", label: "Parchment", sub: "Cozy paper + wood", swatches: ["#F5E9D4","#EFE6D0","#3CA370","#7B5E3B"] }, + { id: "midnight", label: "Midnight", sub: "Low-light reading", swatches: ["#1E212A","#16181F","oklch(0.7 0.12 165)","oklch(0.7 0.1 230)"] }, + { id: "sakura", label: "Sakura", sub: "Spring blossom", swatches: ["#FFFCFB","#FBF0EE","oklch(0.62 0.13 5)","oklch(0.62 0.1 250)"] }, + ]; + + return ( +
+
+
+
Curator
+

Settings

+
+ +
+ +
+
+

Appearance

+

Pick a palette. Choice persists across sessions.

+
+
+ {themes.map(t => ( + + ))} +
+
+ +
+
+

About

+
+
+
+
Version
v0.9.2-alpha · Curator
+ +
Storage
localStorage · {townCount} {townCount===1?"town":"towns"} · {totalDonations} donations
+
Credits
Companion app for the Animal Crossing series. Not affiliated with Nintendo.
+
+
+
+ +
+
+

Danger zone

+

These actions cannot be undone.

+
+
+
+
+
Reset donations for active town
+
Clears all donation marks. Towns themselves are kept.
+
+ +
+
+
+
Reset everything
+
Deletes all towns, donations, and search history.
+
+ +
+
+
+
+ ); +} + +window.ACAddOns = { TownManager, GlobalSearchDropdown, SettingsPage, GAMES }; diff --git a/docs/design-handoffs/v0.9.2_curator/components.jsx b/docs/design-handoffs/v0.9.2_curator/components.jsx new file mode 100644 index 0000000..091fd2e --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/components.jsx @@ -0,0 +1,127 @@ +/* global React */ +const { useState, useMemo } = React; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +// ── Glyph: a simple monogram tile, category-tinted. No copyrighted sprites. ── +function Glyph({ name, category, donated }) { + const initials = name.split(/\s|-/).filter(Boolean).slice(0, 2).map((s) => s[0]).join("").toUpperCase(); + const tint = `var(--chip-${category})`; + return ( +
+ {initials} +
); + +} + +function Pill({ children, tone = "default", size = "sm" }) { + return {children}; +} + +function MonthDots({ months, current }) { + return ( +
+ {Array.from({ length: 12 }, (_, i) => { + const m = i + 1; + const on = months.includes(m); + const here = m === current; + return ; + })} +
); + +} + +function MonthGrid({ months, current }) { + return ( +
+ {MONTHS.map((m, i) => { + const on = months.includes(i + 1); + const here = i + 1 === current; + return ( +
+ {m} +
); + + })} +
); + +} + +// ── Item row with inline expand panel ── +function ItemRow({ item, category, donated, onToggle, currentMonth, expanded, onExpand }) { + const leavingSoon = item.months && item.months.includes(currentMonth) && !item.months.includes(currentMonth === 12 ? 1 : currentMonth + 1); + const newThisMonth = item.months && item.months.includes(currentMonth) && !item.months.includes(currentMonth === 1 ? 12 : currentMonth - 1); + + return ( +
+ + {expanded && +
+ {item.months && +
+
Available in
+ +
+ } +
+ {item.value != null && +
+
{item.value.toLocaleString()}
+
bells · sell value
+
+ } + {item.shadow && +
+
{item.shadow}
+
shadow size
+
+ } + {item.time && +
+
{item.time}
+
active hours
+
+ } + {item.notes && +
{item.notes}
+ } + +
+
+ } +
); + +} + +window.ACComponents = { Glyph, Pill, MonthDots, MonthGrid, ItemRow, MONTHS, MONTHS_LONG }; \ No newline at end of file diff --git a/docs/design-handoffs/v0.9.2_curator/data.js b/docs/design-handoffs/v0.9.2_curator/data.js new file mode 100644 index 0000000..4cf64aa --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/data.js @@ -0,0 +1,121 @@ +// Sample museum data inspired by AC GCN/NH species lists. +// Real-feeling enough for a prototype; no copyrighted sprite assets. +window.MUSEUM_DATA = { + meta: { + townName: "Marigold", + playerName: "Bea", + game: "New Horizons", + hemisphere: "NH", + currentMonth: 5, // May + currentDay: 2, + }, + fish: [ + { id: "loach", name: "Loach", value: 400, habitat: "river", shadow: "small", time: "all day", months: [3,4,5] }, + { id: "angelfish", name: "Angelfish", value: 3000, habitat: "river", shadow: "small", time: "4pm–9am", months: [5,6,7,8,9,10] }, + { id: "cherry-salmon", name: "Cherry Salmon", value: 1000, habitat: "river", shadow: "medium", time: "4pm–9am", months: [3,4,5,6,9,10,11] }, + { id: "barbel-steed", name: "Barbel Steed", value: 200, habitat: "river", shadow: "medium", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "barred-knifejaw", name: "Barred Knifejaw", value: 5000, habitat: "ocean", shadow: "medium", time: "all day", months: [3,4,5,6,7,8,9,10,11] }, + { id: "bass", name: "Black Bass", value: 400, habitat: "river", shadow: "large", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "bluegill", name: "Bluegill", value: 180, habitat: "river", shadow: "small", time: "9am–4pm", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "brook-trout", name: "Brook Trout", value: 150, habitat: "river-clifftop", shadow: "small", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "carp", name: "Carp", value: 300, habitat: "pond", shadow: "medium", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "cherry-shrimp", name: "Cherry Shrimp", value: 600, habitat: "pond", shadow: "tiny", time: "all day", months: [4,5,6,7,8,9] }, + { id: "clouded-cichlid", name: "Clouded Cichlid", value: 800, habitat: "river", shadow: "medium", time: "all day", months: [4,5,6,7,8,9,10] }, + { id: "coelacanth", name: "Coelacanth", value: 15000, habitat: "ocean", shadow: "huge", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12], notes: "Only when raining or snowing" }, + { id: "crawfish", name: "Crawfish", value: 200, habitat: "pond", shadow: "small", time: "all day", months: [4,5,6,7,8,9] }, + { id: "crucian-carp", name: "Crucian Carp", value: 160, habitat: "river", shadow: "small", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "dace", name: "Dace", value: 240, habitat: "river", shadow: "medium", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "freshwater-goby", name: "Freshwater Goby", value: 400, habitat: "river", shadow: "small", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "frog", name: "Frog", value: 120, habitat: "pond", shadow: "small", time: "all day", months: [5,6,7,8] }, + { id: "goldfish", name: "Goldfish", value: 1300, habitat: "pond", shadow: "tiny", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "guppy", name: "Guppy", value: 1300, habitat: "river", shadow: "tiny", time: "9am–4pm", months: [4,5,6,7,8,9,10,11] }, + { id: "killifish", name: "Killifish", value: 300, habitat: "pond", shadow: "tiny", time: "all day", months: [4,5,6,7,8] }, + { id: "koi", name: "Koi", value: 4000, habitat: "pond", shadow: "medium", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "ladybug", name: "Ladybug", value: 200, habitat: "pond", shadow: "tiny", time: "8am–5pm", months: [3,4,5,6,10] }, + { id: "rainbow-trout", name: "Rainbow Trout", value: 800, habitat: "river-clifftop", shadow: "medium", time: "4pm–9am", months: [3,4,5,6,9,10,11] }, + { id: "sea-bass", name: "Sea Bass", value: 400, habitat: "ocean", shadow: "large", time: "all day", months: [1,2,3,4,5,6,7,9,10,11,12] }, + { id: "stringfish", name: "Stringfish", value: 15000, habitat: "river-clifftop", shadow: "huge", time: "4pm–9am", months: [12,1,2,3] }, + ], + bugs: [ + { id: "mole-cricket", name: "Mole Cricket", value: 500, location: "underground", time: "all day", months: [11,12,1,2,3,4,5] }, + { id: "ant", name: "Ant", value: 80, location: "rotten food", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "bee", name: "Bee", value: 2500, location: "shaking trees", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "common-butterfly", name: "Common Butterfly", value: 160, location: "flying", time: "4am–7pm", months: [1,2,3,4,5,6,9,10,11,12] }, + { id: "common-dragonfly", name: "Common Dragonfly", value: 180, location: "flying", time: "8am–7pm", months: [4,5,6,7,8,9,10] }, + { id: "clouded-yellow", name: "Clouded Yellow Butterfly", value: 160, location: "flying", time: "4am–7pm", months: [3,4,5,6,9,10] }, + { id: "cockroach", name: "Cockroach", value: 5, location: "underground", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "cricket", name: "Cricket", value: 130, location: "ground", time: "5pm–8am", months: [9,10,11] }, + { id: "darner-dragonfly", name: "Darner Dragonfly", value: 230, location: "flying", time: "8am–5pm", months: [4,5,6,7,8,9,10] }, + { id: "diving-beetle", name: "Diving Beetle", value: 800, location: "ponds", time: "8am–7pm", months: [5,6,7,8,9] }, + { id: "earth-boring-beetle", name: "Earth-Boring Beetle", value: 300, location: "ground", time: "all day", months: [7,8,9,10,11] }, + { id: "hermit-crab", name: "Hermit Crab", value: 1000, location: "beach", time: "7pm–8am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "honeybee", name: "Honeybee", value: 200, location: "flying", time: "8am–5pm", months: [3,4,5,6,7] }, + ], + fossils: [ + { id: "ammonite", name: "Ammonite", value: 1100 }, + { id: "ankylo-skull", name: "Ankylo Skull", value: 6000 }, + { id: "ankylo-tail", name: "Ankylo Tail", value: 2500 }, + { id: "ankylo-torso", name: "Ankylo Torso", value: 5500 }, + { id: "archaeopteryx", name: "Archaeopteryx", value: 1300 }, + { id: "archelon-skull", name: "Archelon Skull", value: 4000 }, + { id: "archelon-tail", name: "Archelon Tail", value: 3500 }, + { id: "australopith", name: "Australopith", value: 1100 }, + { id: "brachio-chest", name: "Brachio Chest", value: 5500 }, + { id: "brachio-pelvis", name: "Brachio Pelvis", value: 5500 }, + { id: "brachio-skull", name: "Brachio Skull", value: 6000 }, + { id: "brachio-tail", name: "Brachio Tail", value: 5500 }, + { id: "deinony-tail", name: "Deinony Tail", value: 2500 }, + { id: "deinony-torso", name: "Deinony Torso", value: 5500 }, + { id: "diplo-chest", name: "Diplo Chest", value: 5000 }, + { id: "diplo-neck", name: "Diplo Neck", value: 2500 }, + { id: "diplo-pelvis", name: "Diplo Pelvis", value: 4500 }, + { id: "diplo-skull", name: "Diplo Skull", value: 5000 }, + { id: "diplo-tail", name: "Diplo Tail", value: 2500 }, + ], + art: [ + { id: "academic", name: "Academic Painting", basedOn: "Vitruvian Man by Leonardo da Vinci", hasFake: true }, + { id: "amazing", name: "Amazing Painting", basedOn: "The Night Watch by Rembrandt", hasFake: true }, + { id: "basic", name: "Basic Painting", basedOn: "The Blue Boy by Thomas Gainsborough", hasFake: false }, + { id: "calm", name: "Calm Painting", basedOn: "A Sunday Afternoon on La Grande Jatte by Georges Seurat", hasFake: false }, + { id: "classic", name: "Classic Painting", basedOn: "Washington Crossing the Delaware by Emanuel Leutze", hasFake: true }, + { id: "common", name: "Common Painting", basedOn: "The Gleaners by Jean-François Millet", hasFake: false }, + { id: "dainty", name: "Dainty Painting", basedOn: "The Star — Dancer on Stage by Edgar Degas", hasFake: true }, + { id: "famous", name: "Famous Painting", basedOn: "Mona Lisa by Leonardo da Vinci", hasFake: true }, + { id: "flowery", name: "Flowery Painting", basedOn: "Sunflowers by Vincent van Gogh", hasFake: false }, + { id: "graceful", name: "Graceful Painting", basedOn: "Beauty Looking Back by Hishikawa Moronobu", hasFake: true }, + { id: "moving", name: "Moving Painting", basedOn: "The Birth of Venus by Sandro Botticelli", hasFake: true }, + { id: "perfect", name: "Perfect Painting", basedOn: "The Apotheosis of Homer by Ingres", hasFake: false }, + { id: "scary", name: "Scary Painting", basedOn: "The Great Wave off Kanagawa by Hokusai", hasFake: false }, + { id: "warm", name: "Warm Painting", basedOn: "Et in Arcadia ego by Nicolas Poussin", hasFake: false }, + ], + // Pre-seed donation state for realism + donated: { + fish: ["barbel-steed","bass","bluegill","brook-trout","carp","crucian-carp","dace","frog","goldfish","koi"], + bugs: ["ant","cockroach","common-butterfly","clouded-yellow","honeybee"], + fossils: ["ammonite","ankylo-skull","ankylo-tail","ankylo-torso","archaeopteryx","brachio-chest","brachio-pelvis","brachio-skull","brachio-tail","deinony-tail","diplo-chest","diplo-pelvis","diplo-skull"], + art: ["basic","calm","common","flowery","perfect","scary","warm"], + sea: ["seaweed","sea-anemone","scallop"], + }, + towns: [ + { id: "marigold", name: "Marigold", gameId: "acnh", hemisphere: "NH", itemCount: 35 }, + { id: "saffron", name: "Saffron", gameId: "acgcn", hemisphere: null, itemCount: 12 }, + { id: "willow", name: "Willow", gameId: "acnl", hemisphere: null, itemCount: 28 }, + ], + sea: [ + { id: "seaweed", name: "Seaweed", value: 600, shadow: "tiny", time: "all day", months: [10,11,12,1,2,3,4,5,6,7] }, + { id: "sea-grapes", name: "Sea Grapes", value: 900, shadow: "tiny", time: "all day", months: [6,7,8,9] }, + { id: "sea-anemone", name: "Sea Anemone", value: 500, shadow: "small", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "sea-pineapple", name: "Sea Pineapple", value: 1500, shadow: "small", time: "all day", months: [4,5,6,7,8] }, + { id: "sea-cucumber", name: "Sea Cucumber", value: 500, shadow: "small", time: "all day", months: [11,12,1,2,3,4,5] }, + { id: "spider-crab", name: "Spider Crab", value: 12000, shadow: "huge", time: "all day", months: [3,4] }, + { id: "spotted-garden-eel", name: "Spotted Garden Eel", value: 1100, shadow: "small", time: "4am–9pm", months: [5,6,7,8,9,10] }, + { id: "scallop", name: "Scallop", value: 1200, shadow: "medium", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + ], + recentActivity: [ + { item: "Honeybee", category: "bugs", when: "2h ago" }, + { item: "Brook Trout", category: "fish", when: "yesterday"}, + { item: "Brachio Chest", category: "fossils", when: "yesterday"}, + { item: "Warm Painting", category: "art", when: "3d ago" }, + { item: "Carp", category: "fish", when: "5d ago" }, + ], +}; diff --git a/docs/design-handoffs/v0.9.2_curator/shell.jsx b/docs/design-handoffs/v0.9.2_curator/shell.jsx new file mode 100644 index 0000000..e324dd1 --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/shell.jsx @@ -0,0 +1,123 @@ +/* global React, ACComponents */ +const { useState, useMemo } = React; +const { Glyph, Pill, MonthDots, MonthGrid, ItemRow, MONTHS, MONTHS_LONG } = ACComponents; + +// ── Header / sidebar / shell ── +function Sidebar({ town, currentTab, onTab, stats, onOpenTowns, onOpenSettings }) { + const hasSea = town.gameId === "acnh" || town.gameId === "acnl"; + const tabs = [ + { id: "home", label: "Home" }, + { id: "fish", label: "Fish", n: stats.fish }, + { id: "bugs", label: "Bugs", n: stats.bugs }, + { id: "fossils", label: "Fossils", n: stats.fossils }, + { id: "art", label: "Art", n: stats.art }, + ...(hasSea ? [{ id: "sea", label: "Sea", n: stats.sea }] : []), + { id: "stats", label: "Stats" }, + ]; + return ( + + ); +} + +function MonthStrip({ current }) { + return ( +
+ {MONTHS.map((m,i) => ( +
+ {String(i+1).padStart(2,"0")} + {m} +
+ ))} +
+ ); +} + +function ProgressMeter({ stats, gameId }) { + const hasSea = (gameId === "acnh" || gameId === "acnl") && stats.sea && stats.sea.total > 0; + const segKeys = hasSea + ? ["fish","bugs","fossils","art","sea"] + : ["fish","bugs","fossils","art"]; + + const total = segKeys.reduce((a,k) => a + stats[k].total, 0); + const done = segKeys.reduce((a,k) => a + stats[k].donated, 0); + const pct = total > 0 ? Math.round((done/total)*100) : 0; + + return ( +
+
+
+
Museum progress
+
+ {done} + / {total} +
+
+
{pct}%
+
+
+ {segKeys.map(k => { + const seg = stats[k]; + const frac = seg.total/total; + return ( +
+
+
+ + {k} + {seg.donated}/{seg.total} +
+
+ ); + })} +
+
+ ); +} + +window.ACShell = { Sidebar, MonthStrip, ProgressMeter }; diff --git a/docs/design-handoffs/v0.9.2_curator/styles.css b/docs/design-handoffs/v0.9.2_curator/styles.css new file mode 100644 index 0000000..3df4e54 --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/styles.css @@ -0,0 +1,576 @@ +/* AC Curator — soft museum aesthetic */ + +@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,500&family=Inter:wght@400;500;600;700&family=Varela+Round&display=swap'); + +:root { + --bg: #F4EFE3; + --surface: #FFFDF7; + --surface-alt: #F8F2E2; + --ink: #23241F; + --ink-soft: #5C5848; + --ink-muted: #8A8470; + --border: #E2D9C3; + --border-strong: #CFC4A8; + --accent: oklch(0.55 0.09 150); + --accent-soft: oklch(0.55 0.09 150 / 0.12); + --accent-ink: oklch(0.32 0.06 150); + --warn: oklch(0.62 0.12 50); + --warn-soft: oklch(0.62 0.12 50 / 0.14); + --chip-fish: oklch(0.62 0.08 230); + --chip-bugs: oklch(0.6 0.1 130); + --chip-fossils: oklch(0.55 0.06 60); + --chip-art: oklch(0.58 0.08 320); + --chip-sea: oklch(0.58 0.09 200); + --font-display: 'Fraunces', Georgia, serif; + --font-ui: 'Inter', system-ui, sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font-ui); + background: var(--bg); + color: var(--ink); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + letter-spacing: -0.005em; +} +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; } + +/* ── App shell ── */ +.ac-app { + display: grid; + grid-template-columns: 280px 1fr; + min-height: 100vh; + max-width: 1440px; + margin: 0 auto; +} + +/* ── Sidebar ── */ +.ac-sidebar { + border-right: 1px solid var(--border); + padding: 28px 22px; + display: flex; + flex-direction: column; + gap: 22px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} +.ac-brand { display: flex; gap: 12px; align-items: center; color: var(--accent); } +.ac-brand-mark { + width: 38px; height: 38px; + border-radius: 50%; + border: 1.5px solid currentColor; + display: grid; place-items: center; +} +.ac-brand-name { font-family: var(--font-display); font-size: 20px; font-weight: 600; color: var(--ink); letter-spacing: -0.02em; } +.ac-brand-sub { font-size: 11px; color: var(--ink-muted); font-style: italic; font-family: var(--font-display); } + +.ac-town-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px 16px; +} +.ac-town-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-town-name { font-family: var(--font-display); font-size: 22px; font-weight: 500; margin-top: 2px; letter-spacing: -0.01em; } +.ac-town-meta { font-size: 12px; color: var(--ink-soft); margin-top: 2px; display: flex; gap: 6px; align-items: center; } +.ac-dot-sep { color: var(--border-strong); } +.ac-town-switch { + font-size: 12px; color: var(--accent-ink); margin-top: 10px; + padding: 0; text-align: left; font-weight: 500; +} +.ac-town-switch:hover { text-decoration: underline; } + +.ac-nav { display: flex; flex-direction: column; gap: 1px; } +.ac-nav-item { + display: flex; justify-content: space-between; align-items: baseline; + padding: 10px 14px; border-radius: 9px; + font-size: 14px; color: var(--ink-soft); text-align: left; + transition: background 0.12s; +} +.ac-nav-item:hover { background: var(--surface-alt); color: var(--ink); } +.ac-nav-item-active { background: var(--accent-soft); color: var(--accent-ink); font-weight: 500; } +.ac-nav-count { font-size: 11px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } +.ac-nav-count-slash { opacity: 0.5; margin: 0 1px; } +.ac-nav-item-active .ac-nav-count { color: var(--accent-ink); } + +.ac-sidebar-foot { margin-top: auto; display: flex; flex-direction: column; gap: 4px; padding-top: 14px; border-top: 1px solid var(--border); } +.ac-foot-link { padding: 6px 14px; font-size: 12px; color: var(--ink-muted); text-align: left; } +.ac-foot-link:hover { color: var(--ink); } + +/* ── Main column ── */ +.ac-main { + padding: 32px 48px 80px; + min-width: 0; +} +.ac-topbar { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 28px; gap: 16px; +} +.ac-search { + flex: 1; max-width: 380px; + display: flex; align-items: center; gap: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 12px; +} +.ac-search:focus-within { border-color: var(--border-strong); } +.ac-search input { border: none; background: none; outline: none; font: inherit; flex: 1; color: var(--ink); } +.ac-search input::placeholder { color: var(--ink-muted); } +.ac-search-icon { color: var(--ink-muted); } + +.ac-topbar-actions { display: flex; gap: 8px; align-items: center; } +.ac-date-chip { + display: flex; align-items: baseline; gap: 6px; + padding: 6px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + color: var(--ink-soft); +} +.ac-date-chip strong { font-family: var(--font-display); font-weight: 500; color: var(--ink); } + +/* ── Hero ── */ +.ac-hero { margin-bottom: 36px; } +.ac-hero-eyebrow { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--ink-muted); margin-bottom: 8px; +} +.ac-hero-title { + font-family: var(--font-display); + font-weight: 400; + font-size: 38px; + line-height: 1.15; + letter-spacing: -0.02em; + margin: 0 0 24px; + text-wrap: pretty; + max-width: 720px; +} +.ac-hero-title em { font-style: italic; color: var(--accent-ink); font-weight: 500; } +.ac-hero-aside { color: var(--warn); font-style: italic; font-size: 0.85em; } + +/* ── Month strip ── */ +.ac-monthstrip { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 8px; +} +.ac-monthstrip-cell { + display: flex; flex-direction: column; align-items: center; + padding: 10px 4px; + border-radius: 8px; + position: relative; +} +.ac-monthstrip-num { + font-size: 10px; color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} +.ac-monthstrip-name { + font-family: var(--font-display); + font-size: 14px; + margin-top: 2px; + color: var(--ink-soft); +} +.ac-monthstrip-cell.is-now { + background: var(--accent-soft); +} +.ac-monthstrip-cell.is-now .ac-monthstrip-num { color: var(--accent-ink); } +.ac-monthstrip-cell.is-now .ac-monthstrip-name { color: var(--accent-ink); font-weight: 500; } + +/* ── Progress meter ── */ +.ac-meter { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 18px 22px; + margin-bottom: 36px; +} +.ac-meter-head { + display: flex; justify-content: space-between; align-items: flex-start; + margin-bottom: 18px; +} +.ac-meter-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); } +.ac-meter-num { font-family: var(--font-display); font-size: 32px; line-height: 1; margin-top: 4px; letter-spacing: -0.02em; } +.ac-meter-done { font-weight: 500; } +.ac-meter-of { color: var(--ink-muted); font-weight: 400; } +.ac-meter-pct { font-family: var(--font-display); font-size: 44px; font-weight: 500; color: var(--accent-ink); letter-spacing: -0.03em; line-height: 1; } +.ac-meter-pct-sym { font-size: 0.5em; vertical-align: top; margin-left: 2px; opacity: 0.7; } + +.ac-meter-bar { + display: flex; + gap: 10px; +} +.ac-meter-5 .ac-meter-bar { gap: 8px; } +.ac-meter-seg { + background: var(--surface-alt); + border-radius: 8px; + padding: 10px 12px; + position: relative; + overflow: hidden; + min-width: 0; +} +.ac-meter-seg-fill { + position: absolute; left: 0; top: 0; bottom: 0; + opacity: 0.18; + border-right: 2px solid currentColor; + z-index: 0; +} +.ac-meter-seg-label { + position: relative; z-index: 1; + display: flex; align-items: center; gap: 6px; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--ink-soft); + white-space: nowrap; +} +.ac-meter-seg-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; } +.ac-meter-seg-name { text-transform: capitalize; } +.ac-meter-seg-frac { margin-left: auto; color: var(--ink-muted); font-variant-numeric: tabular-nums; letter-spacing: 0.04em; } + +/* 5-segment compaction at narrow main-column widths. + At medium widths, drop fractions; at very narrow, drop names too (dot + frac on first 2). */ +@media (max-width: 1180px) { + .ac-meter-5 .ac-meter-seg-frac { display: none; } + .ac-meter-5 .ac-meter-seg-label { padding: 7px 8px; gap: 5px; } +} +@media (max-width: 980px) { + .ac-meter-5 .ac-meter-bar { flex-wrap: wrap; gap: 6px; } + .ac-meter-5 .ac-meter-seg { flex-basis: calc(50% - 3px); flex-grow: 1; } + .ac-meter-5 .ac-meter-seg-frac { display: inline; } +} + +/* ── Shelf (cards row) ── */ +.ac-shelf { margin-bottom: 36px; } +.ac-shelf-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; } +.ac-shelf-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); } +.ac-shelf-eyebrow-warn { color: var(--warn); } +.ac-shelf-title { font-family: var(--font-display); font-weight: 500; font-size: 24px; margin: 4px 0 0; letter-spacing: -0.01em; } +.ac-shelf-count { font-family: var(--font-display); font-size: 28px; color: var(--ink-muted); font-weight: 400; } + +.ac-cards { + display: grid; grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +.ac-card { + display: flex; gap: 14px; align-items: flex-start; + padding: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + text-align: left; + transition: border-color 0.12s, transform 0.12s; +} +.ac-card:hover { border-color: var(--border-strong); transform: translateY(-1px); } +.ac-card-glyph { + width: 44px; height: 44px; + border: 1.5px solid; + border-radius: 10px; + display: grid; place-items: center; + font-family: var(--font-display); font-size: 14px; font-weight: 500; + flex: none; + background: repeating-linear-gradient(135deg, transparent 0 4px, currentColor 4px 5px); + background-blend-mode: overlay; +} +.ac-card-glyph::before { + content: attr(data-i); +} +.ac-card-body { flex: 1; min-width: 0; } +.ac-card-name { font-weight: 500; color: var(--ink); margin-bottom: 2px; } +.ac-card-meta { font-size: 12px; color: var(--ink-soft); margin-bottom: 8px; text-transform: capitalize; } +.ac-card-warn { color: var(--warn); font-size: 18px; } + +/* ── Month dots (in cards) ── */ +.ac-monthdots { display: flex; gap: 3px; } +.ac-monthdot { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--surface-alt); + border: 1px solid var(--border); +} +.ac-monthdot.on { background: var(--accent); border-color: var(--accent); opacity: 0.6; } +.ac-monthdot.here.on { opacity: 1; box-shadow: 0 0 0 1.5px var(--accent-soft); } +.ac-monthdot.here:not(.on) { border-color: var(--ink-muted); } + +/* ── Activity ── */ +.ac-activity { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.ac-activity-row { + display: grid; + grid-template-columns: 12px 1fr auto auto; + gap: 12px; align-items: center; + padding: 12px 18px; + border-bottom: 1px solid var(--border); +} +.ac-activity-row:last-child { border-bottom: none; } +.ac-activity-dot { width: 8px; height: 8px; border-radius: 50%; } +.ac-activity-name { color: var(--ink); } +.ac-activity-cat { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-activity-when { font-size: 12px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } + +/* ── Category groups ── */ +.ac-category-head { + display: flex; justify-content: space-between; align-items: flex-end; + margin-bottom: 24px; padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.ac-category-title { + font-family: var(--font-display); font-weight: 400; + font-size: 44px; letter-spacing: -0.02em; margin: 0; +} +.ac-category-title em { font-style: italic; color: var(--accent-ink); } +.ac-category-meta { font-size: 13px; color: var(--ink-soft); text-align: right; } +.ac-category-meta strong { font-family: var(--font-display); font-weight: 500; color: var(--ink); font-size: 18px; display: block; } + +.ac-group { margin-bottom: 28px; } +.ac-group-head { + display: flex; align-items: baseline; gap: 12px; + margin-bottom: 8px; + padding: 0 4px; +} +.ac-group-title { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; + font-weight: 600; + margin: 0; + color: var(--ink-soft); +} +.ac-group-warn .ac-group-title { color: var(--warn); } +.ac-group-accent .ac-group-title { color: var(--accent-ink); } +.ac-group-done .ac-group-title { color: var(--ink-muted); } +.ac-group-count { font-size: 12px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } + +.ac-list { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} + +/* ── Item row ── */ +.ac-row { border-bottom: 1px solid var(--border); } +.ac-row:last-child { border-bottom: none; } + +/* ── Highlight pulse: 1.4s ease-out, accent-soft fill that fades back ── */ +.ac-row-pulse > .ac-row-main { animation: ac-row-pulse 1.4s ease-out; } +@keyframes ac-row-pulse { + 0% { background: var(--accent-soft, color-mix(in oklch, var(--accent) 22%, transparent)); } + 60% { background: var(--accent-soft, color-mix(in oklch, var(--accent) 16%, transparent)); } + 100% { background: var(--surface-alt); } +} +.ac-row-main { + display: flex; gap: 14px; align-items: center; + width: 100%; + padding: 12px 18px; + text-align: left; + transition: background 0.12s; +} +.ac-row-main:hover { background: var(--surface-alt); } +.ac-row-expanded > .ac-row-main { background: var(--surface-alt); } +.ac-row-donated .ac-row-name { color: var(--ink-muted); } +.ac-row-donated .ac-row-name span:first-child { text-decoration: line-through; text-decoration-color: var(--border-strong); text-decoration-thickness: 1px; } + +.ac-glyph { + width: 32px; height: 32px; + border: 1.5px solid; + border-radius: 8px; + display: grid; place-items: center; + font-family: var(--font-display); + font-size: 11px; font-weight: 500; + flex: none; +} +.ac-row-text { flex: 1; min-width: 0; } +.ac-row-name { + display: flex; align-items: center; gap: 8px; + font-weight: 500; +} +.ac-row-checkmark { color: var(--accent); font-size: 8px; } +.ac-row-meta { + display: flex; gap: 0; font-size: 12px; color: var(--ink-muted); + margin-top: 2px; + flex-wrap: wrap; +} +.ac-row-meta-bit:not(:last-child)::after { content: "·"; margin: 0 8px; color: var(--border-strong); } +.ac-row-meta-italic { font-style: italic; font-family: var(--font-display); } +.ac-row-meta-bells { color: var(--ink-soft); font-variant-numeric: tabular-nums; } +.ac-row-side { display: flex; align-items: center; gap: 10px; flex: none; } +.ac-row-time { font-size: 11px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } +.ac-chevron { color: var(--ink-muted); font-size: 18px; transition: transform 0.18s; line-height: 1; } +.ac-chevron-open { transform: rotate(90deg); color: var(--ink); } + +/* ── Pills ── */ +.ac-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.ac-pill-warn { background: var(--warn-soft); color: var(--warn); } +.ac-pill-accent { background: var(--accent-soft); color: var(--accent-ink); } + +/* ── Expand panel ── */ +.ac-expand { + display: grid; + grid-template-columns: 1fr 240px; + gap: 28px; + padding: 4px 18px 22px 64px; + background: var(--surface-alt); + border-top: 1px solid var(--border); +} +.ac-expand-section { } +.ac-expand-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--ink-muted); margin-bottom: 10px; margin-top: 14px; +} +.ac-monthgrid { + display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; +} +.ac-monthcell { + aspect-ratio: 1; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + display: grid; place-items: center; + position: relative; +} +.ac-monthcell-label { + font-size: 10px; color: var(--ink-muted); + font-family: var(--font-display); +} +.ac-monthcell.on { background: var(--accent-soft); border-color: var(--accent); } +.ac-monthcell.on .ac-monthcell-label { color: var(--accent-ink); } +.ac-monthcell.here { box-shadow: inset 0 0 0 1.5px var(--accent); } +.ac-monthcell.here .ac-monthcell-label { font-weight: 600; } + +.ac-expand-side { + display: flex; flex-direction: column; gap: 10px; + padding-top: 14px; +} +.ac-stat { + display: flex; flex-direction: column; gap: 1px; +} +.ac-stat-num { font-family: var(--font-display); font-size: 22px; font-weight: 500; letter-spacing: -0.01em; } +.ac-stat-num-text { font-size: 16px; } +.ac-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-note { + font-size: 12px; color: var(--ink-soft); font-style: italic; + font-family: var(--font-display); + padding: 8px 10px; + background: var(--surface); + border-left: 2px solid var(--accent); + border-radius: 4px; +} +.ac-donate-btn { + margin-top: auto; + padding: 10px 14px; + border-radius: 8px; + background: var(--accent); + color: var(--surface); + font-weight: 500; + font-size: 13px; + transition: opacity 0.12s; +} +.ac-donate-btn:hover { opacity: 0.88; } +.ac-donate-btn-on { background: var(--surface); color: var(--accent-ink); border: 1px solid var(--border-strong); } + +.ac-empty { + padding: 60px 0; text-align: center; + color: var(--ink-muted); + font-style: italic; + font-family: var(--font-display); +} + +/* ── Stats ── */ +.ac-stats-grid { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; + margin-bottom: 36px; +} +.ac-statcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; +} +.ac-statcard-cat { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; } +.ac-statcard-num { font-family: var(--font-display); font-size: 32px; margin-top: 4px; letter-spacing: -0.02em; } +.ac-statcard-of { color: var(--ink-muted); font-size: 0.6em; margin-left: 4px; } +.ac-statcard-bar { height: 4px; background: var(--surface-alt); border-radius: 2px; overflow: hidden; margin-top: 10px; } +.ac-statcard-fill { height: 100%; } +.ac-statcard-pct { font-size: 11px; color: var(--ink-muted); margin-top: 6px; } + +.ac-chartcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 22px; +} +.ac-chart { + display: grid; grid-template-columns: repeat(12, 1fr); + gap: 6px; + height: 180px; + margin-top: 18px; + align-items: end; +} +.ac-chart-col { display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; } +.ac-chart-bar { flex: 1; width: 100%; display: flex; align-items: flex-end; } +.ac-chart-bar-bg { + width: 100%; + background: var(--surface-alt); + border-radius: 6px 6px 0 0; + position: relative; + overflow: hidden; + border: 1px solid var(--border); + border-bottom: none; +} +.ac-chart-bar-fill { + position: absolute; left: 0; right: 0; bottom: 0; + background: var(--accent); + opacity: 0.5; +} +.ac-chart-num { font-size: 11px; color: var(--ink-soft); font-variant-numeric: tabular-nums; } +.ac-chart-month { font-size: 11px; color: var(--ink-muted); font-family: var(--font-display); } +.ac-chart-col.is-now .ac-chart-bar-bg { border-color: var(--accent); } +.ac-chart-col.is-now .ac-chart-month { color: var(--accent-ink); font-weight: 600; } +.ac-chart-legend { + display: flex; gap: 18px; + font-size: 11px; color: var(--ink-muted); + margin-top: 14px; padding-top: 14px; + border-top: 1px solid var(--border); +} +.ac-chart-legend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; vertical-align: middle; } +.ac-chart-legend-dot-bg { background: var(--surface-alt); border: 1px solid var(--border); } +.ac-chart-legend-dot-fill { background: var(--accent); opacity: 0.5; } + +/* ── Theme: parchment forces Varela ── */ +[data-theme="parchment"] .ac-hero-title em, +[data-theme="parchment"] .ac-category-title em { + font-style: normal; +} + +/* ── Responsive ── */ +@media (max-width: 980px) { + .ac-app { grid-template-columns: 1fr; } + .ac-sidebar { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); } + .ac-main { padding: 24px 20px 60px; } + .ac-cards { grid-template-columns: 1fr; } + .ac-stats-grid { grid-template-columns: repeat(2, 1fr); } + .ac-meter-bar { flex-wrap: wrap; } + .ac-expand { grid-template-columns: 1fr; padding-left: 18px; } + .ac-hero-title { font-size: 28px; } + .ac-monthstrip-name { font-size: 12px; } +} diff --git a/docs/design-handoffs/v0.9.2_curator/tabs.jsx b/docs/design-handoffs/v0.9.2_curator/tabs.jsx new file mode 100644 index 0000000..7a374b2 --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/tabs.jsx @@ -0,0 +1,288 @@ +/* global React, ACComponents, ACShell */ +const { useState, useMemo, useEffect } = React; +const { ItemRow, Pill, MonthDots, MONTHS, MONTHS_LONG } = ACComponents; +const { MonthStrip, ProgressMeter } = ACShell; + +// ── Home tab ── +function HomeTab({ data, donated, currentMonth, onJump, onToggle, showLeavingShelf = true, showNewShelf = true, gameId }) { + const hasSea = (gameId === "acnh" || gameId === "acnl") && data.sea && data.sea.length > 0; + const allItems = useMemo(() => [ + ...data.fish.map(x=>({...x, _cat:"fish"})), + ...data.bugs.map(x=>({...x, _cat:"bugs"})), + ...(hasSea ? data.sea.map(x=>({...x, _cat:"sea"})) : []), + ], [data, hasSea]); + + const leavingSoon = allItems.filter(it => + it.months && it.months.includes(currentMonth) && + !it.months.includes(currentMonth === 12 ? 1 : currentMonth+1) && + !donated[it._cat].has(it.id) + ); + + const newThisMonth = allItems.filter(it => + it.months && it.months.includes(currentMonth) && + !it.months.includes(currentMonth === 1 ? 12 : currentMonth-1) && + !donated[it._cat].has(it.id) + ); + + const stillNeeded = allItems.filter(it => + it.months && it.months.includes(currentMonth) && !donated[it._cat].has(it.id) + ).length; + + return ( +
+
+
Available in {MONTHS_LONG[currentMonth-1]}
+

+ {stillNeeded} creatures still to donate this month. + {leavingSoon.length > 0 && <>
{leavingSoon.length} are leaving soon.} +

+ +
+ + + + {showLeavingShelf && leavingSoon.length > 0 && ( +
+
+
+
Leaving end of month
+

Catch these before {MONTHS_LONG[currentMonth-1]} ends

+
+ {leavingSoon.length} +
+
+ {leavingSoon.slice(0,6).map(it => ( + + ))} +
+
+ )} + + {showNewShelf && newThisMonth.length > 0 && ( +
+
+
+
Just arrived
+

New this month

+
+ {newThisMonth.length} +
+
+ {newThisMonth.slice(0,6).map(it => ( + + ))} +
+
+ )} + +
+
+
+
Recent
+

Latest donations

+
+
+
+ {data.recentActivity.map((a,i) => ( +
+
+
{a.item}
+
{a.category}
+
{a.when}
+
+ ))} +
+
+
+ ); +} + +// ── Category tab (sectioned list) ── +function CategoryTab({ category, items, donated, onToggle, currentMonth, search, highlightId, onHighlightConsumed }) { + const [expanded, setExpanded] = useState(null); + + // Scroll-to + highlight when highlightId arrives + useEffect(() => { + if (!highlightId) return; + setExpanded(highlightId); + // wait a frame for expand to render + const raf = requestAnimationFrame(() => { + const el = document.querySelector(`[data-row-id="${CSS.escape(highlightId)}"]`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("ac-row-pulse"); + setTimeout(() => el.classList.remove("ac-row-pulse"), 1400); + } + onHighlightConsumed && onHighlightConsumed(); + }); + return () => cancelAnimationFrame(raf); + }, [highlightId, onHighlightConsumed]); + + const lowered = search.trim().toLowerCase(); + const filtered = lowered + ? items.filter(i => i.name.toLowerCase().includes(lowered) || (i.basedOn||"").toLowerCase().includes(lowered)) + : items; + + const isAvail = (it) => it.months ? it.months.includes(currentMonth) : true; + const isLeavingSoon = (it) => it.months && it.months.includes(currentMonth) && + !it.months.includes(currentMonth===12?1:currentMonth+1); + + const groups = useMemo(() => { + const leaving = [], avail = [], done = [], locked = []; + for (const it of filtered) { + const isDonated = donated.has(it.id); + if (isDonated) done.push(it); + else if (isLeavingSoon(it)) leaving.push(it); + else if (isAvail(it)) avail.push(it); + else locked.push(it); + } + const byName = (a,b)=>a.name.localeCompare(b.name); + return [ + { id: "leaving", label: "Leaving this month", items: leaving.sort(byName), tone: "warn" }, + { id: "avail", label: "Available now", items: avail.sort(byName), tone: "accent" }, + { id: "locked", label: "Out of season", items: locked.sort(byName), tone: "muted" }, + { id: "done", label: "Already donated", items: done.sort(byName), tone: "done" }, + ].filter(g => g.items.length > 0); + }, [filtered, donated, currentMonth]); + + return ( +
+ {groups.map(g => ( +
+
+

{g.label}

+ {g.items.length} +
+
+ {g.items.map(it => ( + onToggle(it.id)} + currentMonth={currentMonth} + expanded={expanded === it.id} + onExpand={()=>setExpanded(expanded===it.id?null:it.id)} + /> + ))} +
+
+ ))} + {groups.length === 0 && ( +
No matches for "{search}"
+ )} +
+ ); +} + +// ── Stats tab ── +function StatsTab({ data, donated, currentMonth }) { + const counts = { + fish: { donated: donated.fish.size, total: data.fish.length }, + bugs: { donated: donated.bugs.size, total: data.bugs.length }, + fossils: { donated: donated.fossils.size, total: data.fossils.length }, + art: { donated: donated.art.size, total: data.art.length }, + }; + + // monthly bars: how many fish+bugs available per month + const monthlyAvail = MONTHS.map((_, i) => { + const m = i+1; + let avail = 0, donatedCount = 0; + for (const cat of ["fish","bugs"]) { + for (const it of data[cat]) { + if (it.months && it.months.includes(m)) { + avail++; + if (donated[cat].has(it.id)) donatedCount++; + } + } + } + return { avail, donatedCount, m }; + }); + const maxAvail = Math.max(...monthlyAvail.map(x=>x.avail)); + + return ( +
+
+ {Object.entries(counts).map(([k,v]) => ( +
+
{k}
+
+ {v.donated} + / {v.total} +
+
+
+
+
{Math.round((v.donated/v.total)*100)}% complete
+
+ ))} +
+ +
+
+
+
Yearly rhythm
+

Fish & bug availability by month

+
+
+
+ {monthlyAvail.map(({avail, donatedCount, m}, i) => ( +
+
+
+
+
+
+
{avail}
+
{MONTHS[i]}
+
+ ))} +
+
+ Available + Already donated +
+
+
+ ); +} + +window.ACTabs = { HomeTab, CategoryTab, StatsTab }; diff --git a/docs/design-handoffs/v0.9.2_curator/theme.js b/docs/design-handoffs/v0.9.2_curator/theme.js new file mode 100644 index 0000000..3d903b1 --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/theme.js @@ -0,0 +1,105 @@ +// Theme tokens. Exposed as CSS variables on :root via setTheme(). +window.THEMES = { + meadow: { + label: "Meadow", + bg: "#F4EFE3", + surface: "#FFFDF7", + surfaceAlt: "#F8F2E2", + ink: "#23241F", + inkSoft: "#5C5848", + inkMuted: "#8A8470", + border: "#E2D9C3", + borderStrong: "#CFC4A8", + accent: "oklch(0.55 0.09 150)", // moss + accentSoft: "oklch(0.55 0.09 150 / 0.12)", + accentInk: "oklch(0.32 0.06 150)", + warn: "oklch(0.62 0.12 50)", // clay + warnSoft: "oklch(0.62 0.12 50 / 0.14)", + chipFish: "oklch(0.62 0.08 230)", + chipBugs: "oklch(0.6 0.1 130)", + chipFossils: "oklch(0.55 0.06 60)", + chipArt: "oklch(0.58 0.08 320)", + chipSea: "oklch(0.58 0.09 200)", + fontDisplay: "'Fraunces', 'Playfair Display', Georgia, serif", + fontUi: "'Inter', system-ui, -apple-system, sans-serif", + }, + parchment: { + label: "Parchment (current)", + bg: "#EFE6D0", + surface: "#F5E9D4", + surfaceAlt: "#EADCBE", + ink: "#2A2A2A", + inkSoft: "#5a4a35", + inkMuted: "#8B7A5C", + border: "#E0D2B0", + borderStrong: "#C9B98D", + accent: "#3CA370", + accentSoft: "rgba(60,163,112,0.14)", + accentInk: "#1F5A3C", + warn: "#C76B3F", + warnSoft: "rgba(199,107,63,0.16)", + chipFish: "#3F6FA8", + chipBugs: "#7B9C3A", + chipFossils: "#7B5E3B", + chipArt: "#8B5E94", + chipSea: "#3D8B96", + fontDisplay: "'Varela Round', system-ui, sans-serif", + fontUi: "'Varela Round', system-ui, sans-serif", + }, + midnight: { + label: "Midnight", + bg: "#16181F", + surface: "#1E212A", + surfaceAlt: "#262A35", + ink: "#EFEAE0", + inkSoft: "#B5AE9E", + inkMuted: "#7E7866", + border: "#2F3340", + borderStrong: "#3D4250", + accent: "oklch(0.7 0.09 150)", + accentSoft: "oklch(0.7 0.09 150 / 0.16)", + accentInk: "oklch(0.85 0.06 150)", + warn: "oklch(0.72 0.12 50)", + warnSoft: "oklch(0.72 0.12 50 / 0.18)", + chipFish: "oklch(0.72 0.09 230)", + chipBugs: "oklch(0.74 0.09 130)", + chipFossils: "oklch(0.7 0.06 60)", + chipArt: "oklch(0.72 0.08 320)", + chipSea: "oklch(0.74 0.08 200)", + fontDisplay: "'Fraunces', Georgia, serif", + fontUi: "'Inter', system-ui, sans-serif", + }, + sakura: { + label: "Sakura", + bg: "#FBF0EE", + surface: "#FFFCFB", + surfaceAlt: "#F7E5E1", + ink: "#2C2228", + inkSoft: "#705661", + inkMuted: "#9C8590", + border: "#EFD9D3", + borderStrong: "#DEBDB4", + accent: "oklch(0.6 0.11 10)", // rose + accentSoft: "oklch(0.6 0.11 10 / 0.12)", + accentInk: "oklch(0.4 0.09 10)", + warn: "oklch(0.6 0.13 50)", + warnSoft: "oklch(0.6 0.13 50 / 0.14)", + chipFish: "oklch(0.6 0.08 230)", + chipBugs: "oklch(0.6 0.09 130)", + chipFossils: "oklch(0.55 0.06 60)", + chipArt: "oklch(0.58 0.08 320)", + chipSea: "oklch(0.6 0.08 200)", + fontDisplay: "'Fraunces', Georgia, serif", + fontUi: "'Inter', system-ui, sans-serif", + }, +}; + +window.applyTheme = function applyTheme(name) { + const t = window.THEMES[name] || window.THEMES.meadow; + const root = document.documentElement; + for (const [k, v] of Object.entries(t)) { + if (k === "label") continue; + root.style.setProperty(`--${k.replace(/([A-Z])/g, "-$1").toLowerCase()}`, v); + } + root.dataset.theme = name; +}; diff --git a/docs/design-handoffs/v0.9.2_curator/tweaks-panel.jsx b/docs/design-handoffs/v0.9.2_curator/tweaks-panel.jsx new file mode 100644 index 0000000..5f8f95a --- /dev/null +++ b/docs/design-handoffs/v0.9.2_curator/tweaks-panel.jsx @@ -0,0 +1,425 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/docs/design-handoffs/v0.9_curator/Curator.html b/docs/design-handoffs/v0.9_curator/Curator.html new file mode 100644 index 0000000..5dc8dad --- /dev/null +++ b/docs/design-handoffs/v0.9_curator/Curator.html @@ -0,0 +1,220 @@ + + + + + +Curator — AC Museum Tracker + + + +
+ + + + + + + + + + + + + + + + + diff --git a/docs/design-handoffs/v0.9_curator/README.md b/docs/design-handoffs/v0.9_curator/README.md new file mode 100644 index 0000000..000456c --- /dev/null +++ b/docs/design-handoffs/v0.9_curator/README.md @@ -0,0 +1,205 @@ +# Handoff: v0.9 UI Redesign — "Curator" + +## Overview + +This is a redesign of the Animal Crossing Museum Tracker for **v0.9** (the UI redesign pass mentioned in `CLAUDE.md` roadmap). The new direction is codenamed **"Curator"** — a softer, more editorial museum aesthetic that keeps the cozy warmth of the parchment/wood palette but lightens density, modernizes typography, and introduces a sectioned list pattern that surfaces seasonal urgency ("leaving soon" / "available now") without burying the player in a flat A–Z list. + +## About the Design Files + +The files in this bundle (`Curator.html`, `styles.css`, `*.jsx`, `data.js`, `theme.js`) are **design references** — an HTML/React prototype showing the intended look, layout, and interaction patterns. They are **not production code to copy directly**. + +Your job is to **recreate this design inside the existing Vite + React 19 + TypeScript + Tailwind v4 + Zustand codebase**, using its established conventions: +- Real Zustand store + `useMuseumData` hook for data (not the static `data.js` here) +- React Router v6 routing for tabs (preserve `/town/:townId/:tab` URL structure) +- TypeScript everywhere; extend `src/lib/types.ts` if you need new fields +- Tailwind utility classes (the prototype uses raw CSS — translate to Tailwind classes + `colors.ts` tokens) +- Existing component split (`MuseumHeader`, `TabBar`, `CollectibleRow`, `ItemExpandPanel`, `HomeTab`, etc.) — refactor in place rather than introducing parallel components + +## Fidelity + +**High-fidelity.** Colors, type scale, spacing, and interactions are intentional. Recreate pixel-perfectly. + +The prototype ships **4 themes** (Meadow / Parchment / Midnight / Sakura) — only **Meadow** is required for v0.9. Parchment is the existing app's palette preserved as a fallback. Midnight + Sakura are bonus directions; ship if you want, otherwise defer. + +--- + +## Screens + +### 1. App shell (sidebar + main column) + +- **Layout:** CSS grid `280px 1fr`, max-width 1440px, centered. Sidebar is sticky/full-height with its own scroll; main column scrolls with page. Below 980px, sidebar collapses above main column. +- **Sidebar contents (top → bottom):** + - Brand: monogram circle (38×38) + "Curator" wordmark (Fraunces 20/600) + "a museum companion" italic sub + - Active town card: `surface` bg, 1px border, 14px radius, 14×16 padding. Eyebrow ("ACTIVE TOWN" 10px upper, letter-spacing 0.08em, `ink-muted`), town name (Fraunces 22/500), meta line ("New Horizons · Hem. NH" 12px `ink-soft`), "Switch town ›" link 12px `accent-ink` + - Nav: vertical list, 1px gap. Each item is a button with label left + count right (`donated/total` in tabular-nums, dimmed slash). Active state: `accent-soft` bg + `accent-ink` text + 500 weight + 9px radius. Hover: `surface-alt` bg. + - Foot links (Export CSV, Settings) pushed to bottom, 1px top border. +- **Main column padding:** 32px 48px 80px + +### 2. Topbar (above all main views) + +- Search bar (left, max 380px): search icon SVG + bordered pill input. Placeholder rotates by tab ("Search across categories…" on home, "Search fish…" on category). +- Date chip (right): "Sat **May 2**" — pill with day-of-week regular + date in Fraunces 500 ink. + +### 3. Home tab + +- **Hero:** + - Eyebrow: "AVAILABLE IN MAY" + - Headline: Fraunces 38/400, line-height 1.15, letter-spacing -0.02em, max-width 720px. Pattern: `{stillNeeded} creatures still to donate this month.
{leavingSoon} are leaving soon.` — italic accent number in `accent-ink`, line-break before the warn aside (clay color, italic). +- **Month strip:** 12-cell grid in a `surface` rounded card. Each cell shows `01` numeral above `Jan` Fraunces label. Current month cell has `accent-soft` bg + `accent-ink` text + 500 weight on month name. +- **"Leaving end of month" shelf:** eyebrow in `warn` color, Fraunces title "Catch these before May ends", count on right in big Fraunces. Cards grid: 3 columns, each card has tinted monogram glyph (44×44 with diagonal stripe pattern), name + meta + month-dots strip + warn ⚠ icon. +- **"Just arrived" shelf:** same card pattern, no warn icon. +- **Latest donations:** rounded `surface` card, list rows with category dot + item name + category eyebrow + relative time. + +### 4. Category tabs (Fish / Bugs / Fossils / Art) + +- **Header:** `{donated} of {total} {category}` — Fraunces 44/400, italic accent number. Right side: `{pct}% complete` strong + helper text ("Showing availability for May") for time-sensitive categories. +- **Sectioned list — groups appear only when non-empty, in this order:** + 1. **Leaving this month** (warn-toned eyebrow) + 2. **Available now** (accent-toned eyebrow) + 3. **Out of season** (muted eyebrow) + 4. **Already donated** (muted eyebrow) +- **Group head:** uppercase 11px / 600 / 0.12em letter-spacing, count right-aligned 12px tabular. +- **List card:** `surface` bg, 1px border, 12px radius, divider lines between rows. +- **Item row:** + - Glyph (32×32 rounded square, 1.5px category-tinted border, Fraunces monogram letters; **donated** state fills bg with category tint and inverts text to `surface`) + - Name (500 weight) + small ● checkmark in `accent` if donated; **donated rows strike through name** with `border-strong` 1px decoration + - Meta line: `habitat · value ✦` (or `location · value` for bugs, `part · value` for fossils, italic `basedOn` for art) — separators use `·` glyph in `border-strong`, 8px each side + - Right side: `Leaving soon` warn pill OR `New this month` accent pill (only when not donated), time text "4pm–9am" if not "all day", chevron › + - Hover: `surface-alt` bg. Expanded: same. +- **Expand panel** (inline accordion, opens when row clicked): + - Two-column grid `1fr 240px`, padding `4px 18px 22px 64px` (left-pad aligns with row text), `surface-alt` bg, 1px top border + - Left: "Available in" eyebrow + 12-cell month grid. Cells are square, 6px radius. Available months: `accent-soft` bg + `accent` border. Current month: 1.5px inset accent ring + 600 label. + - Right column: stats stack — bells value (Fraunces 22/500), shadow size, active hours; optional `notes` quote-block (`accent`-bordered left). Donate button at bottom: `accent` bg / `surface` text → flips to outlined "Donated ✓ — undonate" when on. + - Chevron rotates 90° when expanded. + +### 5. Stats tab + +- Header: `Stats & rhythms` (Fraunces 44/400) +- 4-up category cards: eyebrow in category color, big donated/total Fraunces, thin progress bar tinted with category color, "X% complete" caption +- Yearly rhythm chart: 12 columns, each with a stacked bar. BG height = % of max availability across all months. Inner fill (50% accent opacity) = donated fraction within available. Current month column has accent border. Number above each column = how many available that month. Legend: "Available" / "Already donated" + +### 6. Item detail (inline expand) + +Same as the expand panel described in section 4. **No bottom sheet, no separate modal** for fish/bugs/fossils. Art still uses `DetailModal` per existing code; the row treatment is the same up to the expand point. + +--- + +## Interactions + +- **Tab switch:** instant. URL updates per existing React Router setup. +- **Row click:** toggles inline expand. Only one row open at a time per category (state lives in CategoryTab). +- **Donate toggle:** `e.stopPropagation()` so the donate button doesn't also collapse the expand. Updates Zustand store. +- **Search:** debounced 150ms (existing `useSearch` hook is fine). Filters within the active category for category tabs; on home it's the global search trigger. +- **Hide donated tweak:** filters category lists to omit items in the `donated[cat]` set. Already-donated section disappears. +- **Month slider:** for prototyping only — production should always use `new Date().getMonth() + 1`. +- **Hover transitions:** 0.12s on row bg, card border, donate button opacity. Chevron rotation: 0.18s. + +--- + +## Design Tokens + +All values in `theme.js` → port to `src/lib/colors.ts` and Tailwind `theme.extend`. Add a new theme object `meadow` alongside the existing `colors` export; keep `colors` (parchment) intact as fallback. + +### Meadow palette (default) + +| token | value | usage | +|---|---|---| +| `bg` | `#F4EFE3` | page background | +| `surface` | `#FFFDF7` | card backgrounds | +| `surface-alt` | `#F8F2E2` | hover, expand panel, secondary surfaces | +| `ink` | `#23241F` | primary text | +| `ink-soft` | `#5C5848` | secondary text | +| `ink-muted` | `#8A8470` | tertiary text, eyebrows | +| `border` | `#E2D9C3` | subtle borders, dividers | +| `border-strong` | `#CFC4A8` | hover borders, separator dots, strikethrough | +| `accent` | `oklch(0.55 0.09 150)` | primary moss green — buttons, progress bars | +| `accent-soft` | `oklch(0.55 0.09 150 / 0.12)` | nav active, accent pill bg, current-month bg | +| `accent-ink` | `oklch(0.32 0.06 150)` | accent text on light bg | +| `warn` | `oklch(0.62 0.12 50)` | clay — leaving-soon | +| `warn-soft` | `oklch(0.62 0.12 50 / 0.14)` | warn pill bg | +| `chip-fish` | `oklch(0.62 0.08 230)` | fish category tint | +| `chip-bugs` | `oklch(0.6 0.1 130)` | bugs category tint | +| `chip-fossils` | `oklch(0.55 0.06 60)` | fossils category tint | +| `chip-art` | `oklch(0.58 0.08 320)` | art category tint | + +### Typography + +- **Display:** `'Fraunces', Georgia, serif` — 9..144 opsz axis. Weights 400/500/600. Use italic 400/500 for accents. +- **UI:** `'Inter', system-ui, sans-serif` — weights 400/500/600/700. +- Replace `Varela Round` global rule in `src/index.css`. Load both via Google Fonts (already shown in `styles.css` `@import` URL). + +### Type scale + +| usage | font / size / weight / lh / tracking | +|---|---| +| Hero headline | Fraunces 38 / 400 / 1.15 / -0.02em | +| Category h1 | Fraunces 44 / 400 / 1.0 / -0.02em | +| Card / shelf title | Fraunces 24 / 500 / 1.2 / -0.01em | +| Section eyebrow | Inter 11 / 600 / 1.4 / 0.12em uppercase | +| Body | Inter 14 / 400 / 1.5 / -0.005em | +| Row name | Inter 14 / 500 | +| Row meta | Inter 12 / 400 | +| Stat value | Fraunces 22 / 500 / -0.01em | +| Tabular numbers | `font-variant-numeric: tabular-nums` everywhere counts/values appear | + +### Spacing & radii + +- Card radius: **12px** (lists, stat cards), **14px** (hero cards, town card) +- Pill radius: **999px** +- Button radius: **8px**, glyph: **8–10px** +- Section gaps: 36px between hero/shelves/groups; 28px between groups +- Sidebar padding: 28×22; main padding: 32×48 (24×20 mobile) + +### Shadows + +None — borders carry separation. Hover lift is `transform: translateY(-1px)` + border color change, no shadow. + +--- + +## State Management + +No new store fields required. Reuses existing schema: +- `donated[townId][gameId][itemId]` — per `CLAUDE.md` v3 schema +- `Town.hemisphere` — already exists +- Active month: `new Date().getMonth() + 1` derived in component, **not** stored + +New UI state (component-local, not persisted): +- `expanded: string | null` per CategoryTab +- `search: string` lifted to App or per-tab +- Optional Zustand: `viewPrefs.hideDonated: boolean` if you want the toggle persistent + +--- + +## Assets + +- **Fonts:** Fraunces + Inter via Google Fonts (`https://fonts.googleapis.com/css2?...`) +- **No item sprites.** The prototype uses 2-letter monograms because real AC sprites are copyrighted. If your existing app already has acceptable item imagery, swap glyphs for those — just preserve the 32×32 / 44×44 frame and category-tinted border treatment. +- **Brand mark:** the museum-roof SVG in the sidebar is in `shell.jsx` — ``. Lift it directly. +- **Icons:** search icon is inline SVG in `Curator.html`. Use Lucide or your existing icon set if you have one. + +--- + +## Files in this bundle + +| file | what's in it | +|---|---| +| `Curator.html` | Entry point — App component, theme application, Tweaks panel wiring | +| `styles.css` | All visual styling. Read this for exact CSS values. | +| `theme.js` | All 4 theme palettes as JS objects | +| `data.js` | Sample museum data (NOT representative of full datasets — you have the real JSON in `public/data/`) | +| `shell.jsx` | Sidebar, MonthStrip, ProgressMeter | +| `tabs.jsx` | HomeTab, CategoryTab, StatsTab | +| `components.jsx` | ItemRow, Glyph, Pill, MonthDots, MonthGrid | +| `tweaks-panel.jsx` | Prototype-only tweaks UI; not for prod | + +--- + +## Migration plan suggestion (in priority order) + +1. **Tokens & fonts** — extend `colors.ts` with meadow palette, swap Varela Round for Fraunces+Inter in `index.css`, set up Tailwind colors so utility classes work. +2. **App shell** — replace `MuseumHeader` + horizontal `TabBar` with sidebar nav. Keep React Router routes intact; the sidebar just renders ``s. +3. **CollectibleRow** — restyle to match new row spec (glyph, meta line with `·` separators, donated strikethrough, pill states). +4. **ItemExpandPanel** — restyle to two-column grid layout, add stat stack on right, donate button at bottom. +5. **HomeTab** — rebuild with hero + month strip + leaving/new shelves. The "leaving soon" filter logic is in `tabs.jsx` (`HomeTab` function, `leavingSoon` array). +6. **AnalyticsView (Stats)** — new card grid + monthly availability chart. +7. **Polish** — animations, mobile responsive pass, settings/onboarding (per v0.9 roadmap). diff --git a/docs/design-handoffs/v0.9_curator/components.jsx b/docs/design-handoffs/v0.9_curator/components.jsx new file mode 100644 index 0000000..8b67154 --- /dev/null +++ b/docs/design-handoffs/v0.9_curator/components.jsx @@ -0,0 +1,127 @@ +/* global React */ +const { useState, useMemo } = React; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +// ── Glyph: a simple monogram tile, category-tinted. No copyrighted sprites. ── +function Glyph({ name, category, donated }) { + const initials = name.split(/\s|-/).filter(Boolean).slice(0, 2).map((s) => s[0]).join("").toUpperCase(); + const tint = `var(--chip-${category})`; + return ( +
+ {initials} +
); + +} + +function Pill({ children, tone = "default", size = "sm" }) { + return {children}; +} + +function MonthDots({ months, current }) { + return ( +
+ {Array.from({ length: 12 }, (_, i) => { + const m = i + 1; + const on = months.includes(m); + const here = m === current; + return ; + })} +
); + +} + +function MonthGrid({ months, current }) { + return ( +
+ {MONTHS.map((m, i) => { + const on = months.includes(i + 1); + const here = i + 1 === current; + return ( +
+ {m} +
); + + })} +
); + +} + +// ── Item row with inline expand panel ── +function ItemRow({ item, category, donated, onToggle, currentMonth, expanded, onExpand }) { + const leavingSoon = item.months && item.months.includes(currentMonth) && !item.months.includes(currentMonth === 12 ? 1 : currentMonth + 1); + const newThisMonth = item.months && item.months.includes(currentMonth) && !item.months.includes(currentMonth === 1 ? 12 : currentMonth - 1); + + return ( +
+ + {expanded && +
+ {item.months && +
+
Available in
+ +
+ } +
+ {item.value != null && +
+
{item.value.toLocaleString()}
+
bells · sell value
+
+ } + {item.shadow && +
+
{item.shadow}
+
shadow size
+
+ } + {item.time && +
+
{item.time}
+
active hours
+
+ } + {item.notes && +
{item.notes}
+ } + +
+
+ } +
); + +} + +window.ACComponents = { Glyph, Pill, MonthDots, MonthGrid, ItemRow, MONTHS, MONTHS_LONG }; \ No newline at end of file diff --git a/docs/design-handoffs/v0.9_curator/data.js b/docs/design-handoffs/v0.9_curator/data.js new file mode 100644 index 0000000..66499da --- /dev/null +++ b/docs/design-handoffs/v0.9_curator/data.js @@ -0,0 +1,105 @@ +// Sample museum data inspired by AC GCN/NH species lists. +// Real-feeling enough for a prototype; no copyrighted sprite assets. +window.MUSEUM_DATA = { + meta: { + townName: "Marigold", + playerName: "Bea", + game: "New Horizons", + hemisphere: "NH", + currentMonth: 5, // May + currentDay: 2, + }, + fish: [ + { id: "loach", name: "Loach", value: 400, habitat: "river", shadow: "small", time: "all day", months: [3,4,5] }, + { id: "angelfish", name: "Angelfish", value: 3000, habitat: "river", shadow: "small", time: "4pm–9am", months: [5,6,7,8,9,10] }, + { id: "cherry-salmon", name: "Cherry Salmon", value: 1000, habitat: "river", shadow: "medium", time: "4pm–9am", months: [3,4,5,6,9,10,11] }, + { id: "barbel-steed", name: "Barbel Steed", value: 200, habitat: "river", shadow: "medium", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "barred-knifejaw", name: "Barred Knifejaw", value: 5000, habitat: "ocean", shadow: "medium", time: "all day", months: [3,4,5,6,7,8,9,10,11] }, + { id: "bass", name: "Black Bass", value: 400, habitat: "river", shadow: "large", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "bluegill", name: "Bluegill", value: 180, habitat: "river", shadow: "small", time: "9am–4pm", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "brook-trout", name: "Brook Trout", value: 150, habitat: "river-clifftop", shadow: "small", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "carp", name: "Carp", value: 300, habitat: "pond", shadow: "medium", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "cherry-shrimp", name: "Cherry Shrimp", value: 600, habitat: "pond", shadow: "tiny", time: "all day", months: [4,5,6,7,8,9] }, + { id: "clouded-cichlid", name: "Clouded Cichlid", value: 800, habitat: "river", shadow: "medium", time: "all day", months: [4,5,6,7,8,9,10] }, + { id: "coelacanth", name: "Coelacanth", value: 15000, habitat: "ocean", shadow: "huge", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12], notes: "Only when raining or snowing" }, + { id: "crawfish", name: "Crawfish", value: 200, habitat: "pond", shadow: "small", time: "all day", months: [4,5,6,7,8,9] }, + { id: "crucian-carp", name: "Crucian Carp", value: 160, habitat: "river", shadow: "small", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "dace", name: "Dace", value: 240, habitat: "river", shadow: "medium", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "freshwater-goby", name: "Freshwater Goby", value: 400, habitat: "river", shadow: "small", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "frog", name: "Frog", value: 120, habitat: "pond", shadow: "small", time: "all day", months: [5,6,7,8] }, + { id: "goldfish", name: "Goldfish", value: 1300, habitat: "pond", shadow: "tiny", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "guppy", name: "Guppy", value: 1300, habitat: "river", shadow: "tiny", time: "9am–4pm", months: [4,5,6,7,8,9,10,11] }, + { id: "killifish", name: "Killifish", value: 300, habitat: "pond", shadow: "tiny", time: "all day", months: [4,5,6,7,8] }, + { id: "koi", name: "Koi", value: 4000, habitat: "pond", shadow: "medium", time: "4pm–9am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "ladybug", name: "Ladybug", value: 200, habitat: "pond", shadow: "tiny", time: "8am–5pm", months: [3,4,5,6,10] }, + { id: "rainbow-trout", name: "Rainbow Trout", value: 800, habitat: "river-clifftop", shadow: "medium", time: "4pm–9am", months: [3,4,5,6,9,10,11] }, + { id: "sea-bass", name: "Sea Bass", value: 400, habitat: "ocean", shadow: "large", time: "all day", months: [1,2,3,4,5,6,7,9,10,11,12] }, + { id: "stringfish", name: "Stringfish", value: 15000, habitat: "river-clifftop", shadow: "huge", time: "4pm–9am", months: [12,1,2,3] }, + ], + bugs: [ + { id: "mole-cricket", name: "Mole Cricket", value: 500, location: "underground", time: "all day", months: [11,12,1,2,3,4,5] }, + { id: "ant", name: "Ant", value: 80, location: "rotten food", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "bee", name: "Bee", value: 2500, location: "shaking trees", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "common-butterfly", name: "Common Butterfly", value: 160, location: "flying", time: "4am–7pm", months: [1,2,3,4,5,6,9,10,11,12] }, + { id: "common-dragonfly", name: "Common Dragonfly", value: 180, location: "flying", time: "8am–7pm", months: [4,5,6,7,8,9,10] }, + { id: "clouded-yellow", name: "Clouded Yellow Butterfly", value: 160, location: "flying", time: "4am–7pm", months: [3,4,5,6,9,10] }, + { id: "cockroach", name: "Cockroach", value: 5, location: "underground", time: "all day", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "cricket", name: "Cricket", value: 130, location: "ground", time: "5pm–8am", months: [9,10,11] }, + { id: "darner-dragonfly", name: "Darner Dragonfly", value: 230, location: "flying", time: "8am–5pm", months: [4,5,6,7,8,9,10] }, + { id: "diving-beetle", name: "Diving Beetle", value: 800, location: "ponds", time: "8am–7pm", months: [5,6,7,8,9] }, + { id: "earth-boring-beetle", name: "Earth-Boring Beetle", value: 300, location: "ground", time: "all day", months: [7,8,9,10,11] }, + { id: "hermit-crab", name: "Hermit Crab", value: 1000, location: "beach", time: "7pm–8am", months: [1,2,3,4,5,6,7,8,9,10,11,12] }, + { id: "honeybee", name: "Honeybee", value: 200, location: "flying", time: "8am–5pm", months: [3,4,5,6,7] }, + ], + fossils: [ + { id: "ammonite", name: "Ammonite", value: 1100 }, + { id: "ankylo-skull", name: "Ankylo Skull", value: 6000 }, + { id: "ankylo-tail", name: "Ankylo Tail", value: 2500 }, + { id: "ankylo-torso", name: "Ankylo Torso", value: 5500 }, + { id: "archaeopteryx", name: "Archaeopteryx", value: 1300 }, + { id: "archelon-skull", name: "Archelon Skull", value: 4000 }, + { id: "archelon-tail", name: "Archelon Tail", value: 3500 }, + { id: "australopith", name: "Australopith", value: 1100 }, + { id: "brachio-chest", name: "Brachio Chest", value: 5500 }, + { id: "brachio-pelvis", name: "Brachio Pelvis", value: 5500 }, + { id: "brachio-skull", name: "Brachio Skull", value: 6000 }, + { id: "brachio-tail", name: "Brachio Tail", value: 5500 }, + { id: "deinony-tail", name: "Deinony Tail", value: 2500 }, + { id: "deinony-torso", name: "Deinony Torso", value: 5500 }, + { id: "diplo-chest", name: "Diplo Chest", value: 5000 }, + { id: "diplo-neck", name: "Diplo Neck", value: 2500 }, + { id: "diplo-pelvis", name: "Diplo Pelvis", value: 4500 }, + { id: "diplo-skull", name: "Diplo Skull", value: 5000 }, + { id: "diplo-tail", name: "Diplo Tail", value: 2500 }, + ], + art: [ + { id: "academic", name: "Academic Painting", basedOn: "Vitruvian Man by Leonardo da Vinci", hasFake: true }, + { id: "amazing", name: "Amazing Painting", basedOn: "The Night Watch by Rembrandt", hasFake: true }, + { id: "basic", name: "Basic Painting", basedOn: "The Blue Boy by Thomas Gainsborough", hasFake: false }, + { id: "calm", name: "Calm Painting", basedOn: "A Sunday Afternoon on La Grande Jatte by Georges Seurat", hasFake: false }, + { id: "classic", name: "Classic Painting", basedOn: "Washington Crossing the Delaware by Emanuel Leutze", hasFake: true }, + { id: "common", name: "Common Painting", basedOn: "The Gleaners by Jean-François Millet", hasFake: false }, + { id: "dainty", name: "Dainty Painting", basedOn: "The Star — Dancer on Stage by Edgar Degas", hasFake: true }, + { id: "famous", name: "Famous Painting", basedOn: "Mona Lisa by Leonardo da Vinci", hasFake: true }, + { id: "flowery", name: "Flowery Painting", basedOn: "Sunflowers by Vincent van Gogh", hasFake: false }, + { id: "graceful", name: "Graceful Painting", basedOn: "Beauty Looking Back by Hishikawa Moronobu", hasFake: true }, + { id: "moving", name: "Moving Painting", basedOn: "The Birth of Venus by Sandro Botticelli", hasFake: true }, + { id: "perfect", name: "Perfect Painting", basedOn: "The Apotheosis of Homer by Ingres", hasFake: false }, + { id: "scary", name: "Scary Painting", basedOn: "The Great Wave off Kanagawa by Hokusai", hasFake: false }, + { id: "warm", name: "Warm Painting", basedOn: "Et in Arcadia ego by Nicolas Poussin", hasFake: false }, + ], + // Pre-seed donation state for realism + donated: { + fish: ["barbel-steed","bass","bluegill","brook-trout","carp","crucian-carp","dace","frog","goldfish","koi"], + bugs: ["ant","cockroach","common-butterfly","clouded-yellow","honeybee"], + fossils: ["ammonite","ankylo-skull","ankylo-tail","ankylo-torso","archaeopteryx","brachio-chest","brachio-pelvis","brachio-skull","brachio-tail","deinony-tail","diplo-chest","diplo-pelvis","diplo-skull"], + art: ["basic","calm","common","flowery","perfect","scary","warm"], + }, + recentActivity: [ + { item: "Honeybee", category: "bugs", when: "2h ago" }, + { item: "Brook Trout", category: "fish", when: "yesterday"}, + { item: "Brachio Chest", category: "fossils", when: "yesterday"}, + { item: "Warm Painting", category: "art", when: "3d ago" }, + { item: "Carp", category: "fish", when: "5d ago" }, + ], +}; diff --git a/docs/design-handoffs/v0.9_curator/shell.jsx b/docs/design-handoffs/v0.9_curator/shell.jsx new file mode 100644 index 0000000..534116b --- /dev/null +++ b/docs/design-handoffs/v0.9_curator/shell.jsx @@ -0,0 +1,115 @@ +/* global React, ACComponents */ +const { useState, useMemo } = React; +const { Glyph, Pill, MonthDots, MonthGrid, ItemRow, MONTHS, MONTHS_LONG } = ACComponents; + +// ── Header / sidebar / shell ── +function Sidebar({ town, currentTab, onTab, stats }) { + const tabs = [ + { id: "home", label: "Home" }, + { id: "fish", label: "Fish", n: stats.fish }, + { id: "bugs", label: "Bugs", n: stats.bugs }, + { id: "fossils", label: "Fossils", n: stats.fossils }, + { id: "art", label: "Art", n: stats.art }, + { id: "stats", label: "Stats" }, + ]; + return ( + + ); +} + +function MonthStrip({ current }) { + return ( +
+ {MONTHS.map((m,i) => ( +
+ {String(i+1).padStart(2,"0")} + {m} +
+ ))} +
+ ); +} + +function ProgressMeter({ stats }) { + const total = stats.fish.total + stats.bugs.total + stats.fossils.total + stats.art.total; + const done = stats.fish.donated + stats.bugs.donated + stats.fossils.donated + stats.art.donated; + const pct = Math.round((done/total)*100); + return ( +
+
+
+
Museum progress
+
+ {done} + / {total} +
+
+
{pct}%
+
+
+ {["fish","bugs","fossils","art"].map(k => { + const seg = stats[k]; + const frac = seg.total/total; + return ( +
+
+
+ + {k} + {seg.donated}/{seg.total} +
+
+ ); + })} +
+
+ ); +} + +window.ACShell = { Sidebar, MonthStrip, ProgressMeter }; diff --git a/docs/design-handoffs/v0.9_curator/styles.css b/docs/design-handoffs/v0.9_curator/styles.css new file mode 100644 index 0000000..c0b4058 --- /dev/null +++ b/docs/design-handoffs/v0.9_curator/styles.css @@ -0,0 +1,553 @@ +/* AC Curator — soft museum aesthetic */ + +@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,500&family=Inter:wght@400;500;600;700&family=Varela+Round&display=swap'); + +:root { + --bg: #F4EFE3; + --surface: #FFFDF7; + --surface-alt: #F8F2E2; + --ink: #23241F; + --ink-soft: #5C5848; + --ink-muted: #8A8470; + --border: #E2D9C3; + --border-strong: #CFC4A8; + --accent: oklch(0.55 0.09 150); + --accent-soft: oklch(0.55 0.09 150 / 0.12); + --accent-ink: oklch(0.32 0.06 150); + --warn: oklch(0.62 0.12 50); + --warn-soft: oklch(0.62 0.12 50 / 0.14); + --chip-fish: oklch(0.62 0.08 230); + --chip-bugs: oklch(0.6 0.1 130); + --chip-fossils: oklch(0.55 0.06 60); + --chip-art: oklch(0.58 0.08 320); + --font-display: 'Fraunces', Georgia, serif; + --font-ui: 'Inter', system-ui, sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font-ui); + background: var(--bg); + color: var(--ink); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + letter-spacing: -0.005em; +} +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; } + +/* ── App shell ── */ +.ac-app { + display: grid; + grid-template-columns: 280px 1fr; + min-height: 100vh; + max-width: 1440px; + margin: 0 auto; +} + +/* ── Sidebar ── */ +.ac-sidebar { + border-right: 1px solid var(--border); + padding: 28px 22px; + display: flex; + flex-direction: column; + gap: 22px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} +.ac-brand { display: flex; gap: 12px; align-items: center; color: var(--accent); } +.ac-brand-mark { + width: 38px; height: 38px; + border-radius: 50%; + border: 1.5px solid currentColor; + display: grid; place-items: center; +} +.ac-brand-name { font-family: var(--font-display); font-size: 20px; font-weight: 600; color: var(--ink); letter-spacing: -0.02em; } +.ac-brand-sub { font-size: 11px; color: var(--ink-muted); font-style: italic; font-family: var(--font-display); } + +.ac-town-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px 16px; +} +.ac-town-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-town-name { font-family: var(--font-display); font-size: 22px; font-weight: 500; margin-top: 2px; letter-spacing: -0.01em; } +.ac-town-meta { font-size: 12px; color: var(--ink-soft); margin-top: 2px; display: flex; gap: 6px; align-items: center; } +.ac-dot-sep { color: var(--border-strong); } +.ac-town-switch { + font-size: 12px; color: var(--accent-ink); margin-top: 10px; + padding: 0; text-align: left; font-weight: 500; +} +.ac-town-switch:hover { text-decoration: underline; } + +.ac-nav { display: flex; flex-direction: column; gap: 1px; } +.ac-nav-item { + display: flex; justify-content: space-between; align-items: baseline; + padding: 10px 14px; border-radius: 9px; + font-size: 14px; color: var(--ink-soft); text-align: left; + transition: background 0.12s; +} +.ac-nav-item:hover { background: var(--surface-alt); color: var(--ink); } +.ac-nav-item-active { background: var(--accent-soft); color: var(--accent-ink); font-weight: 500; } +.ac-nav-count { font-size: 11px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } +.ac-nav-count-slash { opacity: 0.5; margin: 0 1px; } +.ac-nav-item-active .ac-nav-count { color: var(--accent-ink); } + +.ac-sidebar-foot { margin-top: auto; display: flex; flex-direction: column; gap: 4px; padding-top: 14px; border-top: 1px solid var(--border); } +.ac-foot-link { padding: 6px 14px; font-size: 12px; color: var(--ink-muted); text-align: left; } +.ac-foot-link:hover { color: var(--ink); } + +/* ── Main column ── */ +.ac-main { + padding: 32px 48px 80px; + min-width: 0; +} +.ac-topbar { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 28px; gap: 16px; +} +.ac-search { + flex: 1; max-width: 380px; + display: flex; align-items: center; gap: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 12px; +} +.ac-search:focus-within { border-color: var(--border-strong); } +.ac-search input { border: none; background: none; outline: none; font: inherit; flex: 1; color: var(--ink); } +.ac-search input::placeholder { color: var(--ink-muted); } +.ac-search-icon { color: var(--ink-muted); } + +.ac-topbar-actions { display: flex; gap: 8px; align-items: center; } +.ac-date-chip { + display: flex; align-items: baseline; gap: 6px; + padding: 6px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + color: var(--ink-soft); +} +.ac-date-chip strong { font-family: var(--font-display); font-weight: 500; color: var(--ink); } + +/* ── Hero ── */ +.ac-hero { margin-bottom: 36px; } +.ac-hero-eyebrow { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--ink-muted); margin-bottom: 8px; +} +.ac-hero-title { + font-family: var(--font-display); + font-weight: 400; + font-size: 38px; + line-height: 1.15; + letter-spacing: -0.02em; + margin: 0 0 24px; + text-wrap: pretty; + max-width: 720px; +} +.ac-hero-title em { font-style: italic; color: var(--accent-ink); font-weight: 500; } +.ac-hero-aside { color: var(--warn); font-style: italic; font-size: 0.85em; } + +/* ── Month strip ── */ +.ac-monthstrip { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 8px; +} +.ac-monthstrip-cell { + display: flex; flex-direction: column; align-items: center; + padding: 10px 4px; + border-radius: 8px; + position: relative; +} +.ac-monthstrip-num { + font-size: 10px; color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} +.ac-monthstrip-name { + font-family: var(--font-display); + font-size: 14px; + margin-top: 2px; + color: var(--ink-soft); +} +.ac-monthstrip-cell.is-now { + background: var(--accent-soft); +} +.ac-monthstrip-cell.is-now .ac-monthstrip-num { color: var(--accent-ink); } +.ac-monthstrip-cell.is-now .ac-monthstrip-name { color: var(--accent-ink); font-weight: 500; } + +/* ── Progress meter ── */ +.ac-meter { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 18px 22px; + margin-bottom: 36px; +} +.ac-meter-head { + display: flex; justify-content: space-between; align-items: flex-start; + margin-bottom: 18px; +} +.ac-meter-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); } +.ac-meter-num { font-family: var(--font-display); font-size: 32px; line-height: 1; margin-top: 4px; letter-spacing: -0.02em; } +.ac-meter-done { font-weight: 500; } +.ac-meter-of { color: var(--ink-muted); font-weight: 400; } +.ac-meter-pct { font-family: var(--font-display); font-size: 44px; font-weight: 500; color: var(--accent-ink); letter-spacing: -0.03em; line-height: 1; } +.ac-meter-pct-sym { font-size: 0.5em; vertical-align: top; margin-left: 2px; opacity: 0.7; } + +.ac-meter-bar { + display: flex; + gap: 10px; +} +.ac-meter-seg { + background: var(--surface-alt); + border-radius: 8px; + padding: 10px 12px; + position: relative; + overflow: hidden; + min-width: 0; +} +.ac-meter-seg-fill { + position: absolute; left: 0; top: 0; bottom: 0; + opacity: 0.18; + border-right: 2px solid currentColor; + z-index: 0; +} +.ac-meter-seg-label { + position: relative; z-index: 1; + display: flex; align-items: center; gap: 6px; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--ink-soft); + white-space: nowrap; +} +.ac-meter-seg-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; } +.ac-meter-seg-frac { margin-left: auto; color: var(--ink-muted); font-variant-numeric: tabular-nums; letter-spacing: 0.04em; } + +/* ── Shelf (cards row) ── */ +.ac-shelf { margin-bottom: 36px; } +.ac-shelf-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; } +.ac-shelf-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); } +.ac-shelf-eyebrow-warn { color: var(--warn); } +.ac-shelf-title { font-family: var(--font-display); font-weight: 500; font-size: 24px; margin: 4px 0 0; letter-spacing: -0.01em; } +.ac-shelf-count { font-family: var(--font-display); font-size: 28px; color: var(--ink-muted); font-weight: 400; } + +.ac-cards { + display: grid; grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +.ac-card { + display: flex; gap: 14px; align-items: flex-start; + padding: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + text-align: left; + transition: border-color 0.12s, transform 0.12s; +} +.ac-card:hover { border-color: var(--border-strong); transform: translateY(-1px); } +.ac-card-glyph { + width: 44px; height: 44px; + border: 1.5px solid; + border-radius: 10px; + display: grid; place-items: center; + font-family: var(--font-display); font-size: 14px; font-weight: 500; + flex: none; + background: repeating-linear-gradient(135deg, transparent 0 4px, currentColor 4px 5px); + background-blend-mode: overlay; +} +.ac-card-glyph::before { + content: attr(data-i); +} +.ac-card-body { flex: 1; min-width: 0; } +.ac-card-name { font-weight: 500; color: var(--ink); margin-bottom: 2px; } +.ac-card-meta { font-size: 12px; color: var(--ink-soft); margin-bottom: 8px; text-transform: capitalize; } +.ac-card-warn { color: var(--warn); font-size: 18px; } + +/* ── Month dots (in cards) ── */ +.ac-monthdots { display: flex; gap: 3px; } +.ac-monthdot { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--surface-alt); + border: 1px solid var(--border); +} +.ac-monthdot.on { background: var(--accent); border-color: var(--accent); opacity: 0.6; } +.ac-monthdot.here.on { opacity: 1; box-shadow: 0 0 0 1.5px var(--accent-soft); } +.ac-monthdot.here:not(.on) { border-color: var(--ink-muted); } + +/* ── Activity ── */ +.ac-activity { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.ac-activity-row { + display: grid; + grid-template-columns: 12px 1fr auto auto; + gap: 12px; align-items: center; + padding: 12px 18px; + border-bottom: 1px solid var(--border); +} +.ac-activity-row:last-child { border-bottom: none; } +.ac-activity-dot { width: 8px; height: 8px; border-radius: 50%; } +.ac-activity-name { color: var(--ink); } +.ac-activity-cat { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-activity-when { font-size: 12px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } + +/* ── Category groups ── */ +.ac-category-head { + display: flex; justify-content: space-between; align-items: flex-end; + margin-bottom: 24px; padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.ac-category-title { + font-family: var(--font-display); font-weight: 400; + font-size: 44px; letter-spacing: -0.02em; margin: 0; +} +.ac-category-title em { font-style: italic; color: var(--accent-ink); } +.ac-category-meta { font-size: 13px; color: var(--ink-soft); text-align: right; } +.ac-category-meta strong { font-family: var(--font-display); font-weight: 500; color: var(--ink); font-size: 18px; display: block; } + +.ac-group { margin-bottom: 28px; } +.ac-group-head { + display: flex; align-items: baseline; gap: 12px; + margin-bottom: 8px; + padding: 0 4px; +} +.ac-group-title { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; + font-weight: 600; + margin: 0; + color: var(--ink-soft); +} +.ac-group-warn .ac-group-title { color: var(--warn); } +.ac-group-accent .ac-group-title { color: var(--accent-ink); } +.ac-group-done .ac-group-title { color: var(--ink-muted); } +.ac-group-count { font-size: 12px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } + +.ac-list { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} + +/* ── Item row ── */ +.ac-row { border-bottom: 1px solid var(--border); } +.ac-row:last-child { border-bottom: none; } +.ac-row-main { + display: flex; gap: 14px; align-items: center; + width: 100%; + padding: 12px 18px; + text-align: left; + transition: background 0.12s; +} +.ac-row-main:hover { background: var(--surface-alt); } +.ac-row-expanded > .ac-row-main { background: var(--surface-alt); } +.ac-row-donated .ac-row-name { color: var(--ink-muted); } +.ac-row-donated .ac-row-name span:first-child { text-decoration: line-through; text-decoration-color: var(--border-strong); text-decoration-thickness: 1px; } + +.ac-glyph { + width: 32px; height: 32px; + border: 1.5px solid; + border-radius: 8px; + display: grid; place-items: center; + font-family: var(--font-display); + font-size: 11px; font-weight: 500; + flex: none; +} +.ac-row-text { flex: 1; min-width: 0; } +.ac-row-name { + display: flex; align-items: center; gap: 8px; + font-weight: 500; +} +.ac-row-checkmark { color: var(--accent); font-size: 8px; } +.ac-row-meta { + display: flex; gap: 0; font-size: 12px; color: var(--ink-muted); + margin-top: 2px; + flex-wrap: wrap; +} +.ac-row-meta-bit:not(:last-child)::after { content: "·"; margin: 0 8px; color: var(--border-strong); } +.ac-row-meta-italic { font-style: italic; font-family: var(--font-display); } +.ac-row-meta-bells { color: var(--ink-soft); font-variant-numeric: tabular-nums; } +.ac-row-side { display: flex; align-items: center; gap: 10px; flex: none; } +.ac-row-time { font-size: 11px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } +.ac-chevron { color: var(--ink-muted); font-size: 18px; transition: transform 0.18s; line-height: 1; } +.ac-chevron-open { transform: rotate(90deg); color: var(--ink); } + +/* ── Pills ── */ +.ac-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.ac-pill-warn { background: var(--warn-soft); color: var(--warn); } +.ac-pill-accent { background: var(--accent-soft); color: var(--accent-ink); } + +/* ── Expand panel ── */ +.ac-expand { + display: grid; + grid-template-columns: 1fr 240px; + gap: 28px; + padding: 4px 18px 22px 64px; + background: var(--surface-alt); + border-top: 1px solid var(--border); +} +.ac-expand-section { } +.ac-expand-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--ink-muted); margin-bottom: 10px; margin-top: 14px; +} +.ac-monthgrid { + display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; +} +.ac-monthcell { + aspect-ratio: 1; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + display: grid; place-items: center; + position: relative; +} +.ac-monthcell-label { + font-size: 10px; color: var(--ink-muted); + font-family: var(--font-display); +} +.ac-monthcell.on { background: var(--accent-soft); border-color: var(--accent); } +.ac-monthcell.on .ac-monthcell-label { color: var(--accent-ink); } +.ac-monthcell.here { box-shadow: inset 0 0 0 1.5px var(--accent); } +.ac-monthcell.here .ac-monthcell-label { font-weight: 600; } + +.ac-expand-side { + display: flex; flex-direction: column; gap: 10px; + padding-top: 14px; +} +.ac-stat { + display: flex; flex-direction: column; gap: 1px; +} +.ac-stat-num { font-family: var(--font-display); font-size: 22px; font-weight: 500; letter-spacing: -0.01em; } +.ac-stat-num-text { font-size: 16px; } +.ac-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-note { + font-size: 12px; color: var(--ink-soft); font-style: italic; + font-family: var(--font-display); + padding: 8px 10px; + background: var(--surface); + border-left: 2px solid var(--accent); + border-radius: 4px; +} +.ac-donate-btn { + margin-top: auto; + padding: 10px 14px; + border-radius: 8px; + background: var(--accent); + color: var(--surface); + font-weight: 500; + font-size: 13px; + transition: opacity 0.12s; +} +.ac-donate-btn:hover { opacity: 0.88; } +.ac-donate-btn-on { background: var(--surface); color: var(--accent-ink); border: 1px solid var(--border-strong); } + +.ac-empty { + padding: 60px 0; text-align: center; + color: var(--ink-muted); + font-style: italic; + font-family: var(--font-display); +} + +/* ── Stats ── */ +.ac-stats-grid { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; + margin-bottom: 36px; +} +.ac-statcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; +} +.ac-statcard-cat { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; } +.ac-statcard-num { font-family: var(--font-display); font-size: 32px; margin-top: 4px; letter-spacing: -0.02em; } +.ac-statcard-of { color: var(--ink-muted); font-size: 0.6em; margin-left: 4px; } +.ac-statcard-bar { height: 4px; background: var(--surface-alt); border-radius: 2px; overflow: hidden; margin-top: 10px; } +.ac-statcard-fill { height: 100%; } +.ac-statcard-pct { font-size: 11px; color: var(--ink-muted); margin-top: 6px; } + +.ac-chartcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 22px; +} +.ac-chart { + display: grid; grid-template-columns: repeat(12, 1fr); + gap: 6px; + height: 180px; + margin-top: 18px; + align-items: end; +} +.ac-chart-col { display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; } +.ac-chart-bar { flex: 1; width: 100%; display: flex; align-items: flex-end; } +.ac-chart-bar-bg { + width: 100%; + background: var(--surface-alt); + border-radius: 6px 6px 0 0; + position: relative; + overflow: hidden; + border: 1px solid var(--border); + border-bottom: none; +} +.ac-chart-bar-fill { + position: absolute; left: 0; right: 0; bottom: 0; + background: var(--accent); + opacity: 0.5; +} +.ac-chart-num { font-size: 11px; color: var(--ink-soft); font-variant-numeric: tabular-nums; } +.ac-chart-month { font-size: 11px; color: var(--ink-muted); font-family: var(--font-display); } +.ac-chart-col.is-now .ac-chart-bar-bg { border-color: var(--accent); } +.ac-chart-col.is-now .ac-chart-month { color: var(--accent-ink); font-weight: 600; } +.ac-chart-legend { + display: flex; gap: 18px; + font-size: 11px; color: var(--ink-muted); + margin-top: 14px; padding-top: 14px; + border-top: 1px solid var(--border); +} +.ac-chart-legend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; vertical-align: middle; } +.ac-chart-legend-dot-bg { background: var(--surface-alt); border: 1px solid var(--border); } +.ac-chart-legend-dot-fill { background: var(--accent); opacity: 0.5; } + +/* ── Theme: parchment forces Varela ── */ +[data-theme="parchment"] .ac-hero-title em, +[data-theme="parchment"] .ac-category-title em { + font-style: normal; +} + +/* ── Responsive ── */ +@media (max-width: 980px) { + .ac-app { grid-template-columns: 1fr; } + .ac-sidebar { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); } + .ac-main { padding: 24px 20px 60px; } + .ac-cards { grid-template-columns: 1fr; } + .ac-stats-grid { grid-template-columns: repeat(2, 1fr); } + .ac-meter-bar { flex-wrap: wrap; } + .ac-expand { grid-template-columns: 1fr; padding-left: 18px; } + .ac-hero-title { font-size: 28px; } + .ac-monthstrip-name { font-size: 12px; } +} diff --git a/docs/design-handoffs/v0.9_curator/tabs.jsx b/docs/design-handoffs/v0.9_curator/tabs.jsx new file mode 100644 index 0000000..0dee830 --- /dev/null +++ b/docs/design-handoffs/v0.9_curator/tabs.jsx @@ -0,0 +1,258 @@ +/* global React, ACComponents, ACShell */ +const { useState, useMemo } = React; +const { ItemRow, Pill, MonthDots, MONTHS, MONTHS_LONG } = ACComponents; +const { MonthStrip, ProgressMeter } = ACShell; + +// ── Home tab ── +function HomeTab({ data, donated, currentMonth, onJump, onToggle, showLeavingShelf = true, showNewShelf = true }) { + const allItems = useMemo(() => [ + ...data.fish.map(x=>({...x, _cat:"fish"})), + ...data.bugs.map(x=>({...x, _cat:"bugs"})), + ], [data]); + + const leavingSoon = allItems.filter(it => + it.months && it.months.includes(currentMonth) && + !it.months.includes(currentMonth === 12 ? 1 : currentMonth+1) && + !donated[it._cat].has(it.id) + ); + + const newThisMonth = allItems.filter(it => + it.months && it.months.includes(currentMonth) && + !it.months.includes(currentMonth === 1 ? 12 : currentMonth-1) && + !donated[it._cat].has(it.id) + ); + + const stillNeeded = allItems.filter(it => + it.months && it.months.includes(currentMonth) && !donated[it._cat].has(it.id) + ).length; + + return ( +
+
+
Available in {MONTHS_LONG[currentMonth-1]}
+

+ {stillNeeded} creatures still to donate this month. + {leavingSoon.length > 0 && <>
{leavingSoon.length} are leaving soon.} +

+ +
+ + {showLeavingShelf && leavingSoon.length > 0 && ( +
+
+
+
Leaving end of month
+

Catch these before {MONTHS_LONG[currentMonth-1]} ends

+
+ {leavingSoon.length} +
+
+ {leavingSoon.slice(0,6).map(it => ( + + ))} +
+
+ )} + + {showNewShelf && newThisMonth.length > 0 && ( +
+
+
+
Just arrived
+

New this month

+
+ {newThisMonth.length} +
+
+ {newThisMonth.slice(0,6).map(it => ( + + ))} +
+
+ )} + +
+
+
+
Recent
+

Latest donations

+
+
+
+ {data.recentActivity.map((a,i) => ( +
+
+
{a.item}
+
{a.category}
+
{a.when}
+
+ ))} +
+
+
+ ); +} + +// ── Category tab (sectioned list) ── +function CategoryTab({ category, items, donated, onToggle, currentMonth, search }) { + const [expanded, setExpanded] = useState(null); + + const lowered = search.trim().toLowerCase(); + const filtered = lowered + ? items.filter(i => i.name.toLowerCase().includes(lowered) || (i.basedOn||"").toLowerCase().includes(lowered)) + : items; + + const isAvail = (it) => it.months ? it.months.includes(currentMonth) : true; + const isLeavingSoon = (it) => it.months && it.months.includes(currentMonth) && + !it.months.includes(currentMonth===12?1:currentMonth+1); + + const groups = useMemo(() => { + const leaving = [], avail = [], done = [], locked = []; + for (const it of filtered) { + const isDonated = donated.has(it.id); + if (isDonated) done.push(it); + else if (isLeavingSoon(it)) leaving.push(it); + else if (isAvail(it)) avail.push(it); + else locked.push(it); + } + const byName = (a,b)=>a.name.localeCompare(b.name); + return [ + { id: "leaving", label: "Leaving this month", items: leaving.sort(byName), tone: "warn" }, + { id: "avail", label: "Available now", items: avail.sort(byName), tone: "accent" }, + { id: "locked", label: "Out of season", items: locked.sort(byName), tone: "muted" }, + { id: "done", label: "Already donated", items: done.sort(byName), tone: "done" }, + ].filter(g => g.items.length > 0); + }, [filtered, donated, currentMonth]); + + return ( +
+ {groups.map(g => ( +
+
+

{g.label}

+ {g.items.length} +
+
+ {g.items.map(it => ( + onToggle(it.id)} + currentMonth={currentMonth} + expanded={expanded === it.id} + onExpand={()=>setExpanded(expanded===it.id?null:it.id)} + /> + ))} +
+
+ ))} + {groups.length === 0 && ( +
No matches for "{search}"
+ )} +
+ ); +} + +// ── Stats tab ── +function StatsTab({ data, donated, currentMonth }) { + const counts = { + fish: { donated: donated.fish.size, total: data.fish.length }, + bugs: { donated: donated.bugs.size, total: data.bugs.length }, + fossils: { donated: donated.fossils.size, total: data.fossils.length }, + art: { donated: donated.art.size, total: data.art.length }, + }; + + // monthly bars: how many fish+bugs available per month + const monthlyAvail = MONTHS.map((_, i) => { + const m = i+1; + let avail = 0, donatedCount = 0; + for (const cat of ["fish","bugs"]) { + for (const it of data[cat]) { + if (it.months && it.months.includes(m)) { + avail++; + if (donated[cat].has(it.id)) donatedCount++; + } + } + } + return { avail, donatedCount, m }; + }); + const maxAvail = Math.max(...monthlyAvail.map(x=>x.avail)); + + return ( +
+
+ {Object.entries(counts).map(([k,v]) => ( +
+
{k}
+
+ {v.donated} + / {v.total} +
+
+
+
+
{Math.round((v.donated/v.total)*100)}% complete
+
+ ))} +
+ +
+
+
+
Yearly rhythm
+

Fish & bug availability by month

+
+
+
+ {monthlyAvail.map(({avail, donatedCount, m}, i) => ( +
+
+
+
+
+
+
{avail}
+
{MONTHS[i]}
+
+ ))} +
+
+ Available + Already donated +
+
+
+ ); +} + +window.ACTabs = { HomeTab, CategoryTab, StatsTab }; diff --git a/docs/design-handoffs/v0.9_curator/theme.js b/docs/design-handoffs/v0.9_curator/theme.js new file mode 100644 index 0000000..6d53758 --- /dev/null +++ b/docs/design-handoffs/v0.9_curator/theme.js @@ -0,0 +1,101 @@ +// Theme tokens. Exposed as CSS variables on :root via setTheme(). +window.THEMES = { + meadow: { + label: "Meadow", + bg: "#F4EFE3", + surface: "#FFFDF7", + surfaceAlt: "#F8F2E2", + ink: "#23241F", + inkSoft: "#5C5848", + inkMuted: "#8A8470", + border: "#E2D9C3", + borderStrong: "#CFC4A8", + accent: "oklch(0.55 0.09 150)", // moss + accentSoft: "oklch(0.55 0.09 150 / 0.12)", + accentInk: "oklch(0.32 0.06 150)", + warn: "oklch(0.62 0.12 50)", // clay + warnSoft: "oklch(0.62 0.12 50 / 0.14)", + chipFish: "oklch(0.62 0.08 230)", + chipBug: "oklch(0.6 0.1 130)", + chipFossil: "oklch(0.55 0.06 60)", + chipArt: "oklch(0.58 0.08 320)", + fontDisplay: "'Fraunces', 'Playfair Display', Georgia, serif", + fontUi: "'Inter', system-ui, -apple-system, sans-serif", + }, + parchment: { + label: "Parchment (current)", + bg: "#EFE6D0", + surface: "#F5E9D4", + surfaceAlt: "#EADCBE", + ink: "#2A2A2A", + inkSoft: "#5a4a35", + inkMuted: "#8B7A5C", + border: "#E0D2B0", + borderStrong: "#C9B98D", + accent: "#3CA370", + accentSoft: "rgba(60,163,112,0.14)", + accentInk: "#1F5A3C", + warn: "#C76B3F", + warnSoft: "rgba(199,107,63,0.16)", + chipFish: "#3F6FA8", + chipBug: "#7B9C3A", + chipFossil: "#7B5E3B", + chipArt: "#8B5E94", + fontDisplay: "'Varela Round', system-ui, sans-serif", + fontUi: "'Varela Round', system-ui, sans-serif", + }, + midnight: { + label: "Midnight", + bg: "#16181F", + surface: "#1E212A", + surfaceAlt: "#262A35", + ink: "#EFEAE0", + inkSoft: "#B5AE9E", + inkMuted: "#7E7866", + border: "#2F3340", + borderStrong: "#3D4250", + accent: "oklch(0.7 0.09 150)", + accentSoft: "oklch(0.7 0.09 150 / 0.16)", + accentInk: "oklch(0.85 0.06 150)", + warn: "oklch(0.72 0.12 50)", + warnSoft: "oklch(0.72 0.12 50 / 0.18)", + chipFish: "oklch(0.72 0.09 230)", + chipBug: "oklch(0.74 0.09 130)", + chipFossil: "oklch(0.7 0.06 60)", + chipArt: "oklch(0.72 0.08 320)", + fontDisplay: "'Fraunces', Georgia, serif", + fontUi: "'Inter', system-ui, sans-serif", + }, + sakura: { + label: "Sakura", + bg: "#FBF0EE", + surface: "#FFFCFB", + surfaceAlt: "#F7E5E1", + ink: "#2C2228", + inkSoft: "#705661", + inkMuted: "#9C8590", + border: "#EFD9D3", + borderStrong: "#DEBDB4", + accent: "oklch(0.6 0.11 10)", // rose + accentSoft: "oklch(0.6 0.11 10 / 0.12)", + accentInk: "oklch(0.4 0.09 10)", + warn: "oklch(0.6 0.13 50)", + warnSoft: "oklch(0.6 0.13 50 / 0.14)", + chipFish: "oklch(0.6 0.08 230)", + chipBug: "oklch(0.6 0.09 130)", + chipFossil: "oklch(0.55 0.06 60)", + chipArt: "oklch(0.58 0.08 320)", + fontDisplay: "'Fraunces', Georgia, serif", + fontUi: "'Inter', system-ui, sans-serif", + }, +}; + +window.applyTheme = function applyTheme(name) { + const t = window.THEMES[name] || window.THEMES.meadow; + const root = document.documentElement; + for (const [k, v] of Object.entries(t)) { + if (k === "label") continue; + root.style.setProperty(`--${k.replace(/([A-Z])/g, "-$1").toLowerCase()}`, v); + } + root.dataset.theme = name; +}; diff --git a/docs/design-handoffs/v0.9_curator/tweaks-panel.jsx b/docs/design-handoffs/v0.9_curator/tweaks-panel.jsx new file mode 100644 index 0000000..5f8f95a --- /dev/null +++ b/docs/design-handoffs/v0.9_curator/tweaks-panel.jsx @@ -0,0 +1,425 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/docs/v0.9-plan.md b/docs/v0.9-plan.md new file mode 100644 index 0000000..9919357 --- /dev/null +++ b/docs/v0.9-plan.md @@ -0,0 +1,660 @@ +# v0.9.0-beta — Implementation Plan + +**Status:** ✅ Shipped 2026-05-04 — all 10 phases merged; release branch cut from `development` +**Target tag:** `v0.9.0-beta` +**Planned milestone:** Following v0.8.2-alpha (shipped 2026-05-01) +**Document date:** 2026-05-03 +**Author:** Bea + Claude Code, session 2026-05-03 + +## Design Handoff References + +| Pass | Folder | Covers | +|---|---|---| +| v0.9 (first pass) | `docs/design-handoffs/v0.9_curator/` | Design language, palette, type scale, all screens incl. CategoryTab sectioning | +| v0.9.1 (second pass) | `docs/design-handoffs/v0.9.1_curator/` | TownManager drawer, GlobalSearchDropdown, Sea Creatures nav integration, `chip-sea` token | +| v0.9.2 (third pass, most current) | `docs/design-handoffs/v0.9.2_curator/` | ProgressMeter 5-segment responsive, scroll-to + highlight, Settings page | + +**Read the handoffs in order.** Where a topic is covered by multiple passes, the later pass takes precedence. Treat the v0.9 README as the canonical base spec for anything not overridden by v0.9.1 or v0.9.2. + +> **Codename note:** The design exercise is codenamed "Curator" internally — this appears in folder names and prototype files. "Curator" is **not** a rename of the app. No user-facing copy should say "Curator." + +--- + +## 1. Goals + +### User-facing + +- Replace the horizontal tab bar + header bar with a persistent left sidebar that shows the active town, per-category donation counts, and nav links. Users always know where they are and how complete each category is. +- Surface seasonal urgency on the Home tab (leaving-soon shelf, new-this-month shelf, hero stat) so players don't miss catches. +- Unify town switching, creating, and editing into a single right-side TownManager drawer — no more disjointed modal + dropdown combo. +- Replace the flat A–Z item list with a sectioned view: Leaving this month → Available now → Out of season → Already donated. Players see what matters first. +- Make the app fast and clear on mobile (≥390px) with a single breakpoint at 980px. +- Provide a Settings page with app info and data reset actions. + +### Architectural + +- Retire `MuseumHeader`, `TabBar`, `TownSwitcher`, `CreateTownModal`, `EditTownModal` — replace with `Sidebar` and `TownManager`. +- Retire `GlobalSearchBar`, `GlobalSearchResults`, `SearchHistoryPopover` — replace with `GlobalSearchDropdown`. +- Retire `CategoryProgress` component — progress display moves into the sidebar nav counts and ProgressMeter. +- Introduce Meadow design tokens as CSS custom properties in `index.css`; retire Varela Round; adopt Fraunces + Inter. +- Deprecate `playerName` field from the `Town` type. +- Add a `Settings` route (`/settings`) inside the main column. +- Add `highlightId` app-level state for scroll-to + expand behavior. + +### Version bump rationale + +v0.9 shifts the version suffix from `-alpha` to `-beta`. Alpha denoted pre-feature-complete internal builds. Beta denotes feature-complete, publicly accessible builds undergoing broader testing. v0.9.0-beta is the first release with the full redesigned UI — it warrants the milestone bump. + +--- + +## 2. In-Scope Summary + +- Meadow design tokens + font swap (Fraunces + Inter replacing Varela Round) +- Sidebar shell layout (280px fixed left, 980px breakpoint collapses above main) +- Settings page (About + Danger zone only — see locked decision #3) +- TownManager drawer (replaces three existing components — see locked decision #1) +- CollectibleRow + ItemExpandPanel restyle (glyph tile, meta line, sectioning, pills, pulse animation) +- HomeTab rebuild (hero + month strip + shelves + ProgressMeter) +- CategoryTab sectioning (4 groups: Leaving / Available / Out of season / Already donated) +- GlobalSearchDropdown (5 groups for ACNL/ACNH, keyboard nav, search history in localStorage) +- StatsTab rebuild (4-up or 5-up cards gated by game, updated chart) +- Mobile responsive verification pass (single 980px breakpoint, touch targets ≥44px) +- Scroll-to + highlight wiring across Home shelves and GlobalSearchDropdown +- Sea creatures in ProgressMeter (5th segment, ACNL/ACNH only) +- Sea creatures in StatsTab (5th card, ACNL/ACNH only — see locked decision #4) +- Open issues #59, #60, PR #45 housekeeping + +--- + +## 3. Out of Scope for v0.9.0-beta + +| Item | Reason | +|---|---| +| First-run onboarding flow | Design not yet started; separate parallel workstream | +| PWA service worker / offline support | Bea explicitly deferred — v0.9 ships manifest + icons + add-to-home only ("PWA-lite") | +| ACGCN custom Procreate icons | Bea is painting them on her own timeline; monogram glyphs are the v0.9 fallback per the design | +| Themes beyond Meadow (Parchment, Midnight, Sakura) | Locked decision #2 — see below | +| Styled confirm dialog in danger zone | v1.0 polish item; native `confirm()` is acceptable for v0.9 | +| Migration UX for changing a town's game | Game is immutable post-creation (locked decision #1); no migration UI needed | +| Shadow size display surface (issue #59) | Data + type exist; roll in if cheap during ItemExpandPanel restyle, otherwise carry to v0.9.x | +| Component renames beyond what's listed | No broader rename pass; only the specific retirements listed in the Glossary | + +--- + +## 4. Locked Design Decisions + +These decisions were made explicitly in conversation with Bea on 2026-05-03 and are binding for v0.9.0-beta. Future sessions must not reverse these without a new explicit conversation. + +### Decision 1 — Game is immutable post-creation + +**Rule:** The TownManager edit form must NOT include a game ``: +```jsx + +``` +**Drop this field entirely.** Display game as a read-only badge (same as the view state: `{game?.short}`). Do not carry `gameId` in the `onSave` patch object. + +--- + +### Decision 2 — Ship Meadow theme only + +**Rule:** Parchment, Midnight, and Sakura themes are dropped for v0.9. Meadow is the only theme. + +**Why:** Bea's words: *"parchment looks a little poorly baked and it is more work to maintain. We can add themes later on."* + +**Implications:** +- No `[data-theme="..."]` attribute branching in CSS or JS +- No theme selector in Settings (the Appearance section is omitted entirely — see Decision 3) +- No Zustand persistence for `theme` +- Varela Round is fully retired — Fraunces + Inter become the sole type stack +- The v0.9.2 design's Settings → Appearance section (swatch cards) is not built for v0.9 + +Themes may return in a future v0.9.x or v1.x when there are real alternatives to choose from. + +--- + +### Decision 3 — Settings page = About + Danger zone only + +**Rule:** The Settings page ships with two sections: About and Danger zone. The Appearance section (theme switcher) is not built. + +**Why:** With only one theme (Meadow), there is no choice to present. An Appearance section with a single card would feel hollow and could mislead users into expecting more options. + +**Sections in order:** +1. **About** — version, source link, live storage summary (town count + total donations), credits/disclaimer +2. **Danger zone** — "Reset donations for active town" (ghost button, `confirm()`) + "Reset everything" (solid red, `confirm()`) + +Appearance returns when there are multiple themes to offer (v0.9.x or v1.x). + +--- + +### Decision 4 — Sea creatures in StatsTab as a 5th card + +**Rule:** StatsTab shows 4 category cards for ACGCN/ACWW/ACCF, and 5 category cards (adding sea) for ACNL/ACNH. Gating is identical to home shelves, sidebar nav, and ProgressMeter. + +**Why:** Sea is already a first-class category in sidebar nav, ProgressMeter, global search, and home shelves. Excluding it from Stats would be an inconsistency — players would see sea progress everywhere except the Stats view. The v0.9.2 spec doesn't explicitly include this; implementation extends it for consistency. + +--- + +### Decision 5 — `playerName` deprecated from Town type + +**Rule:** Remove `playerName` from the `Town` interface in `src/lib/types.ts`. Do not display it or collect it in any form. + +**Why:** Bea's words: *"really not important tbh."* It was never surfaced in the v0.9 design. Re-addable later if a user need surfaces. + +**Migration:** Zustand's `persist` middleware will simply ignore the field on existing stored data — no migration step required. Any existing `playerName` values in localStorage will be silently dropped on next write. + +--- + +### Decision 6 — Hemisphere exposed only for ACNH + +**Rule:** In TownManager (both the create form and the edit form), the hemisphere toggle is only shown when `gameId === "acnh"`. + +**Why:** The ACNL sea creature and fish/bug data we shipped is NH-only — no SH month variant exists in our published JSON. Exposing a hemisphere toggle for ACNL would produce incorrect month availability display. This matches the guard condition in the v0.9.2 design. + +--- + +### Decision 7 — Native `confirm()` for danger zone + +**Rule:** Both danger zone actions use native browser `confirm()` dialogs for v0.9. + +**Why:** A styled confirmation dialog is a v1.0 polish item. The v0.9.2 spec explicitly calls this out: *"Both actions in the demo go through `confirm()` placeholders; production should wire a proper styled confirm dialog (out of scope for this design pass)."* + +--- + +### Decision 8 — Search includes `basedOn` field for art + +**Rule:** Art search matches against both `name` and `basedOn` (the real-world painting name). Searching "Leonardo" must surface Famous Painting. + +**Why:** This is the current behavior and is explicitly confirmed in the v0.9.2 README: *"`basedOn` matching confirmed in the existing search filter (`it.basedOn.toLowerCase().includes(q)`) — 'Leonardo' surfaces Famous Painting. No change needed."* + +Do not break this during GlobalSearchDropdown replacement. + +--- + +### Decision 9 — Sea creatures in GlobalSearchDropdown + +**Rule:** For ACNL/ACNH towns, GlobalSearchDropdown shows a 5th result group for sea creatures. Empty groups are hidden, same as other categories. Row treatment matches fish/bugs/fossils. + +**Why:** Sea is a first-class category. Excluding it from search while it appears in nav, ProgressMeter, and HomeTab shelves would be a confusing inconsistency. Explicitly confirmed in the v0.9.2 README. + +--- + +### Decision 10 — Scroll-to + highlight behavior + +**Rule:** When a Home shelf card or GlobalSearchDropdown result is clicked, the target row is: +1. Scrolled into view (`scrollIntoView({ behavior: 'smooth', block: 'center' })`) +2. Auto-expanded (inline detail panel opens) +3. Highlighted with a 1.4s `accent-soft` background pulse + +Behavior is identical on mobile. No separate codepath for small screens. + +**CSS (verbatim from v0.9.2 README):** +```css +.ac-row-pulse > .ac-row-main { animation: ac-row-pulse 1.4s ease-out; } +@keyframes ac-row-pulse { + 0% { background: var(--accent-soft); } + 60% { background: color-mix(in oklch, var(--accent) 16%, transparent); } + 100% { background: var(--surface-alt); } +} +``` + +**Wiring (verbatim from v0.9.2 README):** +> `App` owns `highlightId` state. Setters: `jumpTo(cat, id)` (Home cards) and `onJump` in `GlobalSearchDropdown` both `setTab(cat) + setHighlightId(id)`. `CategoryTab` receives `highlightId` + `onHighlightConsumed`. On change, it sets `expanded = highlightId`, waits a frame for the row to render, then queries `[data-row-id="…"]`, scrolls + adds `.ac-row-pulse`, and clears the parent state via `onHighlightConsumed` so re-clicking the same item triggers the effect again. `ItemRow` stamps `data-row-id={item.id}` on its outer div. + +--- + +## 5. Visual Direction + +### Palette — Meadow (only theme for v0.9) + +All values must be defined as CSS custom properties in `src/index.css` `@theme` block and mirrored in `src/lib/colors.ts`. + +| Token | Value | Usage | +|---|---|---| +| `--bg` | `#F4EFE3` | Page background | +| `--surface` | `#FFFDF7` | Card backgrounds | +| `--surface-alt` | `#F8F2E2` | Hover, expand panel, secondary surfaces | +| `--ink` | `#23241F` | Primary text | +| `--ink-soft` | `#5C5848` | Secondary text | +| `--ink-muted` | `#8A8470` | Tertiary text, eyebrows | +| `--border` | `#E2D9C3` | Subtle borders, dividers | +| `--border-strong` | `#CFC4A8` | Hover borders, separator dots, strikethrough | +| `--accent` | `oklch(0.55 0.09 150)` | Primary moss green — buttons, progress bars | +| `--accent-soft` | `oklch(0.55 0.09 150 / 0.12)` | Nav active bg, accent pill bg, current-month bg | +| `--accent-ink` | `oklch(0.32 0.06 150)` | Accent text on light bg | +| `--warn` | `oklch(0.62 0.12 50)` | Clay — leaving-soon | +| `--warn-soft` | `oklch(0.62 0.12 50 / 0.14)` | Warn pill bg | +| `--chip-fish` | `oklch(0.62 0.08 230)` | Fish category tint | +| `--chip-bugs` | `oklch(0.6 0.1 130)` | Bugs category tint | +| `--chip-fossils` | `oklch(0.55 0.06 60)` | Fossils category tint | +| `--chip-art` | `oklch(0.58 0.08 320)` | Art category tint | +| `--chip-sea` | `oklch(0.58 0.09 200)` | Sea creatures category tint (v0.9.1) | + +Category chip colors are **identity tokens** — per v0.9.1 README: *"Category chip colors are identity tokens — they should remain consistent across all themes. Only `accent`, `bg`, `surface`, and `ink` shift between themes."* + +### Typography + +- **Display:** `'Fraunces', Georgia, serif` — 9..144 opsz axis. Weights 400/500/600. Italic 400/500 for accents. +- **UI:** `'Inter', system-ui, sans-serif` — weights 400/500/600/700. +- Load both via Google Fonts. Remove the Varela Round `@import` from `src/index.css`. + +| Usage | Spec | +|---|---| +| Hero headline | Fraunces 38 / 400 / lh 1.15 / tracking -0.02em | +| Category h1 | Fraunces 44 / 400 / lh 1.0 / tracking -0.02em | +| Card / shelf title | Fraunces 24 / 500 / lh 1.2 / tracking -0.01em | +| Section eyebrow | Inter 11 / 600 / lh 1.4 / tracking 0.12em / UPPERCASE | +| Body | Inter 14 / 400 / lh 1.5 / tracking -0.005em | +| Row name | Inter 14 / 500 | +| Row meta | Inter 12 / 400 | +| Stat value | Fraunces 22 / 500 / tracking -0.01em | +| Tabular numbers | `font-variant-numeric: tabular-nums` everywhere counts/values appear | + +### Spacing and Radii + +- Card radius: 12px (lists, stat cards), 14px (hero cards, town card) +- Pill radius: 999px +- Button radius: 8px; glyph tile: 8–10px +- Section gaps: 36px between hero/shelves/groups; 28px between groups +- Sidebar padding: 28×22px; main column padding: 32×48px (24×20px mobile) +- No box shadows. Hover lift is `transform: translateY(-1px)` + border color change only. + +### Motion + +- Row background + card border + donate button opacity hover transitions: 0.12s +- Chevron rotation: 0.18s +- Scroll-to + highlight: `scrollIntoView({ behavior: 'smooth', block: 'center' })` + 1.4s ease-out pulse (see Decision 10 CSS) + +### Mobile breakpoint + +Single breakpoint at **980px**. Below 980px: +- Sidebar stacks above main column (not hidden) +- ProgressMeter 5-segment wraps to 2 rows (`.ac-meter-5` modifier — see Phase 6) +- Main column padding reduces to 24×20px + +TownManager collapses to bottom sheet at ≤**720px**. +Settings page sections collapse at ≤**700px**. + +--- + +## 6. Implementation Phasing + +Phases are ordered by dependency. Each phase targets the `development` branch via PR on its own feature branch. Each PR must include: CHANGELOG entry, CLAUDE.md updates, passing `npm run build` + `npm test`. + +Parallel work is allowed within a phase where components are independent. The phase order itself is locked. + +--- + +### Phase 1 — Tokens + Fonts ✅ shipped (PR #63) + +**Branch:** `feature/v09-phase-1-tokens` +**Goal:** Pre-work only. No visible UI changes. + +**Work:** +- Add Meadow CSS custom properties to `src/index.css` `@theme` block (all tokens from section 5) +- Add `@import` for Fraunces (variable, weights 400/500/600) + Inter (400/500/600/700) via Google Fonts +- Remove Varela Round `@import` and any `font-family: 'Varela Round'` declarations +- Update `src/lib/colors.ts`: add a `meadow` export alongside the existing `colors` export; keep `colors` intact as it may still be referenced elsewhere until later phases clean it up +- Add `--chip-sea` to the token set + +**Files likely affected:** `src/index.css`, `src/lib/colors.ts` + +**Definition of done:** Build passes; no font flash; grep confirms zero remaining `Varela Round` references. + +--- + +### Phase 2 — Sidebar + Shell Layout ✅ shipped (PR #65) + +**Branch:** `feature/v09-phase-2-sidebar` +**Goal:** Replace `MuseumHeader` + `TabBar` + `TownSwitcher` with a 280px left sidebar. React Router routes and URL structure are fully preserved. + +**Work:** +- Create `src/components/Sidebar.tsx` — CSS grid `280px 1fr`, sticky full-height sidebar, max-width 1440px centered +- Sidebar contents (top → bottom per v0.9 README): + - Brand: museum-roof SVG monogram (38×38) + app wordmark (Fraunces 20/600) + italic sub + - Active town card: eyebrow "ACTIVE TOWN", town name (Fraunces 22/500), meta line with game + hemisphere, "Switch town ›" link that opens TownManager (stub for Phase 4) + - Nav: vertical list, `` per tab. Each shows label left + `donated/total` count right (tabular-nums, dimmed slash). Active: `accent-soft` bg + `accent-ink` text + 500 weight + 9px radius. Hover: `surface-alt` bg. + - Sea entry gated on `gameId in {acnl, acnh}` + - Footer: Export CSV link + Settings link (routes to `/settings`, Phase 3), 1px top border +- Below 980px: sidebar stacks above main (CSS grid row change, not a hidden/toggle) +- Remove `MuseumHeader.tsx`, `TabBar.tsx`, `TownSwitcher.tsx` (or mark as dead — clean delete if no other consumers) +- Update `App.tsx` + `ACCanvas.tsx` to mount `Sidebar` instead of the old header/tab pattern +- Wire existing `useCategoryStats` hook into sidebar nav counts + +**Files likely affected:** `src/App.tsx`, `src/components/ACCanvas.tsx`, `src/components/MuseumHeader.tsx` (delete), `src/components/TabBar.tsx` (delete), `src/components/TownSwitcher.tsx` (delete), new `src/components/Sidebar.tsx` + +**Dependencies:** Phase 1 (tokens must exist) + +**Definition of done:** All 5 tab routes load correctly; counts display; nav active state tracks URL; sea nav entry appears/disappears by game; 980px sidebar stacks above main. + +--- + +### Phase 3 — Settings Page ✅ shipped (PR #66) + +**Branch:** `feature/v09-phase-3-settings` +**Goal:** Full-page settings route. Two sections: About + Danger zone. + +**Work:** +- Create `src/components/SettingsPage.tsx` — full-page route inside main column; when mounted, main column renders `` instead of the active tab +- Add `/settings` route in `App.tsx` router; sidebar Settings link navigates to it; any tab click or Esc closes it (navigate back to active town/tab) +- **About section:** `dl/dt/dd` list in a single card — version label (`v0.9.0-beta`), source (GitHub link, `target="_blank"`), live storage summary (derive from store: town count, total donations), credits disclaimer ("Companion app for the Animal Crossing series. Not affiliated with Nintendo.") +- **Danger zone section:** Red-tinted card (`oklch(0.62 0.12 25)` family per v0.9.2). Two actions: + - "Reset donations for active town" — ghost button, `confirm()` guard, calls store action + - "Reset everything" — solid red button, `confirm()` guard, clears towns + donations + search history +- Responsive: sections collapse at ≤700px; title shrinks 56→40px; danger buttons full-width at ≤700px +- Esc key closes settings (navigate back) +- **No Appearance section** — locked decision #3 + +**Files likely affected:** `src/App.tsx` (new route), new `src/components/SettingsPage.tsx`, `src/lib/store.ts` (confirm reset actions exist) + +**Dependencies:** Phase 2 (sidebar footer link must exist to wire) + +**Definition of done:** Settings route loads; About data is live (not hardcoded); both danger zone actions work with `confirm()`; Esc navigates back; responsive at 700px. + +--- + +### Phase 4 — TownManager Drawer ✅ shipped (PR #67) + +**Branch:** `feature/v09-phase-4-townmanager` +**Goal:** Replace `CreateTownModal`, `EditTownModal`, and the stub "Switch town" link from Phase 2 with a single right-side TownManager drawer. + +**Work:** +- Create `src/components/TownManager.tsx` — right-side drawer, 420px wide, scrim overlay, mounts at **layout level** (in `App.tsx`, above the router outlet), so it renders on all routes including category tabs. This resolves the v0.8.1 stopgap (greyed-out buttons). +- View state: list of towns. Each row shows radio mark, town name, game badge (short label, e.g. "NL"), game full name, hemisphere (if ACNH), donated count. +- Active town: `--accent` border, filled `●` mark +- Edit state (row-level inline): **`name` field only** + hemisphere toggle (ACNH only). **No game ` setName(e.target.value)} + autoFocus + /> + +
+ Game +
+ {game.shortName} + {game.name} +
+ + Game can't be changed after creation. + +
+ {showHemisphere && ( + + )} +
+
+ {canDelete && ( + + )} +
+ + +
+
+
+ ); + } + + return ( +
+ + +
+ ); +} + +interface NewTownFormProps { + onCancel: () => void; + onCreate: (input: { + name: string; + gameId: GameId; + hemisphere: Hemisphere | null; + }) => void; + cancellable: boolean; +} + +function NewTownForm({ onCancel, onCreate, cancellable }: NewTownFormProps) { + const [name, setName] = useState(''); + const [gameId, setGameId] = useState('ACNH'); + const [hemisphere, setHemisphere] = useState('NH'); + const showHemisphere = HEMISPHERE_GAMES.has(gameId); + + return ( +
+ setName(e.target.value)} + /> + + {showHemisphere && ( +
+ + +
+ )} +
+ {cancellable && ( + + )} + +
+
+ ); +} diff --git a/src/components/TownSwitcher.tsx b/src/components/TownSwitcher.tsx deleted file mode 100644 index efd381a..0000000 --- a/src/components/TownSwitcher.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { ChevronDown, Plus, Pencil } from 'lucide-react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { useAppStore } from '../lib/store'; - -// Museum category tabs where the edit/new-town modals can't render (ACCanvas -// is not yet in the layout tree on these routes). Greyed out as a v0.8.1 -// stopgap; proper fix deferred to the v0.9 UI revamp. -const MODAL_BLOCKED_TABS = new Set(['fish', 'bugs', 'fossils']); -const BLOCKED_TOOLTIP = - 'Switch to Home, Search, or Recent Donations to edit your town'; - -export function TownSwitcher({ - onCreateNew, - onEditTown, -}: { - onCreateNew: () => void; - onEditTown: () => void; -}) { - const towns = useAppStore(s => s.towns); - const activeTownId = useAppStore(s => s.activeTownId); - const navigate = useNavigate(); - const { tab } = useParams<{ tab?: string }>(); - const modalBlocked = MODAL_BLOCKED_TABS.has(tab ?? ''); - const [open, setOpen] = useState(false); - const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 }); - const triggerRef = useRef(null); - - // Close the dropdown whenever the active town changes so stale-open state - // can't persist across town switches (fixes duplicate-entry visual bug). - useEffect(() => { - setOpen(false); - }, [activeTownId]); - - const activeTown = towns.find(t => t.id === activeTownId); - - // If activeTown can't be resolved (e.g. during a state transition), still - // render the + button so the user can always create a new town. - if (!activeTown) { - return ( -
- -
- ); - } - - return ( - <> -
-
- {towns.length > 1 ? ( - - ) : ( - - {activeTown.name} - - )} - - - - -
- - {open && towns.length > 1 && ( - <> -
setOpen(false)} - /> -
- {towns - .filter(t => t.id !== activeTownId) - .map((town, i) => ( - - ))} -
- - )} -
- - ); -} diff --git a/src/components/modals/CreateTownModal.tsx b/src/components/modals/CreateTownModal.tsx deleted file mode 100644 index 0c72cf6..0000000 --- a/src/components/modals/CreateTownModal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useState } from 'react'; -import { X } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { useAppStore } from '../../lib/store'; -import { type GameId, GAMES } from '../../lib/types'; -import { TownNameFields } from '../shared/TownNameFields'; - -export function CreateTownModal({ - isOpen, - onClose, - required, -}: { - isOpen: boolean; - onClose: () => void; - required: boolean; -}) { - const createTown = useAppStore(s => s.createTown); - const navigate = useNavigate(); - const [name, setName] = useState(''); - const [playerName, setPlayerName] = useState(''); - const [gameId, setGameId] = useState('ACGCN'); - - if (!isOpen) return null; - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!name.trim() || !playerName.trim()) return; - const town = createTown(name.trim(), playerName.trim(), gameId); - onClose(); - navigate(`/town/${town.id}/home`); - } - - return ( -
-
e.stopPropagation()} - > -
-

- New Town -

- {!required && ( - - )} -
- -
- -
- - -
- - -
-
- ); -} diff --git a/src/components/modals/DetailModal.tsx b/src/components/modals/DetailModal.tsx deleted file mode 100644 index 5ca1b8e..0000000 --- a/src/components/modals/DetailModal.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import React, { useRef, useEffect } from 'react'; -import { CheckCircle2, X } from 'lucide-react'; -import { CATEGORY_META } from '../../lib/categoryMeta'; -import { MonthGrid } from '../shared/MonthGrid'; -import { - displayName, - rowSubtitle, - itemBells, - itemMonths, - itemNotes, - formatTimestamp, - type AnyItem, -} from '../../lib/utils'; -import type { CategoryId } from '../../lib/types'; - -export function DetailModal({ - item, - category, - checked, - donatedAt, - onToggle, - onClose, - hemisphere, -}: { - item: AnyItem; - category: CategoryId; - checked: boolean; - donatedAt?: string; - onToggle: () => void; - onClose: () => void; - hemisphere?: 'NH' | 'SH'; -}) { - const { Icon, label } = CATEGORY_META[category]; - const name = displayName(item, category); - const subtitle = rowSubtitle(item, category); - const bells = itemBells(item, category); - const months = itemMonths(item, category, hemisphere); - const notes = itemNotes(item); - - // Guard against ghost-clicks: the same click that opens the modal can land on - // the newly-mounted backdrop before the browser event loop settles. Defer - // closeability by one tick so backdrop clicks only register on subsequent taps. - const closeable = useRef(false); - useEffect(() => { - const id = setTimeout(() => { - closeable.current = true; - }, 0); - return () => clearTimeout(id); - }, []); - - return ( -
{ - if (closeable.current) onClose(); - }} - > -
e.stopPropagation()} - > -
-
-
-
- -
-
-
-
- -
-
-
- {label} -
-

- {name} -

- {subtitle && ( -

- {subtitle} -

- )} -
-
- - {bells != null && ( -
-
- Value -
-
- {bells.toLocaleString()} Bells -
-
- )} - - {category !== 'fossils' && category !== 'art' && ( -
-
- Availability -
- - {(!months || months.length === 0) && ( -

- Active all year -

- )} -
- )} - - {notes && ( -
- {notes} -
- )} - - {checked && donatedAt && ( -
-
- Donated -
-
- {formatTimestamp(donatedAt)} -
-
- )} - - -
-
-
- ); -} diff --git a/src/components/modals/EditTownModal.tsx b/src/components/modals/EditTownModal.tsx deleted file mode 100644 index c5ca216..0000000 --- a/src/components/modals/EditTownModal.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { X } from 'lucide-react'; -import { useAppStore } from '../../lib/store'; -import { TownNameFields } from '../shared/TownNameFields'; - -interface Props { - isOpen: boolean; - town: { id: string; name: string; playerName: string } | null; - onClose: () => void; -} - -export function EditTownModal({ isOpen, town, onClose }: Props) { - const updateTown = useAppStore(s => s.updateTown); - const [name, setName] = useState(''); - const [playerName, setPlayerName] = useState(''); - - // Sync form state when a different town is opened for editing. - // Depend on the stable primitive id so the effect only fires when the town - // actually changes, not on every parent re-render. - const townId = town?.id; - const townName = town?.name; - const townPlayerName = town?.playerName; - useEffect(() => { - if (townId) { - setName(townName ?? ''); - setPlayerName(townPlayerName ?? ''); - } - }, [townId, townName, townPlayerName]); - - if (!isOpen || !town) return null; - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!name.trim() || !playerName.trim() || !town) return; - updateTown(town.id, name.trim(), playerName.trim()); - onClose(); - } - - return ( -
-
e.stopPropagation()} - > -
-

- Edit Town -

- -
- -
- - - -
-
- ); -} diff --git a/src/components/progressMeterUtils.ts b/src/components/progressMeterUtils.ts new file mode 100644 index 0000000..656ab9b --- /dev/null +++ b/src/components/progressMeterUtils.ts @@ -0,0 +1,15 @@ +import type { CategoryId, GameId } from '../lib/types'; + +const FIVE_SEG_GAMES = new Set(['ACNL', 'ACNH']); + +export function segmentsForGame( + gameId: GameId, + totals: Partial> +): CategoryId[] { + const base: CategoryId[] = ['fish', 'bugs', 'fossils']; + if ((totals.art ?? 0) > 0) base.push('art'); + if (FIVE_SEG_GAMES.has(gameId) && (totals.sea_creatures ?? 0) > 0) { + base.push('sea_creatures'); + } + return base; +} diff --git a/src/components/search/GlobalSearchBar.tsx b/src/components/search/GlobalSearchBar.tsx deleted file mode 100644 index 87f0dfa..0000000 --- a/src/components/search/GlobalSearchBar.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import { Search, X, Clock } from 'lucide-react'; -import { SearchHistoryPopover } from './SearchHistoryPopover'; - -export function GlobalSearchBar({ - query, - setQuery, - onSubmit, - historyOpen, - setHistoryOpen, - recentSearches, - onSelectHistory, - onClearHistory, - wrapperRef, -}: { - query: string; - setQuery: (v: string) => void; - onSubmit: (q: string) => void; - historyOpen: boolean; - setHistoryOpen: React.Dispatch>; - recentSearches: string[]; - onSelectHistory: (s: string) => void; - onClearHistory: () => void; - wrapperRef: React.RefObject; -}) { - return ( -
-
- - setQuery(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && query.trim()) onSubmit(query.trim()); - }} - placeholder="Search all categories…" - className="w-full bg-transparent outline-none text-sm" - style={{ color: '#2A2A2A' }} - /> - {query && ( - - )} - -
- - {historyOpen && ( - { - onSelectHistory(s); - setHistoryOpen(false); - }} - onClear={onClearHistory} - /> - )} -
- ); -} diff --git a/src/components/search/GlobalSearchDropdown.tsx b/src/components/search/GlobalSearchDropdown.tsx new file mode 100644 index 0000000..47b131e --- /dev/null +++ b/src/components/search/GlobalSearchDropdown.tsx @@ -0,0 +1,390 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { CategoryId, GameId } from '../../lib/types'; +import type { AllData } from '../../lib/viewTypes'; +import { + type AnyItem, + isFish, + isFossil, + isArtPiece, + isSeaCreature, +} from '../../lib/utils'; + +const SEARCH_HISTORY_KEY = 'ac-curator-search-history'; +const MAX_HISTORY = 8; +const PER_GROUP_LIMIT = 5; +const SEA_GAMES = new Set(['ACNL', 'ACNH']); + +const GROUP_LABEL: Record = { + fish: 'Fish', + bugs: 'Bugs', + fossils: 'Fossils', + art: 'Art', + sea_creatures: 'Sea', +}; + +const CHIP_VAR: Record = { + fish: 'var(--chip-fish)', + bugs: 'var(--chip-bugs)', + fossils: 'var(--chip-fossils)', + art: 'var(--chip-art)', + sea_creatures: 'var(--chip-sea)', +}; + +function loadHistory(): string[] { + try { + const raw = localStorage.getItem(SEARCH_HISTORY_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter(s => typeof s === 'string') + : []; + } catch { + return []; + } +} + +function saveHistory(arr: string[]) { + try { + localStorage.setItem( + SEARCH_HISTORY_KEY, + JSON.stringify(arr.slice(0, MAX_HISTORY)) + ); + } catch { + /* ignore */ + } +} + +function monogram(name: string): string { + return name + .split(/[\s-]+/) + .filter(Boolean) + .slice(0, 2) + .map(s => s[0]) + .join('') + .toUpperCase(); +} + +function metaFor(item: AnyItem, category: CategoryId): string { + if (category === 'fish' && isFish(item)) { + const bells = + item.value != null ? ` · ${item.value.toLocaleString()} ✦` : ''; + return `${item.habitat}${bells}`; + } + if (category === 'bugs') { + const it = item as AnyItem & { location?: string; value?: number }; + const bells = it.value != null ? ` · ${it.value.toLocaleString()} ✦` : ''; + return `${it.location ?? '—'}${bells}`; + } + if (category === 'fossils' && isFossil(item)) { + const bells = + item.value != null ? ` · ${item.value.toLocaleString()} ✦` : ''; + return `${item.part ?? 'Fossil'}${bells}`; + } + if (category === 'art' && isArtPiece(item)) { + return item.basedOn ?? '—'; + } + if (category === 'sea_creatures' && isSeaCreature(item)) { + const shadow = item.shadow ?? ''; + const bells = + item.value != null ? ` · ${item.value.toLocaleString()} ✦` : ''; + return `${shadow}${bells}`.replace(/^ · /, ''); + } + return '—'; +} + +interface GlobalSearchDropdownProps { + data: AllData; + donated: Record; + gameId: GameId; + onJump: (category: CategoryId, id: string) => void; +} + +export function GlobalSearchDropdown({ + data, + donated, + gameId, + onJump, +}: GlobalSearchDropdownProps) { + const [query, setQuery] = useState(''); + const [focused, setFocused] = useState(false); + const [history, setHistory] = useState(() => loadHistory()); + const [activeIdx, setActiveIdx] = useState(0); + const wrapRef = useRef(null); + const inputRef = useRef(null); + + const visibleCategories = useMemo(() => { + const cats: CategoryId[] = ['fish', 'bugs', 'fossils']; + if (data.art.length > 0) cats.push('art'); + if (SEA_GAMES.has(gameId) && data.sea_creatures.length > 0) { + cats.push('sea_creatures'); + } + return cats; + }, [data.art.length, data.sea_creatures.length, gameId]); + + const grouped = useMemo | null>(() => { + const q = query.trim().toLowerCase(); + if (!q) return null; + const out = { + fish: [] as AnyItem[], + bugs: [] as AnyItem[], + fossils: [] as AnyItem[], + art: [] as AnyItem[], + sea_creatures: [] as AnyItem[], + }; + for (const cat of visibleCategories) { + const items = data[cat] as AnyItem[]; + out[cat] = items + .filter(it => { + if (it.name.toLowerCase().includes(q)) return true; + if (cat === 'art' && isArtPiece(it)) { + return (it.basedOn ?? '').toLowerCase().includes(q); + } + return false; + }) + .slice(0, PER_GROUP_LIMIT); + } + return out; + }, [query, data, visibleCategories]); + + const flatList = useMemo<{ category: CategoryId; item: AnyItem }[]>(() => { + if (!grouped) return []; + const out: { category: CategoryId; item: AnyItem }[] = []; + for (const cat of visibleCategories) { + for (const item of grouped[cat]) out.push({ category: cat, item }); + } + return out; + }, [grouped, visibleCategories]); + + useEffect(() => setActiveIdx(0), [query]); + + // Click outside to close + useEffect(() => { + if (!focused) return; + function onDown(e: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setFocused(false); + } + } + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + }, [focused]); + + function commitSearch(term: string) { + const t = term.trim(); + if (!t) return; + const next = [t, ...history.filter(h => h !== t)].slice(0, MAX_HISTORY); + setHistory(next); + saveHistory(next); + } + + function clearHistory() { + setHistory([]); + saveHistory([]); + } + + function selectIndex(idx: number) { + const entry = flatList[idx]; + if (!entry) return; + commitSearch(query); + onJump(entry.category, entry.item.id); + setQuery(''); + setFocused(false); + inputRef.current?.blur(); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === 'ArrowDown') { + if (flatList.length === 0) return; + e.preventDefault(); + setActiveIdx(i => Math.min(i + 1, flatList.length - 1)); + } else if (e.key === 'ArrowUp') { + if (flatList.length === 0) return; + e.preventDefault(); + setActiveIdx(i => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + if (flatList.length > 0) { + e.preventDefault(); + selectIndex(activeIdx); + } + } else if (e.key === 'Escape') { + setFocused(false); + inputRef.current?.blur(); + } + } + + const showPanel = focused; + const trimmed = query.trim(); + let runningIdx = -1; + + return ( +
+
+ + setQuery(e.target.value)} + onFocus={() => setFocused(true)} + onKeyDown={onKeyDown} + aria-label="Search across categories" + /> +
+ + {showPanel && ( +
+ {!trimmed && history.length === 0 && ( +
+
Search across categories
+
+ Type a name to find fish, bugs, fossils, art, or sea creatures + at once. +
+
+ ↑↓ navigate open esc close +
+
+ )} + + {!trimmed && history.length > 0 && ( + <> +
+ Recent searches + +
+
+ {history.map((h, i) => ( + + ))} +
+ + )} + + {trimmed && grouped && flatList.length === 0 && ( +
+
+ No matches for "{query}" +
+
+ Try a shorter term or check the spelling. +
+
+ )} + + {trimmed && grouped && flatList.length > 0 && ( + <> + {visibleCategories.map(cat => { + const items = grouped[cat]; + if (items.length === 0) return null; + return ( +
+
+ + + {GROUP_LABEL[cat]} + + {items.length} +
+ {items.map(it => { + runningIdx++; + const isActive = runningIdx === activeIdx; + const isDonated = !!donated[it.id]; + const idx = runningIdx; + return ( + + ); + })} +
+ ); + })} +
+ + ↑↓ navigate + + + open + + + esc close + +
+ + )} +
+ )} +
+ ); +} diff --git a/src/components/search/GlobalSearchResults.tsx b/src/components/search/GlobalSearchResults.tsx deleted file mode 100644 index 6cc4fcf..0000000 --- a/src/components/search/GlobalSearchResults.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import { CATEGORY_META } from '../../lib/categoryMeta'; -import { CATEGORY_ORDER } from '../../lib/constants'; -import { CollectibleRow } from '../CollectibleRow'; -import { EmptyState } from '../shared/EmptyState'; -import type { CategoryId } from '../../lib/types'; -import type { AnyItem } from '../../lib/utils'; - -export function GlobalSearchResults({ - results, - query, - donated, - onToggle, - onSelect, -}: { - results: Record | null; - query: string; - donated: Record; - onToggle: (id: string) => void; - onSelect: (item: AnyItem, category: CategoryId) => void; -}) { - if (!results) { - return ( - - ); - } - - const hasAny = CATEGORY_ORDER.some(cat => results[cat].length > 0); - - if (!hasAny) { - return ; - } - - return ( -
- {CATEGORY_ORDER.map(cat => { - const items = results[cat]; - if (items.length === 0) return null; - const { label, Icon } = CATEGORY_META[cat]; - return ( -
-
- - - {label} - - - {items.length} - -
-
- {items.map(item => ( - onToggle(item.id)} - onClick={() => onSelect(item, cat)} - /> - ))} -
-
- ); - })} -
- ); -} diff --git a/src/components/search/SearchHistoryPopover.tsx b/src/components/search/SearchHistoryPopover.tsx deleted file mode 100644 index 35064ee..0000000 --- a/src/components/search/SearchHistoryPopover.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { Clock } from 'lucide-react'; - -export function SearchHistoryPopover({ - searches, - onSelect, - onClear, -}: { - searches: string[]; - onSelect: (s: string) => void; - onClear: () => void; -}) { - return ( -
-
- - Recent Searches - - -
- {searches.length === 0 ? ( -
- No recent searches. -
- ) : ( - searches.map(s => ( - - )) - )} -
- ); -} diff --git a/src/components/shared/MonthGrid.tsx b/src/components/shared/MonthGrid.tsx index f062b69..863031d 100644 --- a/src/components/shared/MonthGrid.tsx +++ b/src/components/shared/MonthGrid.tsx @@ -1,26 +1,27 @@ import React from 'react'; import { MONTH_NAMES } from '../../lib/constants'; -export function MonthGrid({ months }: { months?: number[] }) { +export function MonthGrid({ + months, + current, +}: { + months?: number[]; + current?: number; +}) { return ( -
+
{MONTH_NAMES.map((m, i) => { - const active = !months || months.includes(i + 1); + const idx = i + 1; + const on = !months || months.includes(idx); + const here = current === idx; return (
- - {m} - + {m}
); })} diff --git a/src/components/shared/TownNameFields.tsx b/src/components/shared/TownNameFields.tsx deleted file mode 100644 index 8c3ca04..0000000 --- a/src/components/shared/TownNameFields.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; - -interface Props { - name: string; - playerName: string; - onNameChange: (v: string) => void; - onPlayerNameChange: (v: string) => void; - namePlaceholder?: string; - playerPlaceholder?: string; - autoFocus?: boolean; -} - -const inputStyle = { - borderColor: '#E7DAC4', - backgroundColor: '#FFFDF6', - color: '#2A2A2A', -}; - -const labelStyle = { color: '#5a4a35' }; - -export function TownNameFields({ - name, - playerName, - onNameChange, - onPlayerNameChange, - namePlaceholder, - playerPlaceholder, - autoFocus = true, -}: Props) { - return ( - <> -
- - onNameChange(e.target.value)} - placeholder={namePlaceholder} - className="w-full rounded-[10px] border px-3 py-2 text-sm outline-none" - style={inputStyle} - /> -
-
- - onPlayerNameChange(e.target.value)} - placeholder={playerPlaceholder} - className="w-full rounded-[10px] border px-3 py-2 text-sm outline-none" - style={inputStyle} - /> -
- - ); -} diff --git a/src/components/views/AnalyticsView.tsx b/src/components/views/AnalyticsView.tsx deleted file mode 100644 index 914c3da..0000000 --- a/src/components/views/AnalyticsView.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import React, { useMemo } from 'react'; -import { CATEGORY_META } from '../../lib/categoryMeta'; -import { CATEGORY_ORDER, MONTH_NAMES, SEASONS } from '../../lib/constants'; -import { SectionCard } from './SectionCard'; -import type { CategoryId, Fish as FishType, BugItem } from '../../lib/types'; -import type { AllData } from '../../lib/viewTypes'; - -export function AnalyticsView({ - data, - catCounts, - donatedAt, -}: { - data: AllData; - catCounts: Record; - donatedAt: Record; -}) { - const monthlyBuckets = useMemo(() => { - const map: Record = {}; - for (const iso of Object.values(donatedAt)) { - const d = new Date(iso); - const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; - map[key] = (map[key] ?? 0) + 1; - } - const sorted = Object.entries(map).sort(([a], [b]) => a.localeCompare(b)); - const maxCount = sorted.reduce((m, [, v]) => Math.max(m, v), 0); - return { buckets: sorted, maxCount }; - }, [donatedAt]); - - const monthAvailability = useMemo(() => { - const donatedIds = new Set(Object.keys(donatedAt)); - const counts = new Array(12).fill(0); - for (const cat of ['fish', 'bugs'] as const) { - for (const item of data[cat]) { - if (!donatedIds.has(item.id)) continue; - const months: number[] | undefined = (item as FishType | BugItem) - .months; - const active = - months && months.length > 0 - ? months - : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; - for (const m of active) counts[m - 1]++; - } - } - const max = Math.max(...counts, 1); - return { counts, max }; - }, [donatedAt, data]); - - const seasonalData = useMemo(() => { - const donatedIds = new Set(Object.keys(donatedAt)); - const counts: Record = { - Spring: 0, - Summer: 0, - Fall: 0, - Winter: 0, - }; - for (const cat of ['fish', 'bugs'] as const) { - for (const item of data[cat]) { - if (!donatedIds.has(item.id)) continue; - const months: number[] | undefined = (item as FishType | BugItem) - .months; - const active = - months && months.length > 0 - ? months - : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; - for (const season of SEASONS) { - if ( - active.some(m => (season.months as readonly number[]).includes(m)) - ) { - counts[season.label]++; - } - } - } - } - const totalDonatedFishBugs = [...donatedIds].filter( - id => data.fish.some(f => f.id === id) || data.bugs.some(b => b.id === id) - ).length; - return { counts, total: totalDonatedFishBugs }; - }, [donatedAt, data]); - - const totalDonated = Object.keys(donatedAt).length; - - return ( -
- - {CATEGORY_ORDER.map((cat, i) => { - const { label, Icon } = CATEGORY_META[cat]; - const donated = catCounts[cat]; - const total = data[cat].length; - const pct = total ? Math.round((donated / total) * 100) : 0; - const complete = donated === total && total > 0; - return ( -
-
-
- -
- - {label} - - - {donated}/{total} - - - {pct}% - -
-
-
-
-
- ); - })} - - - 0 - ? `${totalDonated} donation${totalDonated !== 1 ? 's' : ''}` - : undefined - } - > - {monthlyBuckets.buckets.length === 0 ? ( -
- No donations yet — timestamps will appear here once you start - donating. -
- ) : ( -
- {monthlyBuckets.buckets.map(([key, count]) => { - const barHeightPct = (count / monthlyBuckets.maxCount) * 100; - const [year, month] = key.split('-'); - const label = `${MONTH_NAMES[Number(month) - 1]} '${year.slice(2)}`; - return ( -
- - {count} - -
- - {label} - -
- ); - })} -
- )} - - - -
- Donated fish & bugs available each month -
- {totalDonated === 0 ? ( -
- Donate fish or bugs to see monthly availability. -
- ) : ( -
- {MONTH_NAMES.map((name, i) => { - const count = monthAvailability.counts[i]; - const barWidth = (count / monthAvailability.max) * 100; - return ( -
-
- {name.slice(0, 3)} -
-
-
-
-
- {count} -
-
- ); - })} -
- )} - - - -
- Donated fish & bugs available each season -
- {seasonalData.total === 0 ? ( -
- Donate fish or bugs to see seasonal availability. -
- ) : ( -
- {SEASONS.map(season => { - const count = seasonalData.counts[season.label]; - const pct = - seasonalData.total > 0 - ? Math.round((count / seasonalData.total) * 100) - : 0; - const maxCount = Math.max( - ...SEASONS.map(s => seasonalData.counts[s.label]), - 1 - ); - const barWidth = (count / maxCount) * 100; - return ( -
-
- {season.label} -
-
- {count} -
-
- {pct}% of donated -
-
-
-
-
- ); - })} -
- )} - -
- ); -} diff --git a/src/components/views/SectionCard.tsx b/src/components/views/SectionCard.tsx deleted file mode 100644 index a61555a..0000000 --- a/src/components/views/SectionCard.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; - -export function SectionCard({ - title, - subtitle, - children, -}: { - title: string; - subtitle?: string; - children: React.ReactNode; -}) { - return ( -
-
-

- {title} -

- {subtitle && ( - - {subtitle} - - )} -
- {children} -
- ); -} diff --git a/src/hooks/useJumpToRow.ts b/src/hooks/useJumpToRow.ts new file mode 100644 index 0000000..4e0d25f --- /dev/null +++ b/src/hooks/useJumpToRow.ts @@ -0,0 +1,33 @@ +import { useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import type { CategoryId } from '../lib/types'; + +/** + * Decision 10 helper: navigate to a category tab and jump to a specific row. + * + * ACCanvas owns the `highlightId` state and runs the scroll-into-view + pulse + * effect when it changes. This hook just wires the navigation + sets the + * highlight target. Callers (Home shelves, search dropdown) get a stable + * `jumpTo(category, id)` function. + */ +export function useJumpToRow( + setHighlightId: (id: string | null) => void +): (category: CategoryId, id: string) => void { + const navigate = useNavigate(); + const { townId } = useParams<{ townId?: string }>(); + + return useCallback( + (category: CategoryId, id: string) => { + if (!townId) return; + // Clear first so re-jumping to the same id retriggers the pulse. + setHighlightId(null); + navigate(`/town/${townId}/${category}`); + // Defer to next frame so the route + tab content mount before + // ACCanvas's highlight effect tries to query for the row. + requestAnimationFrame(() => { + setHighlightId(id); + }); + }, + [navigate, townId, setHighlightId] + ); +} diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts deleted file mode 100644 index aa08666..0000000 --- a/src/hooks/useSearch.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; - -export function useSearch() { - const [globalQuery, setGlobalQuery] = useState(''); - const [recentSearches, setRecentSearches] = useState([]); - const [historyOpen, setHistoryOpen] = useState(false); - const historyRef = useRef(null); - - function pushRecentSearch(term: string) { - const trimmed = term.trim(); - if (!trimmed) return; - setRecentSearches(prev => { - const deduped = prev.filter(s => s !== trimmed); - return [trimmed, ...deduped].slice(0, 10); - }); - } - - useEffect(() => { - if (!historyOpen) return; - function handleOutside(e: MouseEvent) { - if ( - historyRef.current && - !historyRef.current.contains(e.target as Node) - ) { - setHistoryOpen(false); - } - } - document.addEventListener('mousedown', handleOutside); - return () => document.removeEventListener('mousedown', handleOutside); - }, [historyOpen]); - - return { - globalQuery, - setGlobalQuery, - recentSearches, - setRecentSearches, - historyOpen, - setHistoryOpen, - historyRef, - pushRecentSearch, - }; -} diff --git a/src/index.css b/src/index.css index 7f1fb85..2bc3186 100644 --- a/src/index.css +++ b/src/index.css @@ -1,7 +1,1504 @@ +@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400;1,9..144,500&family=Inter:wght@400;500;600;700&display=swap'); + @tailwind base; @tailwind components; @tailwind utilities; +@theme { + --color-bg: #F4EFE3; + --color-surface: #FFFDF7; + --color-surface-alt: #F8F2E2; + --color-ink: #23241F; + --color-ink-soft: #5C5848; + --color-ink-muted: #8A8470; + --color-border: #E2D9C3; + --color-border-strong: #CFC4A8; + --color-accent: oklch(0.55 0.09 150); + --color-accent-soft: oklch(0.55 0.09 150 / 0.12); + --color-accent-ink: oklch(0.32 0.06 150); + --color-warn: oklch(0.62 0.12 50); + --color-warn-soft: oklch(0.62 0.12 50 / 0.14); + --color-chip-fish: oklch(0.62 0.08 230); + --color-chip-bugs: oklch(0.6 0.1 130); + --color-chip-fossils: oklch(0.55 0.06 60); + --color-chip-art: oklch(0.58 0.08 320); + --color-chip-sea: oklch(0.58 0.09 200); + + --font-display: 'Fraunces', Georgia, serif; + --font-sans: 'Inter', system-ui, sans-serif; +} + +:root { + --bg: #F4EFE3; + --surface: #FFFDF7; + --surface-alt: #F8F2E2; + --ink: #23241F; + --ink-soft: #5C5848; + --ink-muted: #8A8470; + --border: #E2D9C3; + --border-strong: #CFC4A8; + --accent: oklch(0.55 0.09 150); + --accent-soft: oklch(0.55 0.09 150 / 0.12); + --accent-ink: oklch(0.32 0.06 150); + --warn: oklch(0.62 0.12 50); + --warn-soft: oklch(0.62 0.12 50 / 0.14); + --chip-fish: oklch(0.62 0.08 230); + --chip-bugs: oklch(0.6 0.1 130); + --chip-fossils: oklch(0.55 0.06 60); + --chip-art: oklch(0.58 0.08 320); + --chip-sea: oklch(0.58 0.09 200); +} + * { - font-family: 'Varela Round', sans-serif; + font-family: 'Inter', system-ui, sans-serif; +} + +/* ── App shell (v0.9 Phase 2) ── */ +.ac-app { + display: grid; + grid-template-columns: 280px 1fr; + min-height: 100vh; + max-width: 1440px; + margin: 0 auto; + background: var(--bg); + color: var(--ink); +} + +.ac-sidebar { + border-right: 1px solid var(--border); + padding: 28px 22px; + display: flex; + flex-direction: column; + gap: 22px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + background: var(--bg); +} +.ac-brand { display: flex; gap: 12px; align-items: center; color: var(--accent); } +.ac-brand-mark { + width: 38px; height: 38px; + border-radius: 50%; + border: 1.5px solid currentColor; + display: grid; place-items: center; +} +.ac-brand-name { + font-family: var(--font-display, 'Fraunces', Georgia, serif); + font-size: 20px; + font-weight: 600; + color: var(--ink); + letter-spacing: -0.02em; +} +.ac-brand-sub { + font-size: 11px; + color: var(--ink-muted); + font-style: italic; + font-family: var(--font-display, 'Fraunces', Georgia, serif); +} + +.ac-town-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px 16px; +} +.ac-town-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-muted); +} +.ac-town-name { + font-family: var(--font-display, 'Fraunces', Georgia, serif); + font-size: 22px; + font-weight: 500; + margin-top: 2px; + letter-spacing: -0.01em; + color: var(--ink); +} +.ac-town-meta { + font-size: 12px; + color: var(--ink-soft); + margin-top: 2px; + display: flex; + gap: 6px; + align-items: center; +} +.ac-dot-sep { color: var(--border-strong); } +.ac-hem-toggle { display: inline-flex; gap: 2px; } +.ac-hem-btn { + font-size: 11px; + color: var(--ink-muted); + background: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 1px 6px; + cursor: pointer; + font-variant-numeric: tabular-nums; +} +.ac-hem-btn.is-active { + background: var(--accent-soft); + color: var(--accent-ink); + border-color: var(--accent); +} +.ac-town-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + flex-wrap: wrap; +} +.ac-town-switch { + font-size: 12px; + color: var(--accent-ink); + padding: 0; + text-align: left; + font-weight: 500; + background: none; + border: none; + cursor: pointer; +} +.ac-town-switch:hover { text-decoration: underline; } +.ac-town-edit { + font-size: 11px; + color: var(--ink-muted); + background: none; + border: none; + padding: 0; + cursor: pointer; +} +.ac-town-edit:hover { color: var(--ink); text-decoration: underline; } + +.ac-nav { display: flex; flex-direction: column; gap: 1px; } +.ac-nav-item { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 10px 14px; + border-radius: 9px; + font-size: 14px; + color: var(--ink-soft); + text-align: left; + transition: background 0.12s, color 0.12s; + text-decoration: none; + min-height: 44px; + box-sizing: border-box; +} +.ac-nav-item:hover { background: var(--surface-alt); color: var(--ink); } +.ac-nav-item-active { + background: var(--accent-soft); + color: var(--accent-ink); + font-weight: 500; +} +.ac-nav-count { + font-size: 11px; + color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} +.ac-nav-count-slash { opacity: 0.5; margin: 0 1px; } +.ac-nav-item-active .ac-nav-count { color: var(--accent-ink); } + +.ac-sidebar-foot { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 14px; + border-top: 1px solid var(--border); +} +.ac-foot-link { + padding: 6px 14px; + font-size: 12px; + color: var(--ink-muted); + text-align: left; + background: none; + border: none; + cursor: pointer; +} +.ac-foot-link:hover { color: var(--ink); } + +.ac-main { + padding: 32px 48px 80px; + min-width: 0; +} + +/* ── Topbar + Global Search Dropdown ── */ +.ac-topbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} +.ac-search-wrap { position: relative; flex: 1; max-width: 480px; } +.ac-search { + display: flex; + align-items: center; + gap: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + padding: 8px 14px; + transition: border-color 0.12s; +} +.ac-search:focus-within { border-color: var(--border-strong); } +.ac-search input { + flex: 1; + border: none; + background: none; + outline: none; + font: inherit; + font-size: 13px; + color: var(--ink); + min-width: 0; +} +.ac-search input::placeholder { color: var(--ink-muted); } +.ac-search-icon { color: var(--ink-muted); flex: none; } + +.ac-gs-panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(20, 18, 12, 0.10); + z-index: 50; + max-height: 460px; + overflow-y: auto; + padding: 8px; + animation: ac-fade 0.12s ease; +} +@keyframes ac-fade { from { opacity: 0; } to { opacity: 1; } } + +.ac-gs-empty { padding: 22px 14px; text-align: center; } +.ac-gs-empty-title { + font-family: var(--font-display, 'Fraunces'), Georgia, serif; + font-size: 16px; + color: var(--ink); + margin-bottom: 4px; +} +.ac-gs-empty-title em { font-style: italic; color: var(--accent-ink); } +.ac-gs-empty-sub { font-size: 12px; color: var(--ink-muted); } +.ac-gs-hint { margin-top: 14px; font-size: 11px; color: var(--ink-muted); } + +.ac-gs-section-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px 6px; +} +.ac-gs-eyebrow { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 600; + color: var(--ink-muted); +} +.ac-gs-clear { + font-size: 11px; + color: var(--ink-muted); + padding: 2px 6px; + border-radius: 4px; + background: none; + border: none; + cursor: pointer; +} +.ac-gs-clear:hover { background: var(--surface-alt); color: var(--ink); } + +.ac-gs-history { display: flex; flex-direction: column; gap: 1px; } +.ac-gs-history-row { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; + color: var(--ink-soft); + text-align: left; + background: none; + border: none; + cursor: pointer; + width: 100%; +} +.ac-gs-history-row:hover { background: var(--surface-alt); color: var(--ink); } +.ac-gs-history-icon { color: var(--ink-muted); font-size: 12px; } + +.ac-gs-group { margin-bottom: 6px; } +.ac-gs-group-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px 4px; +} +.ac-gs-group-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; } +.ac-gs-group-count { + margin-left: auto; + font-size: 11px; + color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} + +.ac-gs-row { + width: 100%; + display: flex; + gap: 12px; + align-items: center; + padding: 8px 10px; + border-radius: 8px; + text-align: left; + background: none; + border: none; + cursor: pointer; + transition: background 0.1s; +} +.ac-gs-row:hover { background: var(--surface-alt); } +.ac-gs-row-active { background: var(--accent-soft); } +.ac-gs-row-glyph { + width: 28px; + height: 28px; + border: 1.5px solid; + border-radius: 6px; + display: grid; + place-items: center; + font-family: var(--font-display, 'Fraunces'), Georgia, serif; + font-size: 10px; + font-weight: 500; + flex: none; +} +.ac-gs-row-text { flex: 1; min-width: 0; } +.ac-gs-row-name { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--ink); + font-weight: 500; +} +.ac-gs-row-donated { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent); + font-weight: 600; +} +.ac-gs-row-meta { + font-size: 11px; + color: var(--ink-muted); + margin-top: 1px; + text-transform: capitalize; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ac-gs-row-arrow { + color: var(--ink-muted); + font-size: 11px; + opacity: 0; + flex: none; +} +.ac-gs-row-active .ac-gs-row-arrow { opacity: 1; } + +.ac-gs-foot { + display: flex; + gap: 14px; + justify-content: center; + padding: 10px 8px 4px; + border-top: 1px solid var(--border); + margin-top: 6px; + font-size: 10px; + color: var(--ink-muted); +} +.ac-gs-foot kbd, +.ac-gs-hint kbd { + font-family: ui-monospace, monospace; + font-size: 10px; + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 4px; + margin-right: 4px; +} + +@media (max-width: 980px) { + .ac-app { grid-template-columns: 1fr; } + .ac-sidebar { + position: static; + height: auto; + border-right: none; + border-bottom: 1px solid var(--border); + } + .ac-main { padding: 24px 20px 60px; } +} + +/* ── Settings page ── */ +.ac-settings { + max-width: 920px; + margin: 0 auto; + padding: 8px 0 80px; + animation: ac-fade-up 0.28s ease; +} +@keyframes ac-fade-up { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.ac-settings-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding-bottom: 28px; + border-bottom: 1px solid var(--border); + margin-bottom: 36px; +} +.ac-settings-eyebrow { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--ink-muted); + margin-bottom: 8px; +} +.ac-settings-title { + font-family: var(--font-display, 'Fraunces', Georgia, serif); + font-size: 56px; + font-weight: 400; + line-height: 1; + letter-spacing: -0.02em; + margin: 0; + color: var(--ink); +} +.ac-settings-title em { + font-style: italic; + color: var(--accent-ink); + font-variation-settings: 'opsz' 36; +} +.ac-settings-close { + background: var(--surface); + border: 1px solid var(--border); + color: var(--ink-soft); + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 14px; + cursor: pointer; + transition: all 0.15s; +} +.ac-settings-close:hover { + background: var(--surface-alt); + color: var(--ink); + border-color: var(--border-strong); +} + +.ac-settings-section { margin-bottom: 44px; } +.ac-settings-section-head { margin-bottom: 18px; } +.ac-settings-section-title { + font-family: var(--font-display, 'Fraunces', Georgia, serif); + font-size: 22px; + font-weight: 500; + margin: 0; + color: var(--ink); + letter-spacing: -0.01em; +} +.ac-settings-section-sub { + font-size: 13px; + color: var(--ink-muted); + margin: 4px 0 0; +} + +.ac-settings-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 18px 22px; +} +.ac-about-list { + display: grid; + gap: 14px; + margin: 0; +} +.ac-about-list > div { + display: grid; + grid-template-columns: 120px 1fr; + gap: 18px; + font-size: 14px; + align-items: baseline; +} +.ac-about-list dt { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-muted); + margin: 0; +} +.ac-about-list dd { margin: 0; color: var(--ink); } +.ac-about-list a { + color: var(--accent-ink); + text-decoration: underline; + text-decoration-color: var(--accent-soft, var(--border-strong)); + text-underline-offset: 3px; +} + +.ac-settings-danger { + border-color: oklch(0.62 0.12 25 / 0.35); + background: color-mix(in oklch, oklch(0.62 0.12 25) 4%, var(--surface)); +} +.ac-danger-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + padding: 10px 0; +} +.ac-danger-row + .ac-danger-row { + border-top: 1px solid var(--border); + padding-top: 16px; + margin-top: 6px; +} +.ac-danger-name { + font-weight: 500; + color: var(--ink); + font-size: 14px; +} +.ac-danger-sub { + font-size: 12px; + color: var(--ink-muted); + margin-top: 2px; +} +.ac-danger-btn { + background: var(--surface); + border: 1px solid oklch(0.62 0.12 25 / 0.4); + color: oklch(0.5 0.14 25); + padding: 8px 16px; + border-radius: 999px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + font-family: inherit; + flex: none; + min-height: 44px; + transition: all 0.15s; +} +.ac-danger-btn:hover:not(:disabled) { + background: oklch(0.62 0.12 25 / 0.08); + border-color: oklch(0.62 0.12 25 / 0.6); +} +.ac-danger-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.ac-danger-btn-strong { + background: oklch(0.5 0.14 25); + color: white; + border-color: transparent; +} +.ac-danger-btn-strong:hover { background: oklch(0.45 0.16 25); } + +@media (max-width: 700px) { + .ac-settings-title { font-size: 40px; } + .ac-about-list > div { grid-template-columns: 1fr; gap: 2px; } + .ac-danger-row { flex-direction: column; align-items: stretch; } + .ac-danger-btn { width: 100%; } +} + +/* ── Town Manager drawer (Phase 4) ── */ +.ac-tm-scrim { + position: fixed; inset: 0; z-index: 9990; + background: rgba(20,18,12,0.32); + -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); + display: flex; justify-content: flex-end; + animation: ac-fade 0.18s ease; +} +@keyframes ac-slide { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } +.ac-tm-drawer { + width: 420px; max-width: 100vw; + background: var(--bg); + border-left: 1px solid var(--border); + display: flex; flex-direction: column; + height: 100vh; + animation: ac-slide 0.22s ease; + box-shadow: -20px 0 60px rgba(0,0,0,0.08); +} +.ac-tm-head { + display: flex; justify-content: space-between; align-items: flex-start; + padding: 22px 24px 18px; + border-bottom: 1px solid var(--border); + gap: 12px; +} +.ac-tm-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); } +.ac-tm-title { font-family: var(--font-display); font-weight: 500; font-size: 22px; margin: 4px 0 0; letter-spacing: -0.01em; max-width: 280px; } +.ac-tm-close { + width: 32px; height: 32px; border-radius: 8px; + font-size: 22px; line-height: 1; color: var(--ink-muted); + background: transparent; border: none; cursor: pointer; flex: none; +} +.ac-tm-close:hover { background: var(--surface-alt); color: var(--ink); } + +.ac-tm-list { flex: 1; overflow-y: auto; padding: 14px 18px; display: flex; flex-direction: column; gap: 6px; } + +.ac-tm-row { + display: flex; align-items: stretch; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + transition: border-color 0.12s; +} +.ac-tm-row:hover { border-color: var(--border-strong); } +.ac-tm-row-active { border-color: var(--accent); background: var(--accent-soft); } +.ac-tm-row-main { + flex: 1; display: flex; gap: 14px; align-items: center; + padding: 14px 16px; text-align: left; min-width: 0; + background: transparent; border: none; cursor: pointer; + font: inherit; color: inherit; +} +.ac-tm-row-mark { + width: 18px; height: 18px; border-radius: 50%; + border: 1.5px solid; + display: grid; place-items: center; + flex: none; +} +.ac-tm-row-tick { color: var(--accent); font-size: 9px; } +.ac-tm-row-text { flex: 1; min-width: 0; } +.ac-tm-row-name { font-family: var(--font-display); font-weight: 500; font-size: 18px; letter-spacing: -0.01em; color: var(--ink); } +.ac-tm-row-meta { + display: flex; gap: 6px; align-items: center; + font-size: 12px; color: var(--ink-soft); margin-top: 2px; + flex-wrap: wrap; +} +.ac-tm-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: var(--surface-alt); + border: 1px solid var(--border); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--ink-soft); +} +.ac-tm-row-active .ac-tm-badge { background: var(--surface); } + +.ac-tm-row-edit { + padding: 0 16px; + color: var(--ink-muted); + border: none; border-left: 1px solid var(--border); + background: transparent; cursor: pointer; + display: grid; place-items: center; + transition: background 0.12s, color 0.12s; +} +.ac-tm-row-edit:hover { background: var(--surface-alt); color: var(--ink); } +.ac-tm-row-active .ac-tm-row-edit { border-left-color: var(--accent); } + +/* Editing state */ +.ac-tm-row-editing { + flex-direction: column; + background: var(--surface); + border-color: var(--border-strong); + padding: 18px; + gap: 14px; +} +.ac-tm-form { display: flex; flex-direction: column; gap: 12px; } +.ac-tm-field { display: flex; flex-direction: column; gap: 4px; } +.ac-tm-field-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); } +.ac-tm-field-hint { font-size: 11px; color: var(--ink-muted); margin-top: 2px; font-style: italic; } +.ac-tm-input { + font: inherit; color: var(--ink); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + outline: none; +} +.ac-tm-input:focus { border-color: var(--accent); } + +.ac-tm-seg { + display: flex; gap: 4px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px; +} +.ac-tm-seg button { + flex: 1; padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: var(--ink-soft); + background: transparent; border: none; cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.ac-tm-seg button:hover { color: var(--ink); } +.ac-tm-seg button.ac-tm-seg-on { + background: var(--surface); + color: var(--accent-ink); + font-weight: 500; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.ac-tm-row-actions { + display: flex; justify-content: space-between; gap: 8px; + padding-top: 8px; border-top: 1px solid var(--border); +} +.ac-tm-row-actions-right { display: flex; gap: 8px; margin-left: auto; } +.ac-tm-ghost, .ac-tm-primary, .ac-tm-danger { + padding: 7px 14px; border-radius: 8px; + font-size: 13px; font-weight: 500; + background: transparent; border: none; cursor: pointer; + transition: background 0.12s, opacity 0.12s; +} +.ac-tm-ghost { color: var(--ink-soft); } +.ac-tm-ghost:hover { background: var(--surface-alt); color: var(--ink); } +.ac-tm-primary { background: var(--accent); color: var(--surface); } +.ac-tm-primary:hover { opacity: 0.88; } +.ac-tm-primary:disabled { opacity: 0.4; cursor: not-allowed; } +.ac-tm-danger { color: var(--warn); } +.ac-tm-danger:hover { background: var(--warn-soft); } + +.ac-tm-empty { padding: 60px 20px; text-align: center; color: var(--ink-muted); } +.ac-tm-empty-glyph { + width: 56px; height: 56px; border-radius: 50%; + border: 1.5px dashed var(--border-strong); + margin: 0 auto 14px; + display: grid; place-items: center; + color: var(--ink-muted); + font-size: 24px; +} +.ac-tm-empty-title { font-family: var(--font-display); font-size: 18px; color: var(--ink); margin-bottom: 4px; } +.ac-tm-empty-sub { font-size: 13px; } + +.ac-tm-foot { padding: 14px 18px 22px; border-top: 1px solid var(--border); } +.ac-tm-cta { + width: 100%; + display: flex; align-items: center; justify-content: center; gap: 8px; + padding: 12px; + background: var(--surface); + border: 1.5px dashed var(--border-strong); + border-radius: 12px; + color: var(--ink-soft); + font-weight: 500; + font-size: 14px; + cursor: pointer; + transition: border-color 0.12s, color 0.12s, background 0.12s; +} +.ac-tm-cta:hover { border-color: var(--accent); color: var(--accent-ink); background: var(--accent-soft); } +.ac-tm-cta-plus { font-size: 18px; line-height: 1; } +.ac-tm-newform { display: flex; flex-direction: column; gap: 8px; } +.ac-tm-newform-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } + +/* Mobile: bottom sheet */ +@media (max-width: 720px) { + .ac-tm-scrim { align-items: flex-end; justify-content: center; } + .ac-tm-drawer { + width: 100%; height: 88vh; + border-left: none; border-top: 1px solid var(--border); + border-radius: 16px 16px 0 0; + animation: ac-slide-up 0.22s ease; + } + @keyframes ac-slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +} + +/* ── Phase 5: Item rows + expand panel ── */ +.ac-list { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; +} + +.ac-row { border-bottom: 1px solid var(--border); } +.ac-row:last-child { border-bottom: none; } + +.ac-row-pulse > .ac-row-main { animation: ac-row-pulse 1.4s ease-out; } +@keyframes ac-row-pulse { + 0% { background: var(--accent-soft); } + 60% { background: color-mix(in oklch, var(--accent) 16%, transparent); } + 100% { background: var(--surface-alt); } +} + +.ac-row-main { + display: flex; gap: 14px; align-items: center; + width: 100%; + padding: 12px 18px; + text-align: left; + background: transparent; + transition: background 0.12s; + cursor: pointer; +} +.ac-row-main:hover { background: var(--surface-alt); } +.ac-row-main:focus { outline: none; } +.ac-row-main:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} +.ac-row-expanded > .ac-row-main { background: var(--surface-alt); } +.ac-row-donated .ac-row-name { color: var(--ink-muted); } +.ac-row-donated .ac-row-name span:first-child { + text-decoration: line-through; + text-decoration-color: var(--border-strong); + text-decoration-thickness: 1px; +} + +.ac-glyph { + width: 32px; height: 32px; + border: 1.5px solid; + border-radius: 8px; + display: grid; place-items: center; + font-family: var(--font-display); + font-size: 11px; font-weight: 500; + flex: none; +} + +.ac-row-text { flex: 1; min-width: 0; } +.ac-row-name { + display: flex; align-items: center; gap: 8px; + font-weight: 500; + color: var(--ink); +} +.ac-row-checkmark { color: var(--accent); font-size: 8px; } +.ac-row-meta { + display: flex; gap: 0; font-size: 12px; color: var(--ink-muted); + margin-top: 2px; + flex-wrap: wrap; +} +.ac-row-meta-bit { text-transform: capitalize; } +.ac-row-meta-bit:not(:last-child)::after { + content: "·"; + margin: 0 8px; + color: var(--border-strong); +} +.ac-row-meta-italic { font-style: italic; font-family: var(--font-display); text-transform: none; } +.ac-row-meta-bells { + color: var(--ink-soft); + font-variant-numeric: tabular-nums; + text-transform: none; +} + +.ac-row-side { display: flex; align-items: center; gap: 10px; flex: none; } +.ac-row-time { font-size: 11px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } +.ac-chevron { + color: var(--ink-muted); + font-size: 18px; + line-height: 1; + transition: transform 0.18s, color 0.18s; + display: inline-block; +} +.ac-chevron-open { transform: rotate(90deg); color: var(--ink); } + +/* Pills */ +.ac-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.ac-pill-warn { background: var(--warn-soft); color: var(--warn); } +.ac-pill-accent { background: var(--accent-soft); color: var(--accent-ink); } + +/* Expand panel */ +.ac-expand { + display: grid; + grid-template-columns: 1fr 240px; + gap: 28px; + padding: 4px 18px 22px 64px; + background: var(--surface-alt); +} +.ac-expand-no-months { grid-template-columns: 1fr; } +.ac-expand-section { min-width: 0; } +.ac-expand-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--ink-muted); margin-bottom: 10px; margin-top: 14px; +} + +.ac-monthgrid { + display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; +} +.ac-monthcell { + aspect-ratio: 1; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + display: grid; place-items: center; + position: relative; +} +.ac-monthcell-label { + font-size: 10px; color: var(--ink-muted); + font-family: var(--font-display); +} +.ac-monthcell.on { background: var(--accent-soft); border-color: var(--accent); } +.ac-monthcell.on .ac-monthcell-label { color: var(--accent-ink); } +.ac-monthcell.here { box-shadow: inset 0 0 0 1.5px var(--accent); } +.ac-monthcell.here .ac-monthcell-label { font-weight: 600; } + +.ac-expand-side { + display: flex; flex-direction: column; gap: 10px; + padding-top: 14px; +} +.ac-stat { display: flex; flex-direction: column; gap: 1px; } +.ac-stat-num { + font-family: var(--font-display); + font-size: 22px; font-weight: 500; + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; +} +.ac-stat-num-text { font-size: 16px; font-variant-numeric: normal; } +.ac-stat-label { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--ink-muted); +} +.ac-stat-art .ac-stat-num { font-size: 15px; font-style: italic; line-height: 1.35; } +.ac-art-fake-note { + font-size: 12px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid; + font-family: var(--font-display); + font-style: italic; +} +.ac-art-fake-note-warn { + background: var(--warn-soft); + border-color: var(--warn); + color: var(--warn); +} +.ac-art-fake-note-ok { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-ink); +} +.ac-note { + font-size: 12px; color: var(--ink-soft); font-style: italic; + font-family: var(--font-display); + padding: 8px 10px; + background: var(--surface); + border-left: 2px solid var(--accent); + border-radius: 4px; +} +.ac-donate-btn { + margin-top: auto; + padding: 10px 14px; + border-radius: 8px; + background: var(--accent); + color: var(--surface); + font-weight: 500; + font-size: 13px; + border: 1px solid transparent; + transition: opacity 0.12s, background 0.12s; + cursor: pointer; +} +.ac-donate-btn:hover { opacity: 0.88; } +.ac-donate-btn-on { + background: var(--surface); + color: var(--accent-ink); + border-color: var(--border-strong); +} + +@media (max-width: 980px) { + .ac-expand { grid-template-columns: 1fr; padding-left: 18px; gap: 14px; } +} + +/* ── Phase 6: Home + ProgressMeter ── */ +.ac-meter { + display: flex; + gap: 8px; + font-variant-numeric: tabular-nums; +} +.ac-meter-seg { + flex: 1 1 0; + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; +} +.ac-meter-seg-head { + display: flex; align-items: center; gap: 8px; + font-size: 12px; color: var(--ink-soft); +} +.ac-meter-dot { + width: 8px; height: 8px; border-radius: 999px; flex: none; +} +.ac-meter-name { font-weight: 500; color: var(--ink); } +.ac-meter-frac { margin-left: auto; color: var(--ink-soft); } +.ac-meter-slash { color: var(--border-strong); margin: 0 1px; } +.ac-meter-track { + height: 6px; border-radius: 999px; overflow: hidden; + background: var(--surface-alt); +} +.ac-meter-fill { height: 100%; border-radius: 999px; transition: width 0.4s ease; } + +@media (min-width: 980px) and (max-width: 1180px) { + .ac-meter-5 .ac-meter-seg { padding: 7px 8px; } + .ac-meter-5 .ac-meter-frac { display: none; } +} +@media (max-width: 980px) { + .ac-meter-5 { flex-wrap: wrap; } + .ac-meter-5 .ac-meter-seg { flex-basis: calc(50% - 3px); } +} + +.ac-home { display: flex; flex-direction: column; gap: 36px; } + +/* ── Phase 7: Category tab sectioning ── */ +.ac-category { display: flex; flex-direction: column; gap: 18px; } +.ac-category-head { + display: flex; justify-content: space-between; align-items: flex-end; + gap: 16px; + margin-bottom: 6px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.ac-category-title { + font-family: var(--font-display); + font-weight: 400; + font-size: 44px; + line-height: 1; + letter-spacing: -0.02em; + color: var(--ink); + margin: 0; +} +.ac-category-title em { + font-style: italic; + color: var(--accent-ink); + font-variant-numeric: tabular-nums; + font-weight: 500; +} +.ac-category-meta { + font-size: 13px; + color: var(--ink-soft); + text-align: right; + font-variant-numeric: tabular-nums; +} +.ac-category-meta strong { + display: block; + font-family: var(--font-display); + font-weight: 500; + color: var(--ink); + font-size: 18px; +} +.ac-group { display: flex; flex-direction: column; gap: 8px; } +.ac-group-head { + display: flex; + align-items: baseline; + gap: 12px; + padding: 0 4px; +} +.ac-group-title { + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); + margin: 0; +} +.ac-group-warn .ac-group-title { color: var(--warn); } +.ac-group-accent .ac-group-title { color: var(--accent-ink); } +.ac-group-muted .ac-group-title { color: var(--ink-muted); } +.ac-group-done .ac-group-title { color: var(--ink-muted); } +.ac-group-count { + font-size: 12px; + color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} +@media (max-width: 700px) { + .ac-category-head { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .ac-category-meta { text-align: left; } + .ac-category-title { font-size: 32px; } +} + +.ac-hero { + display: flex; flex-direction: column; gap: 18px; +} +.ac-eyebrow { + font-family: var(--font-sans); + font-size: 11px; font-weight: 600; line-height: 1.4; + letter-spacing: 0.12em; text-transform: uppercase; + color: var(--ink-muted); +} +.ac-eyebrow-warn { color: var(--warn); } +.ac-eyebrow-accent { color: var(--accent-ink); } +.ac-hero-headline { + font-family: var(--font-display); + font-size: 38px; font-weight: 400; line-height: 1.15; + letter-spacing: -0.02em; + color: var(--ink); + margin: 0; +} +.ac-hero-headline em { + color: var(--accent-ink); + font-style: italic; + font-weight: 500; + font-variant-numeric: tabular-nums; +} +.ac-hero-aside { + display: block; + font-style: italic; + color: var(--warn); + font-size: 30px; +} + +.ac-month-strip { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 4px; +} +.ac-month-cell { + text-align: center; + font-size: 11px; + color: var(--ink-muted); + padding: 8px 0; + border-radius: 8px; + font-weight: 500; + letter-spacing: 0.05em; + font-variant-numeric: tabular-nums; +} +.ac-month-cell-current { + background: var(--accent-soft); + color: var(--accent-ink); + font-weight: 600; +} + +.ac-shelf-head { + display: flex; align-items: baseline; gap: 12px; + margin-bottom: 14px; +} +.ac-shelf-title { + font-family: var(--font-display); + font-size: 24px; font-weight: 500; + letter-spacing: -0.01em; line-height: 1.2; + color: var(--ink); + margin: 0; +} +.ac-shelf-count { + font-size: 13px; + color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} +.ac-shelf-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} +@media (max-width: 980px) { + .ac-shelf-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 560px) { + .ac-shelf-grid { grid-template-columns: 1fr; } +} + +.ac-shelf-card { + display: flex; gap: 12px; align-items: flex-start; + text-align: left; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + transition: transform 0.12s, border-color 0.12s; + cursor: pointer; + min-width: 0; +} +.ac-shelf-card:hover { + border-color: var(--border-strong); + transform: translateY(-1px); +} +.ac-shelf-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.ac-shelf-glyph { + width: 44px; height: 44px; + border: 1.5px solid; + border-radius: 10px; + display: grid; place-items: center; + font-family: var(--font-display); + font-size: 14px; font-weight: 500; + flex: none; +} +.ac-shelf-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.ac-shelf-card-name { + font-size: 14px; font-weight: 500; color: var(--ink); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.ac-shelf-card-meta { + font-size: 12px; color: var(--ink-soft); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.ac-shelf-card-warn { + font-size: 11px; + color: var(--warn); + font-weight: 500; + display: flex; align-items: center; gap: 4px; +} +.ac-month-dots { + display: flex; gap: 2px; margin-top: 2px; +} +.ac-month-dot { + width: 6px; height: 6px; border-radius: 999px; + background: var(--border); +} +.ac-month-dot-on { background: var(--accent); } + +.ac-recent-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.ac-recent-row { + display: flex; align-items: center; gap: 12px; + padding: 12px 16px; + border-top: 1px solid var(--border); + cursor: pointer; + background: transparent; + width: 100%; text-align: left; + transition: background 0.12s; +} +.ac-recent-row:first-child { border-top: none; } +.ac-recent-row:hover { background: var(--surface-alt); } +.ac-recent-cat-dot { + width: 8px; height: 8px; border-radius: 999px; flex: none; +} +.ac-recent-name { flex: 1; min-width: 0; font-size: 14px; color: var(--ink); font-weight: 500; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ac-recent-cat { + font-size: 11px; font-weight: 600; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--ink-muted); +} +.ac-recent-time { font-size: 12px; color: var(--ink-muted); font-variant-numeric: tabular-nums; } +.ac-recent-empty { + padding: 24px 16px; text-align: center; + color: var(--ink-muted); font-size: 13px; +} + +@media (max-width: 720px) { + .ac-hero-headline { font-size: 30px; } + .ac-hero-aside { font-size: 24px; } + .ac-month-strip { padding: 10px 12px; gap: 2px; } + .ac-month-cell { font-size: 10px; padding: 6px 0; } +} + +/* ── StatsTab ── */ +.ac-stats { display: flex; flex-direction: column; gap: 28px; } +.ac-stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} +.ac-stats-grid[data-card-count="5"] { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} +.ac-stats-grid[data-card-count="3"] { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.ac-statcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + min-width: 0; +} +.ac-statcard-cat { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 600; +} +.ac-statcard-num { + font-family: var(--font-display); + font-size: 32px; + margin-top: 4px; + letter-spacing: -0.02em; + color: var(--ink); + line-height: 1.1; + font-variant-numeric: tabular-nums; +} +.ac-statcard-of { color: var(--ink-muted); font-size: 0.6em; margin-left: 4px; } +.ac-statcard-bar { + height: 4px; + background: var(--surface-alt); + border-radius: 2px; + overflow: hidden; + margin-top: 10px; +} +.ac-statcard-fill { + height: 100%; + border-radius: 2px; + transition: width 0.4s ease; +} +.ac-statcard-pct { font-size: 11px; color: var(--ink-muted); margin-top: 6px; } + +.ac-chartcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 22px; +} +.ac-chart { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 6px; + height: 180px; + margin-top: 18px; + align-items: end; +} +.ac-chart-col { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + height: 100%; + min-width: 0; +} +.ac-chart-bar { + flex: 1; + width: 100%; + display: flex; + align-items: flex-end; + min-height: 0; +} +.ac-chart-bar-bg { + width: 100%; + background: var(--surface-alt); + border-radius: 6px 6px 0 0; + position: relative; + overflow: hidden; + border: 1px solid var(--border); + border-bottom: none; + transition: height 0.4s ease; +} +.ac-chart-bar-fill { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: var(--accent); + opacity: 0.5; + transition: height 0.4s ease; +} +.ac-chart-num { + font-size: 11px; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; +} +.ac-chart-month { + font-size: 11px; + color: var(--ink-muted); + font-family: var(--font-display); +} +.ac-chart-col.is-now .ac-chart-bar-bg { border-color: var(--accent); } +.ac-chart-col.is-now .ac-chart-month { color: var(--accent-ink); font-weight: 600; } +.ac-chart-legend { + display: flex; + gap: 18px; + font-size: 11px; + color: var(--ink-muted); + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border); +} +.ac-chart-legend-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; + margin-right: 6px; + vertical-align: middle; +} +.ac-chart-legend-dot-bg { + background: var(--surface-alt); + border: 1px solid var(--border); +} +.ac-chart-legend-dot-fill { + background: var(--accent); + opacity: 0.5; +} + +@media (max-width: 980px) { + .ac-stats-grid, + .ac-stats-grid[data-card-count="5"], + .ac-stats-grid[data-card-count="3"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 480px) { + .ac-stats-grid, + .ac-stats-grid[data-card-count="5"], + .ac-stats-grid[data-card-count="3"] { + grid-template-columns: 1fr; + } + .ac-chart { height: 140px; gap: 4px; } + .ac-chart-month { font-size: 10px; } + .ac-chart-num { font-size: 10px; } +} + +/* ── Phase 10: Mobile responsive polish ── */ +@media (max-width: 980px) { + /* Sidebar foot links and town actions need adequate tap area when stacked */ + .ac-foot-link { min-height: 44px; padding: 12px 14px; display: flex; align-items: center; } + .ac-town-switch, .ac-town-edit { min-height: 32px; padding: 6px 0; } + /* Search input padding bigger for tap */ + .ac-search { padding: 10px 14px; } + .ac-search input { font-size: 16px; /* prevent iOS zoom */ } +} + +@media (max-width: 720px) { + /* Hero + category title shrink already handled; ensure no overflow */ + .ac-hero-headline { word-break: break-word; } + .ac-category-title { word-break: break-word; } + /* Recent row: allow category label to truncate before time */ + .ac-recent-row { gap: 8px; padding: 12px; } + .ac-recent-cat { display: none; } + /* Settings close 44px */ + .ac-settings-close { width: 44px; height: 44px; font-size: 16px; } + /* TownManager close 44px */ + .ac-tm-close { width: 44px; height: 44px; font-size: 24px; border-radius: 10px; } + /* TownManager row-edit min tap target */ + .ac-tm-row-edit { min-width: 48px; } + /* TownManager action buttons full tap height */ + .ac-tm-ghost, .ac-tm-primary, .ac-tm-danger { min-height: 44px; padding: 10px 16px; } + .ac-tm-seg button { min-height: 36px; } + /* Hemisphere toggle bigger */ + .ac-hem-btn { min-height: 28px; padding: 4px 10px; font-size: 12px; } + /* GlobalSearch history rows tap-friendly */ + .ac-gs-history-row { min-height: 44px; padding: 10px 12px; } + .ac-gs-row { min-height: 48px; } + .ac-gs-clear { min-height: 32px; padding: 6px 10px; } + /* Settings danger button already full-width at 700; chevron tap */ + .ac-chevron { font-size: 22px; padding: 0 4px; } + /* Donate button full tap */ + .ac-donate-btn { min-height: 44px; } + /* Hide kbd hint footer on touch devices (no keyboard) */ + .ac-gs-foot { display: none; } + /* Sidebar nav items keep 44 (already set) but reduce vertical padding waste */ + .ac-nav { gap: 2px; } +} + +@media (max-width: 480px) { + .ac-hero-headline { font-size: 26px; } + .ac-hero-aside { font-size: 20px; } + .ac-category-title { font-size: 28px; } + .ac-settings-title { font-size: 32px; } + .ac-main { padding: 18px 14px 60px; } + .ac-sidebar { padding: 20px 16px; } + /* Expand panel: tighter on tiny screens */ + .ac-expand { padding: 4px 14px 18px 14px; } + /* Monthgrid labels shrink */ + .ac-monthcell-label { font-size: 9px; } + /* Town card name not clipped */ + .ac-town-name { font-size: 20px; word-break: break-word; } + /* Topbar wraps if needed */ + .ac-topbar { flex-wrap: wrap; } + .ac-search-wrap { max-width: 100%; min-width: 0; } } diff --git a/src/lib/categoryMeta.ts b/src/lib/categoryMeta.ts index 2902787..4e9edbc 100644 --- a/src/lib/categoryMeta.ts +++ b/src/lib/categoryMeta.ts @@ -25,7 +25,13 @@ const GAME_DATA_DIR: Partial> = { ACNH: '/data/acnh', }; -const GAMES_WITH_ART = new Set(['ACGCN', 'ACNL', 'ACNH']); +const GAMES_WITH_ART = new Set([ + 'ACGCN', + 'ACWW', + 'ACCF', + 'ACNL', + 'ACNH', +]); const GAMES_WITH_SEA_CREATURES = new Set(['ACNL', 'ACNH']); /** Returns per-category fetch paths for the given game. Null means no data file for that category/game combo. */ diff --git a/src/lib/colors.ts b/src/lib/colors.ts index 9893071..56b3371 100644 --- a/src/lib/colors.ts +++ b/src/lib/colors.ts @@ -7,3 +7,36 @@ export const colors = { border: '#E7DAC4', // borders muted: '#5a4a35', // secondary/muted text } as const; + +/** + * Meadow theme tokens (v0.9). Mirrors the CSS custom properties defined in + * `src/index.css` `@theme` block. Use the CSS vars (`var(--accent)`) for + * styling; this export exists for cases where a JS literal is required. + * + * Color values match the locked palette in docs/v0.9-plan.md §5. + */ +export const meadow = { + bg: '#F4EFE3', + surface: '#FFFDF7', + surfaceAlt: '#F8F2E2', + ink: '#23241F', + inkSoft: '#5C5848', + inkMuted: '#8A8470', + border: '#E2D9C3', + borderStrong: '#CFC4A8', + accent: 'oklch(0.55 0.09 150)', + accentSoft: 'oklch(0.55 0.09 150 / 0.12)', + accentInk: 'oklch(0.32 0.06 150)', + warn: 'oklch(0.62 0.12 50)', + warnSoft: 'oklch(0.62 0.12 50 / 0.14)', + chipFish: 'oklch(0.62 0.08 230)', + chipBugs: 'oklch(0.6 0.1 130)', + chipFossils: 'oklch(0.55 0.06 60)', + chipArt: 'oklch(0.58 0.08 320)', + chipSea: 'oklch(0.58 0.09 200)', +} as const; + +export const fontStacks = { + display: "'Fraunces', Georgia, serif", + sans: "'Inter', system-ui, sans-serif", +} as const; diff --git a/src/lib/csvExport.ts b/src/lib/csvExport.ts index d632301..6797540 100644 --- a/src/lib/csvExport.ts +++ b/src/lib/csvExport.ts @@ -49,10 +49,9 @@ export function downloadCSV( data: AllData, donatedMap: Record, donatedAtMap: Record, - townName: string, - playerName: string + townName: string ): void { - const csv = buildCSV(data, donatedMap, donatedAtMap, townName, playerName); + const csv = buildCSV(data, donatedMap, donatedAtMap, townName); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -70,8 +69,7 @@ export function buildCSV( data: AllData, donatedMap: Record, donatedAtMap: Record, - townName: string, - playerName: string + townName: string ): string { const sections: string[] = []; @@ -80,7 +78,6 @@ export function buildCSV( [ row('Animal Crossing GCN — Museum Tracker Export'), row('Town', townName), - row('Player', playerName), row( 'Exported', new Date().toLocaleString('en-US', { diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts index 23081e5..f1f4e84 100644 --- a/src/lib/store.test.ts +++ b/src/lib/store.test.ts @@ -15,34 +15,34 @@ beforeEach(() => { describe('createTown', () => { it('adds a town to the list', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(useAppStore.getState().towns).toHaveLength(1); expect(useAppStore.getState().towns[0].name).toBe('Pallet'); - expect(useAppStore.getState().towns[0].playerName).toBe('Ash'); + expect(useAppStore.getState().towns[0].gameId).toBe('ACGCN'); }); it('sets the new town as active immediately', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(useAppStore.getState().activeTownId).toBe(town.id); }); it('returns a town with a non-empty id and ISO createdAt', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(town.id).toBeTruthy(); expect(() => new Date(town.createdAt)).not.toThrow(); }); it('accumulates multiple towns', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); - const _t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); + const _t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); expect(useAppStore.getState().towns).toHaveLength(2); }); }); describe('setActiveTown', () => { it('switches the active town', () => { - const t1 = useAppStore.getState().createTown('Pallet', 'Ash'); - const t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + const t1 = useAppStore.getState().createTown('Pallet', 'ACGCN'); + const t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); // createTown sets active to t2; switch back to t1 useAppStore.getState().setActiveTown(t1.id); expect(useAppStore.getState().activeTownId).toBe(t1.id); @@ -53,13 +53,13 @@ describe('setActiveTown', () => { describe('deleteTown', () => { it('removes the town from the list', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().deleteTown(town.id); expect(useAppStore.getState().towns).toHaveLength(0); }); it('clears donated data for the deleted town', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); expect(useAppStore.getState().donated[town.id]).toBeDefined(); useAppStore.getState().deleteTown(town.id); @@ -68,22 +68,22 @@ describe('deleteTown', () => { }); it('falls back to the first remaining town when the active town is deleted', () => { - const t1 = useAppStore.getState().createTown('Pallet', 'Ash'); - const t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + const t1 = useAppStore.getState().createTown('Pallet', 'ACGCN'); + const t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); // t2 is active now; delete t2 → should fall back to t1 useAppStore.getState().deleteTown(t2.id); expect(useAppStore.getState().activeTownId).toBe(t1.id); }); it('sets activeTownId to null when the last town is deleted', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().deleteTown(town.id); expect(useAppStore.getState().activeTownId).toBeNull(); }); it('does not change activeTownId when a non-active town is deleted', () => { - const t1 = useAppStore.getState().createTown('Pallet', 'Ash'); - const t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + const t1 = useAppStore.getState().createTown('Pallet', 'ACGCN'); + const t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); useAppStore.getState().setActiveTown(t2.id); useAppStore.getState().deleteTown(t1.id); expect(useAppStore.getState().activeTownId).toBe(t2.id); @@ -94,20 +94,20 @@ describe('deleteTown', () => { describe('toggle', () => { it('marks an item as donated for the active town', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); expect(useAppStore.getState().isDonated('fish-001')).toBe(true); }); it('un-donates an already-donated item (toggle off)', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); useAppStore.getState().toggle('fish-001'); expect(useAppStore.getState().isDonated('fish-001')).toBe(false); }); it('records a donatedAt timestamp when toggled on', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); const before = Date.now(); useAppStore.getState().toggle('fish-001'); const after = Date.now(); @@ -119,7 +119,7 @@ describe('toggle', () => { }); it('removes the donatedAt timestamp when toggled off', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); useAppStore.getState().toggle('fish-001'); expect(useAppStore.getState().getDonatedAt('fish-001')).toBeUndefined(); @@ -132,10 +132,10 @@ describe('toggle', () => { }); it('keeps donations isolated per town', () => { - const t1 = useAppStore.getState().createTown('Pallet', 'Ash'); + const t1 = useAppStore.getState().createTown('Pallet', 'ACGCN'); useAppStore.getState().toggle('fish-001'); - const _t2 = useAppStore.getState().createTown('Viridian', 'Brock'); + const _t2 = useAppStore.getState().createTown('Viridian', 'ACGCN'); // t2 is now active; fish-001 should NOT be donated here expect(useAppStore.getState().isDonated('fish-001')).toBe(false); @@ -153,7 +153,7 @@ describe('isDonated', () => { }); it('returns false for an item that has never been toggled', () => { - useAppStore.getState().createTown('Pallet', 'Ash'); + useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(useAppStore.getState().isDonated('fish-999')).toBe(false); }); }); @@ -170,7 +170,7 @@ describe('getActiveTown', () => { }); it('returns the currently active town', () => { - const town = useAppStore.getState().createTown('Pallet', 'Ash'); + const town = useAppStore.getState().createTown('Pallet', 'ACGCN'); expect(useAppStore.getState().getActiveTown()?.id).toBe(town.id); }); }); diff --git a/src/lib/store.ts b/src/lib/store.ts index 46a4703..0f7134b 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -8,12 +8,16 @@ export type Hemisphere = 'NH' | 'SH'; export interface Town { id: string; name: string; - playerName: string; gameId: GameId; hemisphere: Hemisphere; createdAt: string; } +export interface TownPatch { + name?: string; + hemisphere?: Hemisphere | null; +} + interface AppState { towns: Town[]; activeTownId: string | null; @@ -22,8 +26,8 @@ interface AppState { // donatedAt[townId][gameId][itemId] = ISO string donatedAt: Record>>; - createTown: (name: string, playerName: string, gameId?: GameId) => Town; - updateTown: (id: string, name: string, playerName: string) => void; + createTown: (name: string, gameId: GameId, hemisphere?: Hemisphere) => Town; + updateTown: (id: string, patch: TownPatch) => void; setTownHemisphere: (id: string, hemisphere: Hemisphere) => void; setActiveTown: (id: string) => void; deleteTown: (id: string) => void; @@ -31,6 +35,8 @@ interface AppState { isDonated: (itemId: string) => boolean; getDonatedAt: (itemId: string) => string | undefined; getActiveTown: () => Town | undefined; + resetActiveTownDonations: () => void; + resetAll: () => void; } function generateId(): string { @@ -45,13 +51,12 @@ export const useAppStore = create()( donated: {}, donatedAt: {}, - createTown: (name, playerName, gameId = 'ACGCN') => { + createTown: (name, gameId, hemisphere = 'NH') => { const town: Town = { id: generateId(), name, - playerName, gameId, - hemisphere: 'NH', + hemisphere, createdAt: new Date().toISOString(), }; set(state => ({ @@ -61,11 +66,17 @@ export const useAppStore = create()( return town; }, - updateTown: (id, name, playerName) => + updateTown: (id, patch) => set(state => ({ - towns: state.towns.map(t => - t.id === id ? { ...t, name, playerName } : t - ), + towns: state.towns.map(t => { + if (t.id !== id) return t; + const next: Town = { ...t }; + if (patch.name !== undefined) next.name = patch.name; + if (patch.hemisphere !== undefined && patch.hemisphere !== null) { + next.hemisphere = patch.hemisphere; + } + return next; + }), })), setTownHemisphere: (id, hemisphere) => @@ -143,6 +154,31 @@ export const useAppStore = create()( const { towns, activeTownId } = get(); return towns.find(t => t.id === activeTownId); }, + + resetActiveTownDonations: () => + set(state => { + const { activeTownId } = state; + if (!activeTownId) return state; + const donated = { ...state.donated }; + const donatedAt = { ...state.donatedAt }; + delete donated[activeTownId]; + delete donatedAt[activeTownId]; + return { donated, donatedAt }; + }), + + resetAll: () => { + try { + localStorage.removeItem('ac-curator-search-history'); + } catch { + // ignore — localStorage may be unavailable (SSR / privacy mode) + } + set({ + towns: [], + activeTownId: null, + donated: {}, + donatedAt: {}, + }); + }, }), { name: 'ac-web', diff --git a/src/lib/types.ts b/src/lib/types.ts index 7640cd7..560f219 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -53,6 +53,15 @@ export const GAMES: Record = { }, }; +/** Convenience: list of games for selectors. */ +export const GAME_LIST: Game[] = [ + GAMES.ACGCN, + GAMES.ACWW, + GAMES.ACCF, + GAMES.ACNL, + GAMES.ACNH, +]; + export type Habitat = 'river' | 'ocean' | 'pond' | 'lake' | 'other'; export type CategoryId = 'fish' | 'bugs' | 'fossils' | 'art' | 'sea_creatures'; @@ -90,6 +99,8 @@ export interface ArtPiece { id: string; name: string; basedOn: string; + /** ACNH: whether Crazy Redd sells a counterfeit version of this piece */ + hasFake?: boolean; } export interface SeaCreature { diff --git a/src/lib/uiStore.ts b/src/lib/uiStore.ts new file mode 100644 index 0000000..b6a9efa --- /dev/null +++ b/src/lib/uiStore.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; + +interface UIState { + townManagerOpen: boolean; + /** When true, drawer is locked open (no scrim/Esc dismiss) and forced into create mode. Used when no towns exist. */ + townManagerForceCreate: boolean; + openTownManager: (forceCreate?: boolean) => void; + closeTownManager: () => void; +} + +export const useUIStore = create(set => ({ + townManagerOpen: false, + townManagerForceCreate: false, + openTownManager: (forceCreate = false) => + set({ townManagerOpen: true, townManagerForceCreate: forceCreate }), + closeTownManager: () => + set({ townManagerOpen: false, townManagerForceCreate: false }), +})); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 041e8f5..427afd7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -70,12 +70,8 @@ export function itemMonths( return critter.months; } -export function itemNotes( - item: AnyItem, - category?: CategoryId -): string | undefined { - if (category === 'sea_creatures' || isSeaCreature(item)) - return (item as SeaCreature).time; +export function itemNotes(item: AnyItem): string | undefined { + if (isSeaCreature(item)) return item.time; return isFish(item) ? item.notes : undefined; } diff --git a/src/lib/viewTypes.ts b/src/lib/viewTypes.ts index a0e499a..0de573d 100644 --- a/src/lib/viewTypes.ts +++ b/src/lib/viewTypes.ts @@ -7,7 +7,7 @@ import type { SeaCreature, } from './types'; -export type ViewId = CategoryId | 'home' | 'activity' | 'search' | 'analytics'; +export type ViewId = CategoryId | 'home' | 'activity' | 'analytics'; export interface AllData { fish: Fish[];