Skip to content

Latest commit

 

History

History
272 lines (217 loc) · 17.1 KB

File metadata and controls

272 lines (217 loc) · 17.1 KB

Summit — Design Decision Notes

Note: This is a planning artifact generated by /peak-workflow:setup. Initial decisions are derived from CLAUDE.md. Additional decisions will be captured in session handoff files as epics are implemented. Run /peak-workflow:refresh-docs to consolidate all decisions into this document.


These notes capture design decisions and rationale that complement the Architecture Document.


1. Client-only architecture, no backend

Decision: Summit has no server or API — it is a Vite + TypeScript SPA that persists all state to the browser's localStorage.

Rationale: This is a public reference example for the peak-workflow plugin. Favoring a client-only architecture keeps the end-to-end lifecycle walkthrough focused on clarity rather than backend/infra complexity.

2. Version sourced from package.json, stamped at both footer and console startup

Decision: package.json's version field is the single source of truth for the app version. It is injected as a build-time constant (via a Vite define) and used both for the footer display and a console.info log on app startup.

Rationale: Keeps version exposure and startup logging in sync with a single authoritative value, avoiding drift between what's displayed and what's built.


3. In-memory walking skeleton for habits (Epic U4nHItd) — staged replacement complete

Decision: In Epic U4nHItd, the habit list, add-habit input, filter control, and habit cards were backed by a module-scoped, in-memory ShellHabit[] array. There was no localStorage persistence, no streak math, and no name validation — intentional gaps filled by later epics.

Rationale: The walking skeleton allowed the TORs for add-habit form, filter toggle, and habit card rendering to be verified independently before the backend persistence logic (streaks, validation, archive semantics) existed. This kept the first epic's scope small and allowed verification to proceed in parallel with persistence and business logic work.

Staged Replacement (all completed):

  • Epic 1WIBPa0 (Local Persistence): Replaced the in-memory array with a localStorage-backed store module (src/storage/habitStore.ts), so state survives a page reload.
  • Epic Yz4JE9Z (Habit Management): Decomposed the monolithic src/App.ts render function into separate component and state modules (see Decision 6 below), added name validation, and implemented real archive/unarchive semantics.
  • Epic WKhBuVK (Daily Check-In & Streaks): Added streak math behind the existing done-today button in HabitCard via the markDoneStreak() function (see Decision 10 below).

4. localStorage persistence schema and streak-recalculation ownership

Decision: The full habit dataset lives under a single namespaced key, summit.habits (src/storage/habitStore.ts), as a JSON array of Habit objects: { name, streak, lastCompletedDate, archived }. lastCompletedDate is a local-time YYYY-MM-DD string or null — computed via todayISO(), which reads local calendar fields (getFullYear/getMonth/getDate), not toISOString(), since the latter is UTC and shifts the calendar date for most timezones. loadHabits() treats localStorage as a system boundary: it validates the parsed value is an array of well-shaped Habit objects and falls back to [] on any absent key, JSON parse failure, or shape mismatch, without throwing or logging.

Streak logic is split across two epics by lifecycle stage: this epic (src/storage/streakRecalculation.ts) owns load-time staleness recalculation — if lastCompletedDate is today or yesterday the streak survives untouched, otherwise it resets to 0. Epic WKhBuVK owns the mark-done increment rule. Concretely, in this epic the done-today toggle only sets/clears lastCompletedDate and persists it; it does not increment streak.

Rationale: A single JSON-array key keeps the storage contract simple and matches the product vision's stated persistence model. Local-date strings (rather than epoch timestamps) make "was this done today/yesterday" comparisons trivial string equality instead of timezone arithmetic. Splitting staleness recalculation (load-time) from increment (mutation-time) lets each epic own one clear lifecycle moment without the two streak epics stepping on each other.

5. Test infrastructure: localStorage polyfill for Node 22+ compatibility

Decision: tests/setup.ts installs a minimal in-memory MemoryStorage class (implementing the Storage interface) onto globalThis.localStorage before tests run. This is scoped to the test environment only — the browser app uses the real localStorage API.

Rationale: Node 22+'s experimental native localStorage global shadows jsdom's working implementation, and Vitest's jsdom allowlist predates Node's native global, leaving localStorage non-functional in tests. The polyfill restores test compatibility without affecting the production app. This approach is transparent to test code: all localStorage calls work as expected in both unit tests (against the polyfill) and browser verification (against the real API).

6. Component decomposition and state separation (Epic Yz4JE9Z)

Decision: The monolithic src/App.ts render function was decomposed into dedicated component and state modules:

  • src/components/AddHabitForm.ts — form with validation and error display
  • src/components/FilterToggle.ts — active/archived filter toggle with aria-pressed state
  • src/components/HabitList.ts — filtered habit list, delegating empty state and card rendering to child components
  • src/state/habitActions.ts — mutation layer with addHabit(), archiveHabit(), unarchiveHabit()
  • src/state/viewState.ts — encapsulates filter view state and filtering logic

