From f28c1a0e830739749582d1ba5ae09dd9c69b489b Mon Sep 17 00:00:00 2001 From: Brock Jenkinson Date: Sun, 3 May 2026 17:31:25 -0400 Subject: [PATCH 01/28] docs: add v0.9.0-beta implementation plan Canonical scope, phasing, and decision record for the v0.9 UI revamp. Covers 10 implementation phases, 10 locked design decisions, success criteria checklist, glossary of new/retired components, and open issue tracking (#59, #60, PR #45). Co-Authored-By: Claude Sonnet 4.6 --- docs/v0.9-plan.md | 660 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 docs/v0.9-plan.md diff --git a/docs/v0.9-plan.md b/docs/v0.9-plan.md new file mode 100644 index 0000000..459f1a1 --- /dev/null +++ b/docs/v0.9-plan.md @@ -0,0 +1,660 @@ +# v0.9.0-beta — Implementation Plan + +**Status:** In design / Pre-implementation +**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) | `design_handoff_v0.9_curator/` | Design language, palette, type scale, all screens incl. CategoryTab sectioning | +| v0.9.1 (second pass) | `design_handoff_v0.9.1_curator/` | TownManager drawer, GlobalSearchDropdown, Sea Creatures nav integration, `chip-sea` token | +| v0.9.2 (third pass, most current) | `design_handoff_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 + +**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 + +**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 + +**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 + +**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 /> + + + {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 index 459f1a1..5b95aa5 100644 --- a/docs/v0.9-plan.md +++ b/docs/v0.9-plan.md @@ -10,9 +10,9 @@ | Pass | Folder | Covers | |---|---|---| -| v0.9 (first pass) | `design_handoff_v0.9_curator/` | Design language, palette, type scale, all screens incl. CategoryTab sectioning | -| v0.9.1 (second pass) | `design_handoff_v0.9.1_curator/` | TownManager drawer, GlobalSearchDropdown, Sea Creatures nav integration, `chip-sea` token | -| v0.9.2 (third pass, most current) | `design_handoff_v0.9.2_curator/` | ProgressMeter 5-segment responsive, scroll-to + highlight, Settings page | +| 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. @@ -91,7 +91,7 @@ These decisions were made explicitly in conversation with Bea on 2026-05-03 and **Why:** Changing a town's game would orphan all existing donation data — the 3-level store schema (`donated[townId][gameId][itemId]`) scopes donations under the original `gameId`. A game change would leave ghost data under the old `gameId` with no migration UX. Building migration UX is out of scope for v0.9. -**Implementation note:** The v0.9.2 design (`design_handoff_v0.9.2_curator/addons.jsx` `TownRow` editing state, line 104) still includes the game ``: ```jsx
); } diff --git a/src/components/CategoryTab.tsx b/src/components/CategoryTab.tsx index b46ff75..317336d 100644 --- a/src/components/CategoryTab.tsx +++ b/src/components/CategoryTab.tsx @@ -28,7 +28,6 @@ interface CategoryTabProps { query: string; setQuery: (q: string) => void; highlightId: string | null; - onItemSelect: (item: AnyItem) => void; onToggle: (id: string) => void; catLabel: string; } @@ -43,7 +42,6 @@ export function CategoryTab({ query, setQuery, highlightId, - onItemSelect, onToggle, catLabel, }: CategoryTabProps) { @@ -57,7 +55,6 @@ export function CategoryTab({ // Open the expand panel when a highlight arrives (Decision 10). useEffect(() => { if (!highlightId) return; - if (category === 'art') return; // art uses modal, not inline setExpandedId(highlightId); }, [highlightId, category]); @@ -177,22 +174,16 @@ export function CategoryTab({ category={category} checked={!!donated[item.id]} onClick={() => { - if (category === 'art') { - onItemSelect(item); - } else { - setExpandedId(prev => - prev === item.id ? null : item.id - ); - } + setExpandedId(prev => + prev === item.id ? null : item.id + ); }} - expanded={ - category !== 'art' ? expandedId === item.id : undefined - } + expanded={expandedId === item.id} highlighted={highlightId === item.id} hemisphere={hemisphere} currentMonth={currentMonth} /> - {category !== 'art' && expandedId === item.id && ( + {expandedId === item.id && ( 0); @@ -47,6 +49,21 @@ export function ItemExpandPanel({
)}
+ {art && ( +
+
{art.basedOn}
+
based on
+
+ )} + {art?.hasFake !== undefined && ( +
+ {art.hasFake + ? 'Crazy Redd may sell a counterfeit — verify before donating.' + : 'No known counterfeit — always genuine.'} +
+ )} {bells != null && (
{bells.toLocaleString()}
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/index.css b/src/index.css index 1f96727..2bc3186 100644 --- a/src/index.css +++ b/src/index.css @@ -948,6 +948,25 @@ 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); diff --git a/src/lib/types.ts b/src/lib/types.ts index b2156d4..560f219 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -99,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 { From a5e650901ed0676f9715357570b9bf55668c478e Mon Sep 17 00:00:00 2001 From: Brock Jenkinson Date: Mon, 4 May 2026 01:25:53 -0400 Subject: [PATCH 27/28] docs(#81): update DetailModal references after retirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep of active living docs to reflect that DetailModal was deleted in #81 and Art now uses the inline ItemExpandPanel. - README.md: collapse the separate "bottom-sheet detail view" bullet into the inline-expand bullet; mention basedOn + Crazy Redd note. - .claude/rules/architecture.md: ACCanvas, CategoryTab, and modals/ entries all reframed — DetailModal listed as retired. Left untouched (history / out of scope): - CHANGELOG Phase 7 Decisions entry ("Art keeps its bottom-sheet DetailModal") — describes a v0.9 Phase 7 decision that was reversed in #81. As Phase 7 history it's still accurate. Reported to Bea. - docs/v0.7-audit.md, docs/v0.7-architecture-proposal.md — historical v0.7 docs. - docs/design-handoffs/v0.9_curator/README.md — design handoff snapshot. Reported to Bea. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/architecture.md | 6 +++--- README.md | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 1c1ac21..5105dc8 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -27,12 +27,12 @@ Vite + React 19 + TypeScript + Tailwind CSS v4 + Zustand (persist + non-persiste - `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) and DetailModal (art). +- `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`. Routes art clicks to DetailModal instead of inline expand. +- `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. @@ -40,7 +40,7 @@ Vite + React 19 + TypeScript + Tailwind CSS v4 + Zustand (persist + non-persiste - `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.tsx` — bottom-sheet detail. Used by Art and as a fallback in some paths. +- `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. diff --git a/README.md b/README.md index 0704a2a..67388c4 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,7 @@ A cozy companion web app for tracking Animal Crossing museum donations across mu - 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 - **Persistent left sidebar** with brand, active town card, per-category donation counts, and Export CSV / Settings footer -- **Inline item expand** — tap a Fish, Bug, Fossil, or Sea Creature row to open a two-column panel with the month availability grid, bells / shadow / hours, notes, and donate/undonate button -- **Bottom-sheet detail view** — Art opens a full detail sheet (`DetailModal`) +- **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 hero stat, current-month strip, "Leaving end of {month}" and "Just arrived" shelves, segmented progress meter, and latest donations From 6a169c0ff22014340b031f36dc7566b3d58204fa Mon Sep 17 00:00:00 2001 From: Brock Jenkinson Date: Mon, 4 May 2026 01:53:02 -0400 Subject: [PATCH 28/28] release: stage v0.9.0-beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: roll [Unreleased] section into [v0.9.0-beta] - 2026-05-04; add fresh empty [Unreleased] above - App.tsx (#60): hide branch suffix in version footer when branch starts with `release/` - CLAUDE.md, README.md: mark v0.9.0-beta as shipped - docs/v0.9-plan.md: status header to shipped - public/version-history.html: add v0.9.0-beta entry to timeline, velocity table, and "currently building" → v1.0 --- CHANGELOG.md | 7 +++- CLAUDE.md | 2 +- README.md | 2 +- docs/v0.9-plan.md | 2 +- public/version-history.html | 77 ++++++++++++++++++++++++++++--------- src/App.tsx | 26 ++++++++----- 6 files changed, 84 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb8b81..c3297c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,12 @@ All notable changes to this project are documented here. -## [Unreleased] — v0.9.0-beta (in progress) +## [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). diff --git a/CLAUDE.md b/CLAUDE.md index fc51553..60e523c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ See `docs/architecture.md` — deep architectural context (store schema, migrati ## Project Overview Animal Crossing multi-game companion web app. Tracks museum donations (fish, bugs, fossils, art) across multiple towns and games. -Meadow design language (Fraunces + Inter, moss-green accent) as of v0.9. **Last stable: v0.8.2-alpha (shipped 2026-05-01). Active development: v0.9.0-beta — Phases 1–9 shipped to `development`; Phase 10 (mobile responsive verification) pending. See `docs/v0.9-plan.md` for canonical plan.** +Meadow design language (Fraunces + Inter, moss-green accent) as of v0.9. **Current release: v0.9.0-beta (shipped 2026-05-04) — full UI revamp (Sidebar, TownManager, sectioned CategoryTab, redesigned HomeTab + StatsTab, GlobalSearchDropdown, mobile responsive). See `docs/v0.9-plan.md` for the implementation plan and `CHANGELOG.md` for the full entry.** Live at: https://animalcrossingwebapp.vercel.app | Dev preview: https://development-animalcrossingwebapp.vercel.app ## Commands diff --git a/README.md b/README.md index 67388c4..68f0b96 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,6 @@ Museum data lives in `public/data//`: ## Version -Current release: **v0.8.2-alpha** (last stable on `main`). Active development: **v0.9.0-beta** — Phases 1–9 of the UI revamp shipped to `development`; Phase 10 (mobile responsive verification) pending. See [CHANGELOG.md](CHANGELOG.md) for history and [docs/v0.9-plan.md](docs/v0.9-plan.md) for the canonical plan. +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/v0.9-plan.md b/docs/v0.9-plan.md index fa96b61..9919357 100644 --- a/docs/v0.9-plan.md +++ b/docs/v0.9-plan.md @@ -1,6 +1,6 @@ # v0.9.0-beta — Implementation Plan -**Status:** In implementation — Phases 1–10 shipped to `development`; ready to tag v0.9.0-beta +**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 diff --git a/public/version-history.html b/public/version-history.html index f8c969e..d60fd23 100644 --- a/public/version-history.html +++ b/public/version-history.html @@ -422,22 +422,22 @@
🍃

Animal Crossing Web App

-

Version history & roadmap · Updated May 1, 2026

+

Version history & roadmap · Updated May 4, 2026

-
🚀 v0.1 → v0.8.2 in 21 days
-
Eleven releases. Blathers can't keep up.
+
🚀 v0.1 → v0.9 in 24 days
+
Twelve releases. Blathers can't keep up.
-
11
+
12
releases shipped
-
21
+
24
days elapsed
@@ -456,6 +456,35 @@

Animal Crossing Web App

🏅 Shipped versions

+ +
+
May 4
2026
+
+
+
+
+
+
+ v0.9.0-beta + Curator UI revamp + 3 days after v0.8.2 +
+
    +
  • Full Meadow design language — Fraunces + Inter type, moss-green accent, retired Varela Round + parchment palette
  • +
  • Sidebar shell replaces MuseumHeader + TabBar + TownSwitcher; per-category counts, sea nav gated for ACNL/ACNH
  • +
  • TownManager drawer unifies create/edit/delete (resolves the v0.8.1 greyed-out modal stopgap); game immutable post-creation, `playerName` deprecated
  • +
  • Settings page with About + Danger zone (reset active-town donations, reset everything)
  • +
  • HomeTab rebuilt — hero stat, month strip, leaving-soon + just-arrived shelves, ProgressMeter (4 or 5 segments by game)
  • +
  • CategoryTab sectioned (Leaving / Available / Out of season / Already donated) with month-wrap logic
  • +
  • GlobalSearchDropdown with category groups, keyboard nav (↑↓↵esc), search history, art `basedOn` matching preserved
  • +
  • StatsTab redesigned — per-category cards (3/4/5 by game) and yearly rhythm chart
  • +
  • Art tab now uses inline ItemExpandPanel; DetailModal retired
  • +
  • ACWW + ACCF art data shipped (Closes #74); Meadow tokens, scroll-to + pulse highlight, full mobile responsive pass at 980/720/700/480 breakpoints
  • +
  • Bug fixes: ACNH all-caught-up (#71), art modal close (#81), version footer suffix (#60), modal sticky-label (#26)
  • +
+
+
+
May 1
2026
@@ -710,47 +739,51 @@

🏅 Shipped versions

-

🔨 Currently building — v0.9

+

🔨 Currently building — v1.0

UP NEXT -

Polish, onboarding & PWA

- making it feel at home +

Launch ready

+ polish, PWA, accessibility
-

✅ Recently shipped (v0.8.2)

+

✅ Recently shipped (v0.9.0-beta)

- Sea creatures tab — ACNH + ACNL, inline expand panel, Home tab cards, search, CSV + Meadow UI revamp — Sidebar, TownManager drawer, sectioned CategoryTab, redesigned HomeTab + StatsTab, GlobalSearchDropdown +
+
+
+ Mobile responsive pass at 980/720/700/480 breakpoints; scroll-to + highlight wiring +
+
+
+ ACWW + ACCF art data; art tab now inline-expand

⬜ Planned

-
-
·
- UI redesign pass — cozy-er, more expressive -
·
PWA support — install to home screen
·
- Mobile-first responsive layout pass + First-run onboarding / empty state improvements
·
- First-run onboarding / empty state improvements + Full accessibility audit (#76, #82) — keyboard nav, ARIA, focus rings
·
- Seasonal / time-based filtering in list views + SEO + Open Graph metadata; performance pass
·
- Improved accessibility — keyboard nav, focus rings + Branding & identity polish — drop the alpha/beta label
@@ -886,13 +919,19 @@

⚡ Velocity detail

1 day Sea Creatures tab (ACNH + ACNL) + + v0.9.0-beta + May 4 + 3 days + Curator UI revamp — Meadow design, Sidebar, TownManager, sectioned categories +
diff --git a/src/App.tsx b/src/App.tsx index 93c2802..9b1efa6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -81,15 +81,23 @@ function App() { pointerEvents: 'none', }} > - {window.location.hostname === 'animalcrossingwebapp.vercel.app' ? ( - <>v{import.meta.env.VITE_APP_VERSION} - ) : ( - <> - v{import.meta.env.VITE_APP_VERSION} - {' · '} - {import.meta.env.VITE_GIT_BRANCH} - - )} + {(() => { + const version = import.meta.env.VITE_APP_VERSION; + const branch = import.meta.env.VITE_GIT_BRANCH; + const isProd = + window.location.hostname === 'animalcrossingwebapp.vercel.app'; + const hideBranchSuffix = + isProd || !branch || branch.startsWith('release/'); + return hideBranchSuffix ? ( + <>v{version} + ) : ( + <> + v{version} + {' · '} + {branch} + + ); + })()}
)}