From dde5e77317d7b31c6f5ed4d40a533782ce1d1852 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 4 Sep 2026 13:44:31 +0200 Subject: [PATCH] feat: enhance typography and accessibility in design system - Added new UI heading utility with specific styles in typography.css. - Updated typography tests to include new UI heading role and ensure proper size and styling. - Documented design system audit findings, focusing on accessibility improvements and component consistency. - Introduced CalendarExamples component to demonstrate calendar functionality with habit tracking. - Created a shared Field component for consistent labeling and hinting of form controls. - Implemented comprehensive accessibility tests for calendar and editor components, ensuring proper focus management and keyboard navigation. - Added structure tests for Field and Card components to verify correct ID generation and heading levels. --- README.md | 57 ++- docs/design/audit-2026-09-04.md | 80 ++++ src/App.test.tsx | 82 +++- .../design-system/CalendarExamples.tsx | 64 +++ .../design-system/CalendarHeatmap.tsx | 296 +++++++------ src/components/design-system/Card.tsx | 5 +- .../design-system/ContainerShowcase.tsx | 16 +- .../design-system/DesignSystemTabs.tsx | 12 +- .../design-system/EditingWorkbench.tsx | 70 +-- src/components/design-system/Field.tsx | 28 ++ .../design-system/HabitColorPicker.tsx | 30 +- .../design-system/accessibility.test.tsx | 215 ++++++++++ .../design-system/calendar-model.test.ts | 16 + .../design-system/calendar-model.ts | 20 + .../design-system/structure.test.tsx | 52 +++ src/pages/DesignSystem.tsx | 404 +++--------------- styles/design-system.css | 245 ++++++----- styles/typography.css | 8 + styles/typography.test.ts | 58 ++- 19 files changed, 1077 insertions(+), 681 deletions(-) create mode 100644 docs/design/audit-2026-09-04.md create mode 100644 src/components/design-system/CalendarExamples.tsx create mode 100644 src/components/design-system/Field.tsx create mode 100644 src/components/design-system/accessibility.test.tsx create mode 100644 src/components/design-system/structure.test.tsx diff --git a/README.md b/README.md index d7da6d8..5f98cf6 100644 --- a/README.md +++ b/README.md @@ -19,21 +19,29 @@ Open http://127.0.0.1:3000. Set `PORT` to use a different port. ### Design system preview -The library uses seven tabs: Foundations, Components, Layout, Calendars, Playground, -Editing, and References. Foundations is the default. Each tab has a URL hash; -existing section links still work. Arrow keys, Home, and End navigate the tabs. -Switching tabs preserves unsaved demo state; browser back/forward restores the section. +The library uses four tabs: Foundations, Components, Layout, and Calendars. +Foundations is the default. Arrow keys, Home, and End navigate the tabs; URL +hashes and browser back/forward restore the section. Switching tabs preserves +local example state. Old `#playground` links open `#calendar-states`; removed +`#editing` and `#views` links resolve to `#foundations`. -Open `/design-system#editing` for interactive habit settings, task recurrence, -and dated progress corrections. Habit colors include Earth, Coast, Dusk, Forest, -Citrus, Blossom, Jewel, and Slate palettes plus a native picker and hex input. Editors support save/cancel, -archive/restore, and an in-session correction history. The backfill calendar -uses frozen historical requirements, independent of edits to today's settings. -These are unsaved, local UI examples with a fixed September 4, 2026 demo clock; -they do not call the habit API or change account data. Reload or Reset editor -clears the preview. Habit colors preview immediately across the editor and the -daily/detail charts; Save keeps the color in the demo and Cancel restores it. -Other settings and progress remain independent of the daily tracking playground. +Open `/design-system#calendar-states` for progress states and three working +calendar examples: a count target, a manual checkbox, and combined completion. +Changing water or reading progress updates today's combined score. Each calendar +supports 3/4/6/12-month ranges, a Custom start/end date range within available +history, date inspection, and keyboard navigation. Custom dates are inclusive; +Apply range updates the chart. Dates +form one continuous weekly chart with shared month and weekday labels and a fixed +4px gap between dates across every timeframe. Landscape +views fit the available width without chart scrollbars; narrow portrait views +retain 24px date targets in a local horizontal scroller. Primary controls use +44px targets, and custom checkboxes retain native semantics in forced-color mode. +These examples use a fixed September 4, 2026 demo date and do not call the API; +reload or Reset examples restores their initial progress. + +The Playground, Editing, and References showcase tabs have been removed. +Reusable calendar, editing, color-picker components and reference assets remain +available for future pages. The previous Landing, Home, About, and Settings pages have been removed. The backend API, authentication infrastructure, database, and reusable components remain @@ -52,7 +60,26 @@ in rem. At 640px and below, display steps to 48/54 and section to 36/45; body an supporting text never shrink. Use these classes directly or with `@apply`, not bespoke font sizes or viewport-based type. See `/design-system#foundations` for live specimens. Reuse `SectionHeading`, `Button`, `ButtonLink`, and the shared -`DiscordSignInButton` for headings and calls to action. +`DiscordSignInButton` for headings and calls to action. All default button variants, +including text/ghost buttons and links, share 10px vertical and 19px horizontal +padding. Ghost changes only colors and border visibility; counter buttons retain +their explicit 44px square sizing. +Use `type-ui-heading` (18/27, medium system sans) for interface headings, field +labels, and form legends; it stays visibly larger than 14/21 supporting text at +every breakpoint. Preserve serif `type-title` and `type-section` for editorial +headings. Use `type-eyebrow` for 12/18 section markers. `Field` supplies a control ID and hint ID through its +render callback; apply both to the native control. `Card.headingLevel` defaults +to 3 and can be set to match its surrounding document hierarchy without changing +its appearance. + +Interface colors use the existing neutral palette through `--ds-ink`, +`--ds-secondary`, `--ds-control-border`, `--ds-rule`, `--ds-paper`, +`--ds-surface` (the existing #F5F5F5 fill), and `--ds-empty` (the existing #EEEEEE +calendar fill). Use secondary for muted text and primary hover, surface for +neutral hover/grouping, and control-border for actionable boundaries. Keep the +calendar progress ramp and habit identity colors separate. Foundations lists the +values and roles. Supporting copy should explain a consequence, constraint, or +interaction; omit prose that merely restates a heading or visible control. ```sh bun run typecheck diff --git a/docs/design/audit-2026-09-04.md b/docs/design/audit-2026-09-04.md new file mode 100644 index 0000000..6f7b8c5 --- /dev/null +++ b/docs/design/audit-2026-09-04.md @@ -0,0 +1,80 @@ +# Design-system audit — September 4, 2026 + +Three parallel audits covered component structure, accessibility/interaction, +and visual hierarchy/responsiveness. The existing serif/monochrome direction and +unsaved local demos were preserved. No authentication or API behavior was changed. + +## Findings resolved + +- Extracted a shared `Field` for consistent native controls, labels, and hints; + the Components specimen now matches the editor and color picker. +- Added configurable semantic heading levels to `Card`; Layout cards are h4 + beneath h3 groups, with appearance independent of heading level. +- Documented the eyebrow type role and introduced a shared medium-weight + sans-serif role for compact interface headings. +- Replaced compressed annual heatmap cells (5.39px at a 480px viewport) with + minimum 24px targets in a local horizontal scroller. +- Associated explicit scrolling and keyboard instructions with calendar controls. +- Corrected calendar focus when an editor rejects a selection or changes the + visible month window; rejected requests cannot steal focus on later updates. +- Reveal the selected calendar date when a previously hidden panel opens or its + viewport resizes, without stealing focus from the current control. +- Labeled task groups and restored useful focus after adding/removing tasks, + with live feedback about draft changes. +- Linked editor hints and custom-color validation feedback programmatically. +- Enlarged counters, checkbox labels, month controls, and color choices to 44px. +- Added a higher-contrast shared control-boundary token. +- Allowed palette, spacing, form, toolbar, and chart content to wrap on narrow + layouts; restored description line breaks and stacked split sections earlier. +- Added native checkbox rendering and visible state boundaries for forced colors. + +## Verification + +- Full typecheck, test suite, production build, and whitespace checks pass. +- Regression coverage includes semantic headings, Field IDs/hints, mounted-panel + reference integrity, calendar focus rejection, shifting date windows, task + focus, color validation, contrast tokens, and minimum target styles. +- Live browser: all seven tabs at 480px and 2560px had document width equal to + viewport width. Calendar cells measured 24.11px after the change. +- Live interactions: form focus/hints/submission feedback and task add/remove + focus were verified. The desktop preview reported no console errors/warnings. +- Verified all four calendar ranges, selected-date visibility after tab reveal, + and matching keyboard focus/selection in the live desktop preview. +- The browser's native unsaved-change confirmation stalled the browser tool; + rejection behavior was verified in component tests, not end-to-end through + that dialog. Verification continued in a fresh preview. + +The available browser viewport clamps at 480px, so 320px behavior and actual +screen-reader/forced-color rendering have not been manually certified. Responsive +rules and forced-color fallbacks have source-level regression coverage. + + +## Focused hierarchy and palette follow-up + +- Increased `type-ui-heading` from 14/21 to 18/27 using a shared 1.125rem token. + Specimen headings, component headings, field labels, task/schedule legends, and + the habit-color legend share it. Supporting text remains 14/21; serif display, + section, and component-title roles retain their existing sizes. +- Used 8px heading-to-caption spacing in component rows and calendar state keys. +- Removed redundant section introductions, repeated demo notices, and decorative + encouragement beside working controls. Kept save/cancel, historical-edit, + calendar keyboard, validation, and preview-scope guidance. +- Replaced ad hoc #737373/#888 with secondary, #777 with control-border, #333 + hover with secondary, and #f3f3f3/#ebebeb with the existing #f5f5f5 surface. + Named the already-used surface and empty-calendar fills as `--ds-surface` and + `--ds-empty`. All neutral CSS literals now live in the token declarations; + calendar progress ramps and habit identity/custom colors are unchanged. +- Updated Foundations specimens and README role guidance alongside the styles. + +Follow-up verification: typecheck, production build, all 123 tests (0 failures), +and `git diff --check` passed. A static production preview confirmed 18/27px +interface headings, 14/21px hints, and 8px field gaps. All seven tabs had document +width equal to the 480px viewport; sampled specimen headings, fields, and color +swatches had no internal overflow. Desktop Components and Editing fit at 1440px; +serif card titles remained 32px. Visually inspected desktop Components/Editing +and mobile Components/Layout/Editing. The name-preview submit and counter worked, +and the browser recorded no warnings or errors. The browser clamps requested +mobile widths below 480px, so narrower rendered widths remain unverified. + +This follow-up preserved the pre-existing uncommitted work and did not modify +backend code, database state, or habit calculation behavior. diff --git a/src/App.test.tsx b/src/App.test.tsx index 68a0218..b2a39a9 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -174,7 +174,7 @@ describe("design-system-only routing", () => { expect(container.querySelector(".home-root")).toBeNull(); expect(fetchMock).not.toHaveBeenCalled(); expect(container.querySelectorAll('[role="tablist"]')).toHaveLength(1); - expect(container.querySelectorAll('[role="tab"]')).toHaveLength(7); + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(4); expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1); for (const tab of container.querySelectorAll('[role="tab"]')) { const panel = document.getElementById(tab.getAttribute("aria-controls")!); @@ -183,13 +183,24 @@ describe("design-system-only routing", () => { }); } - test("design system playground remains interactive without API requests", async () => { - await render("/design-system#playground"); - const tabs = Array.from(container.querySelectorAll("button")); - const detail = tabs.find(button => button.textContent?.trim() === "Habit detail")!; - expect(detail).toBeDefined(); - await act(async () => detail.click()); - expect(detail.getAttribute("aria-pressed")).toBe("true"); + test("calendar examples update individual and combined progress without API requests", async () => { + await render("/design-system#calendar-states"); + const calendars = container.querySelectorAll("#ds-panel-calendar-states .ds-calendar"); + expect(calendars).toHaveLength(3); + const inspected = (index: number) => calendars[index]!.querySelector(".ds-date-inspector")!.textContent!; + expect(inspected(0)).toContain("7 of 8 glasses"); + expect(inspected(2)).toContain("1 of 2 habits complete"); + await act(async () => container.querySelector('[aria-label="Increase glasses of water"]')!.click()); + expect(inspected(0)).toContain("8 of 8 glasses"); + expect(inspected(2)).toContain("2 of 2 habits complete"); + const reading = container.querySelector('#ds-panel-calendar-states input[type="checkbox"]')!; + await act(async () => reading.click()); + expect(inspected(1)).toContain("0 of 1 reading session"); + expect(inspected(2)).toContain("1 of 2 habits complete"); + const reset = [...container.querySelectorAll('#ds-panel-calendar-states button')].find(button => button.textContent === "Reset examples ↺")!; + await act(async () => reset.click()); + expect(inspected(0)).toContain("7 of 8 glasses"); + expect(reading.checked).toBe(true); expect(fetchMock).not.toHaveBeenCalled(); }); }); @@ -207,7 +218,7 @@ describe("design system tabs", () => { test("defaults to foundations and each tab exposes only its own panel", async () => { await render(); expect(panel().id).toBe("ds-panel-foundations"); - for (const id of ["components", "containers", "calendar-states", "playground", "editing", "views", "foundations"]) { + for (const id of ["components", "containers", "calendar-states", "foundations"]) { await clickTab(id); expect(panel().id).toBe(`ds-panel-${id}`); expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1); @@ -221,39 +232,39 @@ describe("design system tabs", () => { test("supports arrow keys, Home, End, and wraparound with focus", async () => { await render(); await press("foundations", "ArrowLeft"); - expect(document.activeElement).toBe(tab("views")); - expect(panel().id).toBe("ds-panel-views"); - await press("views", "ArrowRight"); + expect(document.activeElement).toBe(tab("calendar-states")); + expect(panel().id).toBe("ds-panel-calendar-states"); + await press("calendar-states", "ArrowRight"); expect(document.activeElement).toBe(tab("foundations")); await press("foundations", "End"); - expect(document.activeElement).toBe(tab("views")); - await press("views", "Home"); + expect(document.activeElement).toBe(tab("calendar-states")); + await press("calendar-states", "Home"); expect(document.activeElement).toBe(tab("foundations")); expect(panel().id).toBe("ds-panel-foundations"); }); test("honors deep links and browser history", async () => { - await render("/design-system#editing"); - expect(panel().id).toBe("ds-panel-editing"); + await render("/design-system#calendar-states"); + expect(panel().id).toBe("ds-panel-calendar-states"); await clickTab("containers"); const historyButton = (label: string) => Array.from(container.querySelectorAll("button")).find(button => button.textContent === label)!; await act(async () => historyButton("History back").click()); - expect(panel().id).toBe("ds-panel-editing"); + expect(panel().id).toBe("ds-panel-calendar-states"); await act(async () => historyButton("History forward").click()); expect(panel().id).toBe("ds-panel-containers"); }); - test("keeps example state and opens the playground from View habit", async () => { + test("keeps example state and opens Calendars from View habit", async () => { await render("/design-system#components"); await act(async () => panel().querySelector('[aria-label="Increase example count"]')!.click()); - await clickTab("editing"); + await clickTab("calendar-states"); await clickTab("components"); expect(panel().querySelector("output")?.textContent).toBe("4 / 8"); const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!; await act(async () => viewHabit.click()); - expect(panel().id).toBe("ds-panel-playground"); - expect(panel().querySelector('.ds-view-switch [aria-pressed="true"]')?.textContent).toBe("Habit detail"); - expect(document.activeElement).toBe(tab("playground")); + expect(panel().id).toBe("ds-panel-calendar-states"); + expect(panel().querySelectorAll(".ds-calendar")).toHaveLength(3); + expect(document.activeElement).toBe(tab("calendar-states")); }); test("retains child component state and skip link does not switch tabs", async () => { @@ -269,8 +280,35 @@ describe("design system tabs", () => { expect(document.activeElement?.id).toBe("ds-main"); }); + for (const [retired, destination] of [["playground", "calendar-states"], ["editing", "foundations"], ["views", "foundations"]]) { + test(`retired #${retired} links resolve to #${destination}`, async () => { + await render(`/design-system#${retired}`); + expect(panel().id).toBe(`ds-panel-${destination}`); + expect(container.querySelector('[data-testid="hash"]')?.textContent).toBe(`#${destination}`); + expect([...container.querySelectorAll('[role="tab"]')].map(item => item.textContent)).toEqual(["Foundations", "Components", "Layout", "Calendars"]); + for (const id of ["playground", "editing", "views"]) { + expect(container.querySelector(`#${id}`)).toBeNull(); + expect(container.querySelector(`#ds-panel-${id}`)).toBeNull(); + } + }); + } + test("an unknown section safely falls back to foundations", async () => { await render("/design-system#unknown"); expect(panel().id).toBe("ds-panel-foundations"); }); + + test("mounted panels keep unique IDs and valid accessible references", async () => { + await render(); + const ids = Array.from(container.querySelectorAll("[id]"), element => element.id); + expect(new Set(ids).size).toBe(ids.length); + for (const element of container.querySelectorAll("[aria-labelledby], [aria-describedby], [aria-controls], label[for]")) { + for (const attribute of ["aria-labelledby", "aria-describedby", "aria-controls", "for"]) { + for (const id of element.getAttribute(attribute)?.split(/\s+/).filter(Boolean) ?? []) { + expect(document.getElementById(id)).not.toBeNull(); + } + } + } + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/design-system/CalendarExamples.tsx b/src/components/design-system/CalendarExamples.tsx new file mode 100644 index 0000000..6e89105 --- /dev/null +++ b/src/components/design-system/CalendarExamples.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { CalendarHeatmap } from "./CalendarHeatmap"; +import { HabitChart } from "./HabitChart"; +import { Button, Checkbox, Counter } from "./primitives"; +import { combinedProgress, demoCalendar, HABIT_COLORS, type CalendarDay } from "./calendar-model"; + +export function CalendarExamples() { + const [water, setWater] = useState(7); + const [read, setRead] = useState(true); + const waterDays = demoCalendar(water, 8); + const readingDays = demoCalendar(Number(read), 1); + const combinedDays: CalendarDay[] = waterDays.map((day, index) => { + const readingDay = readingDays[index]!; + const total = combinedProgress([day, readingDay].map((entry) => ({ + ...entry, + due: entry.state === "due", + }))); + return { + date: day.date, + value: total.completed, + target: total.due, + state: day.state === "future" ? "future" : total.due === 0 ? "not-due" : "due", + }; + }); + + return ( +
+
+