src/App.ts is now a composition root that wires these modules together, calling render() on every mutation to refresh the UI.

Rationale: Decomposition separates concerns and makes each module's contract explicit. Each component has clear input (props passed by App) and output (event handlers). State modules encapsulate domain operations (habit mutations) and UI state (view filter) separately, reducing coupling and making the system easier to reason about and test.

7. Form-boundary validation (Epic Yz4JE9Z)

Decision: Name validation happens in src/components/AddHabitForm.ts at the form submit boundary, not in the habitActions.ts mutation layer. Empty or whitespace-only names are rejected with an inline error message and a WARN-level console log. The input value is trimmed before being passed to the action layer.

Rationale: Form validation is a presentation concern — errors should be shown to the user at the point of interaction, and only valid, pre-processed data should reach the domain layer. This keeps habitActions.ts simple (it assumes a non-empty, trimmed name) and ensures validation errors are caught and logged consistently at the boundary.

8. Full re-render for implicit form clearing (Epic Yz4JE9Z)

Decision: When a habit is successfully added, the add-habit input is cleared implicitly through a full root.replaceChildren() re-render in App.ts, not by clearing the DOM node in place. The onAdd callback in AddHabitForm triggers a full re-render, which produces a fresh form with an empty input.

Rationale: Full re-render keeps the render logic centralized and predictable — every state change triggers a consistent re-render from the root, ensuring the UI is always in sync with the current state. Avoiding imperative DOM mutations reduces the surface area for bugs and makes the render contract obvious.

9. Streak increment rules via pure function (Epic WKhBuVK)

Decision: Streak arithmetic is implemented in src/state/streakLogic.ts as a pure function markDoneStreak(habit, today) that enforces three rules: (1) if marked done again today, no-op; (2) if completed yesterday, increment streak; (3) otherwise (gap ≥ 2 days), reset streak to 1. The function is called from the mutation layer (habitActions.markDone) and is kept separate from the load-time staleness recalculation logic (storage/streakRecalculation.ts), allowing each epic to own one clear lifecycle moment.

Rationale: Pure functions are easier to test, reason about, and reuse than stateful mutations. Splitting load-time recalculation (Epic 1WIBPa0) from mark-done increment (this epic) keeps the two streak-related concerns independent, reducing coupling and the surface area for state-ordering bugs.

10. Done control idempotency (Epic WKhBuVK)

Decision: The "Done today" button on each habit card is made idempotent through both logic-level and UI-level guards: (1) markDoneStreak() returns a no-op if lastCompletedDate === today, preventing accidental increment; (2) the button's disabled attribute is set once a habit is done, making a second click structurally impossible.

Rationale: Idempotency protects against accidental duplicate increments from a repeat click and makes the button's behavior immediately obvious to the user — once it shows "Done ✓" and disables, there is no doubt that clicking again does nothing.

11. Streak badge visual prominence (Epic WKhBuVK)

Decision: The streak count is rendered in a dedicated renderStreakBadge() component (src/components/StreakBadge.ts) that displays the value distinctly larger and bolder than the habit name. As of Epic R5e7z3Y (see §14), .habit-card__streak-value renders at --text-h2 (1.75rem) / --weight-black (800) in --text-accent (amber), against the habit name's --weight-medium (500) at body size — still the heaviest, largest element on the card, now on the PeakFlames brand type ramp. The badge includes an aria-label that reads aloud as a full sentence ("Read 20 minutes, streak 5") for accessibility.

Rationale: Visual separation makes the streak the most prominent element on the card, drawing the eye and reinforcing the streak mechanic. The semantic aria-label ensures screen-reader users receive the same information without needing to infer the relationship between the number and the name.

Superseded by Epic R5e7z3Y (§14): the accent color driving this prominence is now amber (--text-accent), not the flame primary — see §14 for why.

12. Known Issues and Deferred Work

  • No favicon configured: Every page load triggers a benign browser-initiated /favicon.ico 404. This is cosmetic and not tied to any TOR. Recommend adding a favicon in a future epic.

13. Streak continuation hint placement and copy (Epic e3mj8uq)

Decision: The continuation hint (renderStreakHint() in src/components/StreakHint.ts) is rendered exactly once per page as a <p class="streak-hint">, placed in App.ts between the Active/Archived filter toggle and the habit list, and only when the Active view is selected. Copy is fixed: "Mark done tomorrow to continue a streak — a missed day resets it to 1." The function takes no parameters, attaches no event listeners, and sets no title or hidden attribute.

Rationale: The first implementation of this epic rendered the identical hint sentence inside every habit card (.habit-card__streak-hint), satisfying TOR-03-2OgotAa and TOR-03-MvP98PX as originally written but repeating the same text once per habit — reviewed post-implementation as redundant and noisy for a list of any size. The product vision and ConOps were revised (2026-08-30) to specify a single shared hint location for the habit list instead of per-card text, and both TORs were revised to match (same TOR IDs, updated Given/When/Then) rather than adding new IDs, since this is a scenario correction, not a new requirement. The hint is scoped to the Active view only, mirroring the product vision's placement of the hint under "Habit List View" (not "Archived Habits View") in its MVP Scope Summary. TOR-03-MvP98PX's no-popup/no-dismissal constraint is preserved: the hint still attaches no listeners and sets no title/hidden attribute, regardless of location.

14. PeakFlames Design System adoption: vendored tokens, additive classes, amber streak (Epic R5e7z3Y)

Decision: Summit's visual language is now the PeakFlames Design System. The token CSS (styles.css plus 10 tokens/*.css files: fonts, colors, typography, spacing, radius, elevation, motion, semantic, base, components) is vendored byte-identical into src/styles/peakflames/ from the design project (11ea476f-926c-40ea-8d34-91522c12d907), excluded from npm run lint's Prettier check via .prettierignore so re-syncs stay a clean diff against upstream. src/styles/main.css imports it as line 1, then keeps only Summit-specific layout and the state overrides the vendored .pf-* classes can't express. Every touched component (AddHabitForm, FilterToggle, HabitCard, StreakBadge, StreakHint, EmptyState) adds .pf-* classes alongside its existing class names — no class was renamed or removed, no DOM element, attribute, or copy changed.

Rationale — vendor, don't eyeball: Hand-approximating the brand palette risks drift from the source of truth and forfeits future re-syncs. Pulling the real token files means Summit's colors, spacing, and type scale are byte-for-byte the design system's, and a future brand update is a re-vendor, not a redesign.

Rationale — additive, not a rename: Four of the project's seven test files mount the real app and assert on .habit-card* class names and exact button textContent (see tests/persistence.test.ts, tests/dailyCheckinStreaks.test.ts, tests/habitManagement.test.ts, tests/emptyState.test.ts). Renaming classes to a pure .pf-* vocabulary would have required touching those tests, undermining them as an independent regression signal for a change that is supposed to be purely visual. Keeping both class names side by side lets the vendored CSS drive appearance while the original selectors keep meaning what they always meant to the test suite.

Rationale — amber, not flame, for the streak value: The design system's "one hot element per view" rule (TOR-05-G4eM1DW) reserves the flame accent for exactly one primary control. The add-habit submit button is that control — it's the one element guaranteed present regardless of how many habits exist, unlike a per-habit done button, which would put one flame-accented button per undone habit on screen simultaneously (confirmed during browser verification: with two undone habits, the Add button plus two Done buttons all rendered flame, i.e. three "hot" elements on one view). Habit-card Done buttons therefore use pf-btn--secondary as their base treatment instead of the pf-btn--primary the epic's original Key Components table specified — a spec deviation recorded in the epic's session handoff. --text-accent (amber) carries the streak value's visual weight instead, keeping it the card's most prominent element (§11) without competing for the page's one flame accent.

Rationale — fixed-width streak and done-button columns: The habit list originally used unconstrained flexbox: .habit-card__name grew to fill remaining space, and .habit-card__streak / .habit-card__done-btn sized to their own content. Since digit count ("5" vs "30") and button label ("Done today" vs "Done ✓") vary card to card, this let the streak and button columns start at a different x-position on every row — reported during manual review as misaligned. .habit-card__streak and .habit-card__done-btn now carry fixed min-widths so every card's columns line up regardless of content.

At narrow widths (max-width: 480px), the fix goes further: .habit-card__name is forced onto its own full-width row (flex-basis: 100%) so the habit name never competes for row space, and the streak/done/archive trio is deliberately kept together on the row beneath it — tightened min-widths and padding on .habit-card__streak, .habit-card__done-btn, and .habit-card__archive-btn at that breakpoint (reviewed against both "Done today"/"Archive" and the longer "Unarchive" label) ensure that trio fits on one line at the 375px baseline instead of any one of the three wrapping independently, which — before this pass — made every card break at a different point depending on habit-name length. .habit-card also gets justify-content: space-between at that breakpoint: since the name now owns the only other line, the trio is the sole content on its line, so space-between spreads it across the full card width (streak left, done centered, archive flush right) instead of leaving it clustered at the left edge with dead space after Archive.

Rationale — add-habit input/button share one row: .pf-input sets width: 100%, which becomes the flex item's used flex-basis (per the flexbox spec, an explicit non-auto width wins over a computed flex-basis: auto). That made the add-habit input claim the entire row, pushing the submit button onto its own line, left-aligned — a regression introduced by adding .pf-input additively, not present before this epic. .add-habit-form input now sets flex: 1 1 0%; min-width: 0, overriding the basis so the input fills only the space left over after the button's own content width — keeping both on one row with the button flush right, at every viewport width.