Working calendars

+ +
+

Demo date · September 4, 2026. Change today’s progress or select a date to inspect its value.

+
+ + + + + setRead(event.target.checked)} /> + +
+
+
+

Combined completion

+
+ +

Only fully completed, scheduled habits count. Partial water progress counts once it reaches 8 glasses; days with nothing scheduled have no score.

+
+
+ ); +} diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx index 32cb8f8..9ff9c62 100644 --- a/src/components/design-system/CalendarHeatmap.tsx +++ b/src/components/design-system/CalendarHeatmap.tsx @@ -1,7 +1,10 @@ -import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { CalendarLegend } from "./CalendarLegend"; +import { Field } from "./Field"; +import { Button } from "./primitives"; import { describeDay, + calendarTimeline, progressShade, MONTH_VIEWS, visibleCalendarDays, @@ -32,7 +35,9 @@ export function CalendarHeatmap({ historyLabel?: string; legend?: ReactNode; }) { - const [months, setMonths] = useState(12); + const [months, setMonths] = useState(12); + const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null); + const [rangeError, setRangeError] = useState(""); const latestDate = allDays.findLast((day) => day.state !== "future")?.date ?? allDays.at(-1)?.date; @@ -47,46 +52,55 @@ export function CalendarHeatmap({ if (onSelectDate) onSelectDate(date); else setLocalSelectedDate(date); } - const days = anchor ? visibleCalendarDays(allDays, months, anchor) : []; + const days = months === "custom" && customRange + ? allDays.filter((day) => day.date >= customRange.from && day.date <= customRange.to) + : anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : []; const selected = days.find((day) => day.date === selectedDate) ?? days.find((day) => day.date === anchor) ?? days[0]; - const offset = days[0] - ? new Date(`${days[0].date}T12:00:00Z`).getUTCDay() - : 0; - const weeks = Math.ceil((days.length + offset) / 7); + // Date keys and the shared timeline remain stable when the chart resizes. + const timeline = calendarTimeline(days); const buttons = useRef<(HTMLButtonElement | null)[]>([]); - const scrollArea = useRef(null); const inspectorId = useId(); + const instructionsId = useId(); + const rangeErrorId = useId(); + const pendingFocusDate = useRef(null); + const [focusRequest, setFocusRequest] = useState(0); const selectedIndex = selected ? days.indexOf(selected) : -1; - useEffect(() => { - const container = scrollArea.current; - const button = buttons.current[selectedIndex]; - if (!container || !button) return; - const cellBounds = button.getBoundingClientRect(); - const bounds = container.getBoundingClientRect(); - if (cellBounds.right > bounds.right - 5) - container.scrollLeft += cellBounds.right - bounds.right + 5; - else if (cellBounds.left < bounds.left + 5) - container.scrollLeft += cellBounds.left - bounds.left - 5; - }, [months, selectedIndex]); + // Controlled editors may reject a selection to preserve an unsaved draft. + // Only move focus after that date is actually selected, using the new window. + useLayoutEffect(() => { + if (pendingFocusDate.current !== null) + buttons.current[selectedIndex]?.focus(); + pendingFocusDate.current = null; + }, [focusRequest, selectedIndex, selected?.date]); + function selectAndFocus(date: string) { + pendingFocusDate.current = date; + // A rejected controlled selection still needs a render to restore click + // focus and consume the request, so a later update cannot reuse it. + setFocusRequest((request) => request + 1); + setSelectedDate(date); + } if (!selected) return

No calendar dates to display.

; return (
{new Date(`${days[0]!.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", + day: months === "custom" ? "numeric" : undefined, year: "numeric", timeZone: "UTC", })}{" "} –{" "} {new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", + day: months === "custom" ? "numeric" : undefined, year: "numeric", timeZone: "UTC", })} @@ -107,126 +121,160 @@ export function CalendarHeatmap({ {value}m ))} -
-
-
-
- -
- -
- {Array.from({ length: offset }, (_, index) => ( -
+
+ {months === "custom" && customRange && ( +
{ + event.preventDefault(); + const form = event.currentTarget; + const start = form.elements.namedItem("from") as HTMLInputElement; + const end = form.elements.namedItem("to") as HTMLInputElement; + const isAvailable = (input: HTMLInputElement) => + input.validity.valid && input.value !== "" && + input.value >= allDays[0]!.date && input.value <= allDays.at(-1)!.date; + if (!isAvailable(start) || !isAvailable(end)) { + setRangeError("Choose both dates within the available history."); + (!isAvailable(start) ? start : end).focus(); + return; + } + if (start.value > end.value) { + setRangeError("The end date must be on or after the start date."); + end.focus(); + return; + } + if (!allDays.some((day) => day.date >= start.value && day.date <= end.value)) { + setRangeError("There are no calendar dates in this range."); + return; + } + setCustomRange({ from: start.value, to: end.value }); + setRangeError(""); + }} + > + {(["from", "to"] as const).map((name) => ( + + {(id) => ( + setRangeError("")} /> - ))} -
+ )} + + ))} + + {rangeError && } + + )} +
+
+ + +
+ {timeline.entries.map(({ day, index, column, row }) => ( +
{legend ?? }
- {months} months{" "} + {months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "} /{" "} {historyLabel ?? (compact ? "Demo history" : "Illustrative history")} - - {compact - ? "Select a day · Arrow keys" - : "Select a day to inspect · Arrow keys to move"} + + Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
diff --git a/src/components/design-system/Card.tsx b/src/components/design-system/Card.tsx index 1128059..7cf8280 100644 --- a/src/components/design-system/Card.tsx +++ b/src/components/design-system/Card.tsx @@ -4,6 +4,7 @@ import { useId, type HTMLAttributes, type ReactNode } from "react"; export function Card({ eyebrow, heading, + headingLevel = 3, children, footer, variant = "soft", @@ -13,11 +14,13 @@ export function Card({ }: HTMLAttributes & { eyebrow?: string; heading: ReactNode; + headingLevel?: 2 | 3 | 4 | 5 | 6; footer?: ReactNode; variant?: "soft" | "outlined"; span?: "standard" | "wide" | "featured"; }) { const headingId = useId(); + const Heading = `h${headingLevel}` as const; return (
{eyebrow &&

{eyebrow}

} -

{heading}

+ {heading}
{children &&
{children}
} {footer &&
{footer}
} diff --git a/src/components/design-system/ContainerShowcase.tsx b/src/components/design-system/ContainerShowcase.tsx index d670343..e4fbd37 100644 --- a/src/components/design-system/ContainerShowcase.tsx +++ b/src/components/design-system/ContainerShowcase.tsx @@ -17,10 +17,10 @@ export function ContainerShowcase() { Card · soft / outlined
- +

A neutral fill gathers related content without adding another divider. Use it for summaries, guidance, and a small set of actions.

- +

A fine border defines a standalone group on white. Useful when a form or a focused task needs its own space.

@@ -31,26 +31,24 @@ export function ContainerShowcase() {
Small things, adding up.} - footer={

Interactive example · nothing is saved

} >

{Number(water === 8) + Number(read)} / 2

habits complete

-

Make room for a glass of water and a few pages. A little progress belongs here, too.

- +

{water === 8 ? "Complete for today" : `${8 - water} glasses to go`}

- + setRead(event.target.checked)} /> -

One page is a place to start.

- +

Come back to today. Your next small step is enough.

@@ -58,7 +56,7 @@ export function ContainerShowcase() {

Equal grids suit peer items. Bento gives one summary more room; smaller cards hold short tasks. Both collapse in reading order on small screens. Use one surface per group, with spacing inside and no shadows.

-
{'\n  \n  \n  \n  \n'}
+
{'\n  \n  \n  \n  \n'}
); } diff --git a/src/components/design-system/DesignSystemTabs.tsx b/src/components/design-system/DesignSystemTabs.tsx index 3300254..142e0e9 100644 --- a/src/components/design-system/DesignSystemTabs.tsx +++ b/src/components/design-system/DesignSystemTabs.tsx @@ -1,4 +1,4 @@ -import { useRef, type ReactNode } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { useLocation, useNavigate } from "react-router"; type SystemTab = { id: string; label: string; content: ReactNode }; @@ -7,7 +7,13 @@ export function DesignSystemTabs({ tabs }: { tabs: SystemTab[] }) { const { hash } = useLocation(); const navigate = useNavigate(); const buttons = useRef>([]); - const selected = tabs.findIndex((tab) => hash === `#${tab.id}`); + // Keep bookmarks for retired preview sections useful without mounting them. + const replacementHash = hash === "#playground" ? "#calendar-states" + : hash === "#editing" || hash === "#views" ? "#foundations" : hash; + useEffect(() => { + if (replacementHash !== hash) navigate({ hash: replacementHash }, { replace: true }); + }, [hash, replacementHash, navigate]); + const selected = tabs.findIndex((tab) => replacementHash === `#${tab.id}`); const active = selected < 0 ? 0 : selected; function select(index: number) { @@ -44,7 +50,7 @@ export function DesignSystemTabs({ tabs }: { tabs: SystemTab[] }) { ))} - {/* Keep panels mounted so editors and examples retain their local state. */} + {/* Keep panels mounted so examples retain their local state. */} {tabs.map((tab, index) => (
ReactNode; -}) { - const id = useId(); - return ( -
- - {children(id)} - {hint && {hint}} -
- ); -} - const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; export function ScheduleEditor({ @@ -212,6 +194,9 @@ export function EditingWorkbench({ onColorsChange?: (colors: HabitColors) => void; }) { const [tab, setTab] = useState("Habit settings"); + const taskFields = useRef>({}); + const taskEditor = useRef(null); + const taskFocus = useRef(null); const [habits, setHabits] = useState(() => structuredClone(EDITOR_HABITS)); const [selectedId, setSelectedId] = useState("water"); const selected = habits.find((habit) => habit.id === selectedId)!; @@ -254,6 +239,12 @@ export function EditingWorkbench({ JSON.stringify(taskChecks) !== JSON.stringify(savedChecks)); const dirty = tab === "Backfill progress" ? progressDirty : configDirty; const blocked = backfillError(selectedId, date, progress); + useLayoutEffect(() => { + if (taskFocus.current === "add") + taskEditor.current?.querySelector("[data-add-task]")?.focus(); + else if (taskFocus.current) taskFields.current[taskFocus.current]?.focus(); + taskFocus.current = null; + }); function loadProgress(id: string, nextDate: string) { const original = EDITOR_HABITS.find((habit) => habit.id === id)!.config; @@ -508,9 +499,10 @@ export function EditingWorkbench({ label="Completion method" hint="Method is fixed in this preview. Choose another habit to try its editor." > - {(id) => ( + {(id, describedBy) => ( )}
)} {draft.tasks.map((task, index) => ( -
+
+ Step {index + 1}
- - STEP {String(index + 1).padStart(2, "0")} - @@ -661,6 +654,7 @@ export function EditingWorkbench({ {(id) => ( { taskFields.current[task.id] = element; }} required maxLength={200} value={task.name} @@ -674,24 +668,28 @@ export function EditingWorkbench({ value={task.schedule} onChange={(schedule) => updateTask(task.id, { schedule })} /> -
+
))} @@ -723,9 +721,10 @@ export function EditingWorkbench({ label="Progress date" hint="Select a date here or in the calendar below." > - {(id) => ( + {(id, describedBy) => ( - {(id) => ( + {(id, describedBy) => ( ReactNode; +}) { + const generatedId = useId(); + const id = providedId ?? generatedId; + const hintId = hint != null ? `${id}-hint` : undefined; + + return ( +
+ + {children(id, hintId)} + {hintId && {hint}} +
+ ); +} diff --git a/src/components/design-system/HabitColorPicker.tsx b/src/components/design-system/HabitColorPicker.tsx index 5c19765..78a8cae 100644 --- a/src/components/design-system/HabitColorPicker.tsx +++ b/src/components/design-system/HabitColorPicker.tsx @@ -1,5 +1,6 @@ import { useId } from "react"; import { HABIT_COLORS, progressShade } from "./calendar-model"; +import { Field } from "./Field"; export type HabitColors = { -readonly [K in keyof typeof HABIT_COLORS]: string; @@ -51,8 +52,7 @@ export function HabitColorPicker({
Habit color

- A color for this habit and its calendar. Choose a suggested shade or - make it your own. + Choose a suggested shade or enter a custom color for this habit’s calendar.

{HABIT_PALETTES.map((palette) => ( @@ -83,19 +83,20 @@ export function HabitColorPicker({ ))}
-
- + + {(inputId) => ( onChange(event.currentTarget.value)} /> -
-
- + )} + + + {(inputId) => ( onChange(event.target.value)} /> -
+ )} +
{valid && (
{mode === "create" - ? "Your chosen color is saved with your habit and used in its progress calendar." + ? "Saved with your habit." : mode === "edit" - ? "Save changes to apply this color to your habit’s calendar. Cancel keeps your current color." - : "Previews update immediately, including the charts above. Save to keep this color in the demo; Cancel to revert."} + ? "Save to apply this color; Cancel restores the saved color." + : "Charts preview changes immediately. Save keeps the demo color; Cancel reverts it."}

{!valid && (
{ @@ -82,18 +39,13 @@ export function DesignSystem() {

MINABOT — INTERFACE LANGUAGE

Design system

-

Foundations, reusable components, and working examples for what comes next.

Interactive examples · nothing is saved - - A small vocabulary. -
- Used with intention. -
+

Typography

@@ -117,8 +69,10 @@ export function DesignSystem() { { name: "Component title", className: "type-title", spec: "32/40px" }, { name: "Lead text", className: "type-lead", spec: "20/30px" }, { name: "Body & actions", className: "type-body", spec: "16/24px" }, + { name: "Interface heading", className: "type-ui-heading", spec: "18/27px · medium weight" }, { name: "Supporting text", className: "type-small", spec: "14/21px" }, { name: "SECTION LABEL", className: "type-label", spec: "14/21px · medium weight" }, + { name: "EYEBROW", className: "type-eyebrow", spec: "12/18px · regular weight" }, { name: "− / +", className: "type-control", spec: "24/30px · counter symbols" }, ].map((type) =>
  • {type.name} @@ -132,23 +86,27 @@ export function DesignSystem() {
  • {[ - { name: "Ink", value: "#111111" }, - { name: "Secondary", value: "#666666" }, - { name: "Rule", value: "#DEDEDE" }, - { name: "Paper", value: "#FFFFFF" }, + { name: "Ink", value: "#111111", token: "ink" }, + { name: "Secondary", value: "#666666", token: "secondary" }, + { name: "Control border", value: "#767676", token: "control-border" }, + { name: "Rule", value: "#DEDEDE", token: "rule" }, + { name: "Surface", value: "#F5F5F5", token: "surface" }, + { name: "Empty calendar", value: "#EEEEEE", token: "empty" }, + { name: "Paper", value: "#FFFFFF", token: "paper" }, ].map((color) => (

    {color.name} - {color.value} + {color.value} · --ds-{color.token}

    ))}
    +

    Use secondary for supporting text, surface for grouping and neutral hover states, and control border for inputs. Rule is for decorative dividers. Calendar shades and habit colors retain their own progress and identity roles.

    Habit colors

    Identity, not decoration @@ -182,11 +140,7 @@ export function DesignSystem() { ) }, { id: "components", label: "Components", content: (
    - - Purpose-built primitives. -
    - Try them for yourself. -
    +

    Actions

    @@ -214,9 +168,8 @@ export function DesignSystem() { -
    -

    - {savedName - ? `“${savedName}” — preview accepted. No habit was created.` - : "Keep it short and personal. This is a form preview, not a new habit."} + + {(id, describedBy) => ( +

    + { + setHabitName(event.target.value); + setSavedName(""); + }} + aria-describedby={describedBy} + /> + +
    + )} + +

    + {savedName && `“${savedName}” — preview accepted. No habit was created.`}

    @@ -291,15 +250,12 @@ export function DesignSystem() { ) }, { id: "calendar-states", label: "Calendars", content: ( + <>
    - Progress is a spectrum. -
    - Completion is a clear rule. -
    + />

    One glass at a time

    @@ -340,258 +296,8 @@ export function DesignSystem() {

    - ) }, - { id: "playground", label: "Playground", content: ( -
    -
    -
    - IN PRACTICE -

    - A little, every day. -

    -
    - - Interactive demo · not saved - -
    -
    -
    - {(["Today", "Habit detail", "Combined chart"] as const).map( - (item) => ( - - ), - )} -
    - -
    -
    -
    -

    FRIDAY, SEPTEMBER 4, 2026

    -

    - {view === "Today" - ? "Your habits, at a glance." - : isIndividual - ? "Drink water" - : "Your rhythm, together."} -

    -
    -

    - {view === "Today" - ? `${total.completed} of ${total.due} complete` - : isIndividual - ? "Daily · 8 glasses" - : combined.due - ? `${combined.completed} of ${combined.due} habits complete` - : "No habits selected"} -

    -
    - {view === "Today" ? ( -
    - - - - - setRead(event.target.checked)} - /> - - ( - - setTasks((current) => - current.map((done, i) => - i === index ? event.target.checked : done, - ), - ) - } - /> - ), - )} - /> - - - -

    - Each habit has its own rhythm. Expand tasks to log your day; - only fully completed habits count in a combined chart. -

    -
    - ) : ( -
    - {isIndividual ? ( -
    - - {water} - / 8 - glasses - - -
    - ) : ( -
    - {[ - "Drink water", - "Read a little", - "Evening reset", - "Move a little", - ].map((label, index) => ( - - setIncluded((current) => - current.map((checked, i) => - i === index ? event.target.checked : checked, - ), - ) - } - /> - ))} -
    - )} - -

    - {isIndividual - ? "Partial progress is visible here. Only 8 of 8 glasses counts as complete in a combined chart." - : "Equal weight. Only fully completed, due habits count. Nothing scheduled means no score, not a missed day."} -

    -
    - )} -
    - ) }, - { id: "editing", label: "Editing", content: ( - - ) }, - { id: "views", label: "References", content: ( -
    -
    -
    - VISUAL DIRECTION -

    From idea to interface.

    -
    -

    - Two imagegen studies, grounded in the PRD. -

    -
    -
    -
    - - Generated Today view: four colored habit charts in a two-column grid with an expanded task accordion, serif headings, and no cards - -
    - 01 — Today - - Daily progress - -
    -
    -
    - - Generated Drink water detail view: large seven-of-eight count and monochromatic calendar - -
    - 02 — Habit detail - - A closer look - -
    -
    -
    -

    - Visual references, not authoritative calendar data. The interactive - components above implement exact counts and distinct date states. -

    -
    + + ) }, ]} />