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.
This commit is contained in:
syntaxbullet
2026-09-04 13:44:31 +02:00
parent bfa54851c2
commit dde5e77317
19 changed files with 1077 additions and 681 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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<HTMLButtonElement>("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<HTMLElement>("#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<HTMLButtonElement>('[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<HTMLInputElement>('#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<HTMLButtonElement>('#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<HTMLButtonElement>('[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();
});
});

View File

@@ -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 (
<section className="ds-section" aria-labelledby="calendar-examples-title">
<div className="ds-spec-heading">
<h3 id="calendar-examples-title">Working calendars</h3>
<Button variant="text" onClick={() => { setWater(7); setRead(true); }}>Reset examples </Button>
</div>
<p className="ds-muted type-small">Demo date · September 4, 2026. Change todays progress or select a date to inspect its value.</p>
<div className="ds-habit-chart-grid">
<HabitChart
name="Drink water"
method="Count target"
value={water}
target={8}
unit="glasses"
color={HABIT_COLORS.water}
>
<Counter label="glasses of water" value={water} target={8} onChange={setWater} />
</HabitChart>
<HabitChart
name="Read a little"
method="Manual checkbox"
value={Number(read)}
target={1}
unit="reading session"
color={HABIT_COLORS.reading}
>
<Checkbox label="Mark reading done" checked={read} onChange={(event) => setRead(event.target.checked)} />
</HabitChart>
</div>
<div className="ds-form-section">
<div className="ds-spec-heading">
<h3>Combined completion</h3>
</div>
<CalendarHeatmap days={combinedDays} unit="habits complete" label="Combined habit calendar" />
<p className="ds-footnote">Only fully completed, scheduled habits count. Partial water progress counts once it reaches 8 glasses; days with nothing scheduled have no score.</p>
</div>
</section>
);
}

View File

@@ -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<MonthView>(12);
const [months, setMonths] = useState<MonthView | "custom">(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<HTMLDivElement | null>(null);
const inspectorId = useId();
const instructionsId = useId();
const rangeErrorId = useId();
const pendingFocusDate = useRef<string | null>(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 <p>No calendar dates to display.</p>;
return (
<div
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
data-months={months}
style={{ "--calendar-weeks": timeline.weeks } as CSSProperties}
>
<div className="ds-calendar-range-toolbar">
<span className="ds-calendar-range-label" aria-live="polite">
{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
</button>
))}
</div>
</div>
<div
ref={scrollArea}
className="ds-calendar-scroll"
role="group"
aria-label={label}
>
<div
className="ds-calendar-inner"
style={{
minWidth:
months === 12
? 0
: Math.max(compact ? 280 : 600, weeks * 14 + 38),
}}
>
<div
className="ds-calendar-months"
aria-hidden="true"
style={{
gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))`,
<button
type="button"
aria-pressed={months === "custom"}
onClick={() => {
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
setRangeError("");
setMonths("custom");
}}
>
{days.map(
(day, index) =>
(index === 0 || day.date.endsWith("-01")) && (
<span
key={day.date}
style={{ gridColumn: Math.floor((index + offset) / 7) + 1 }}
>
{new Date(`${day.date}T12:00:00Z`).toLocaleDateString(
"en",
{ month: "short", timeZone: "UTC" },
)}
</span>
),
)}
</div>
<div className="ds-calendar-body">
<div className="ds-calendar-weekdays" aria-hidden="true">
<span>Mon</span>
<span>Wed</span>
<span>Fri</span>
</div>
<div
className="ds-calendar-grid"
style={{
gridTemplateColumns: `repeat(${weeks}, 1fr)`,
}}
>
{Array.from({ length: offset }, (_, index) => (
<span key={`padding-${index}`} aria-hidden="true" />
))}
{days.map((day, index) => (
<button
key={day.date}
ref={(element) => {
buttons.current[index] = element;
}}
type="button"
className={`ds-day ds-day--${day.state}`}
style={{
backgroundColor:
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
aria-pressed={day.date === selected.date}
aria-describedby={inspectorId}
tabIndex={day.date === selected.date ? 0 : -1}
onClick={() => setSelectedDate(day.date)}
onKeyDown={(event) => {
const offset = {
ArrowDown: 1,
ArrowUp: -1,
ArrowRight: 7,
ArrowLeft: -7,
}[event.key];
const next =
event.key === "Home"
? 0
: event.key === "End"
? days.length - 1
: offset !== undefined
? Math.max(
0,
Math.min(days.length - 1, index + offset),
)
: undefined;
if (next !== undefined) {
event.preventDefault();
setSelectedDate(days[next]!.date);
buttons.current[next]?.focus();
}
}}
Custom
</button>
</div>
</div>
{months === "custom" && customRange && (
<form
className="ds-calendar-custom-range"
aria-label={`${label} custom timeframe`}
noValidate
onSubmit={(event) => {
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) => (
<Field key={name} label={name === "from" ? "Start date" : "End date"}>
{(id) => (
<input
id={id}
name={name}
type="date"
required
min={allDays[0]!.date}
max={allDays.at(-1)!.date}
defaultValue={customRange[name]}
aria-invalid={rangeError ? true : undefined}
aria-describedby={rangeError ? rangeErrorId : undefined}
onChange={() => setRangeError("")}
/>
))}
</div>
)}
</Field>
))}
<Button type="submit" variant="secondary">Apply range</Button>
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
</form>
)}
<div
className="ds-calendar-viewport"
role="group"
aria-label={label}
aria-describedby={instructionsId}
>
<div className="ds-calendar-timeline">
<div className="ds-calendar-months" aria-hidden="true">
{timeline.months.map(({ month, column }, index) => (
<div className="ds-calendar-month-label" key={month} style={{
gridColumn: `${column} / ${timeline.months[index + 1]?.column ?? timeline.weeks + 1}`,
}}>
{new Date(`${month}-01T12:00:00Z`).toLocaleDateString("en", {
month: "short", timeZone: "UTC",
})}
</div>
))}
</div>
<div className="ds-calendar-weekdays" aria-hidden="true">
<span>Mon</span>
<span>Wed</span>
<span>Fri</span>
</div>
<div className="ds-calendar-grid">
{timeline.entries.map(({ day, index, column, row }) => (
<button
key={day.date}
ref={(element) => {
buttons.current[index] = element;
}}
type="button"
className={`ds-day ds-day--${day.state}`}
style={{
gridColumn: column,
gridRow: row,
backgroundColor:
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
aria-pressed={day.date === selected.date}
aria-describedby={instructionsId}
tabIndex={day.date === selected.date ? 0 : -1}
onClick={() => selectAndFocus(day.date)}
onKeyDown={(event) => {
const offset = {
ArrowDown: 1,
ArrowUp: -1,
ArrowRight: 7,
ArrowLeft: -7,
}[event.key];
const next =
event.key === "Home"
? 0
: event.key === "End"
? days.length - 1
: offset !== undefined
? Math.max(
0,
Math.min(days.length - 1, index + offset),
)
: undefined;
if (next !== undefined) {
event.preventDefault();
selectAndFocus(days[next]!.date);
}
}}
/>
))}
</div>
</div>
</div>
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
<div className="ds-calendar-caption">
<span>
{months} months{" "}
{months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
<span className="ds-muted">
/{" "}
{historyLabel ??
(compact ? "Demo history" : "Illustrative history")}
</span>
</span>
<span className="ds-muted">
{compact
? "Select a day · Arrow keys"
: "Select a day to inspect · Arrow keys to move"}
<span id={instructionsId} className="ds-muted">
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</span>
</div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">

View File

@@ -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<HTMLElement> & {
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 (
<article
aria-labelledby={headingId}
@@ -26,7 +29,7 @@ export function Card({
>
<header className="ds-card-header">
{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}
<h3 id={headingId}>{heading}</h3>
<Heading id={headingId} className="ds-card-title">{heading}</Heading>
</header>
{children && <div className="ds-card-body">{children}</div>}
{footer && <footer className="ds-card-footer">{footer}</footer>}

View File

@@ -17,10 +17,10 @@ export function ContainerShowcase() {
<span className="ds-code">Card · soft / outlined</span>
</div>
<CardGrid>
<Card eyebrow="SOFT SURFACE" heading="A gentle grouping.">
<Card headingLevel={4} eyebrow="SOFT SURFACE" heading="A gentle grouping.">
<p>A neutral fill gathers related content without adding another divider. Use it for summaries, guidance, and a small set of actions.</p>
</Card>
<Card variant="outlined" eyebrow="OUTLINED SURFACE" heading="A clear boundary.">
<Card headingLevel={4} variant="outlined" eyebrow="OUTLINED SURFACE" heading="A clear boundary.">
<p>A fine border defines a standalone group on white. Useful when a form or a focused task needs its own space.</p>
</Card>
</CardGrid>
@@ -31,26 +31,24 @@ export function ContainerShowcase() {
</div>
<CardGrid layout="bento" aria-label="Interactive bento layout example">
<Card
headingLevel={4}
span="featured"
eyebrow="TODAY / YOUR OWN PACE"
heading={<>Small things, <em>adding up.</em></>}
footer={<p className="ds-muted type-small">Interactive example · nothing is saved</p>}
>
<div className="ds-card-summary" role="status">
<p className="type-display">{Number(water === 8) + Number(read)}<span className="type-title ds-muted"> / 2</span></p>
<p>habits complete</p>
</div>
<p>Make room for a glass of water and a few pages. A little progress belongs here, too.</p>
</Card>
<Card eyebrow="DAILY / 8 GLASSES" heading="Drink water" variant="outlined">
<Card headingLevel={4} eyebrow="DAILY / 8 GLASSES" heading="Drink water" variant="outlined">
<Counter label="bento glasses of water" value={water} target={8} onChange={setWater} />
<p className="type-small" role="status">{water === 8 ? "Complete for today" : `${8 - water} glasses to go`}</p>
</Card>
<Card eyebrow="A FEW PAGES" heading="Read a little" variant="outlined">
<Card headingLevel={4} eyebrow="A FEW PAGES" heading="Read a little" variant="outlined">
<Checkbox label="Reading done" checked={read} onChange={(event) => setRead(event.target.checked)} />
<p className="type-small">One page is a place to start.</p>
</Card>
<Card span="wide" eyebrow="A GENTLE REMINDER" heading="Theres no catching up.">
<Card headingLevel={4} span="wide" eyebrow="A GENTLE REMINDER" heading="Theres no catching up.">
<p>Come back to today. Your next small step is enough.</p>
</Card>
</CardGrid>
@@ -58,7 +56,7 @@ export function ContainerShowcase() {
<p className="ds-footnote">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.</p>
<Button variant="text" onClick={() => { setWater(3); setRead(false); }}>Reset card examples </Button>
</div>
<pre className="ds-code ds-container-code"><code>{'<CardGrid layout="bento">\n <Card heading="Today" span="featured">…</Card>\n <Card heading="Water" variant="outlined">…</Card>\n <Card heading="Reading" variant="outlined">…</Card>\n <Card heading="A reminder" span="wide">…</Card>\n</CardGrid>'}</code></pre>
<pre className="ds-code ds-container-code"><code>{'<CardGrid layout="bento">\n <Card heading="Today" headingLevel={4} span="featured">…</Card>\n <Card heading="Water" headingLevel={4} variant="outlined">…</Card>\n <Card heading="Reading" headingLevel={4} variant="outlined">…</Card>\n <Card heading="A reminder" headingLevel={4} span="wide">…</Card>\n</CardGrid>'}</code></pre>
</section>
);
}

View File

@@ -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<Array<HTMLButtonElement | null>>([]);
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[] }) {
</button>
))}
</div>
{/* Keep panels mounted so editors and examples retain their local state. */}
{/* Keep panels mounted so examples retain their local state. */}
{tabs.map((tab, index) => (
<div
key={tab.id}

View File

@@ -1,6 +1,7 @@
import { useEffect, useId, useState, type ReactNode } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { HabitConfig, Schedule } from "../../habits/contracts";
import { Button, Checkbox } from "./primitives";
import { Field } from "./Field";
import { CalendarHeatmap } from "./CalendarHeatmap";
import {
HabitColorPicker,
@@ -18,25 +19,6 @@ import {
validateTasks,
} from "./editing-model";
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: (id: string) => ReactNode;
}) {
const id = useId();
return (
<div className="ds-field">
<label htmlFor={id}>{label}</label>
{children(id)}
{hint && <small>{hint}</small>}
</div>
);
}
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<EditorTab>("Habit settings");
const taskFields = useRef<Record<string, HTMLInputElement | null>>({});
const taskEditor = useRef<HTMLFieldSetElement | null>(null);
const taskFocus = useRef<string | "add" | null>(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<HTMLButtonElement>("[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) => (
<input
id={id}
aria-describedby={describedBy}
readOnly
value={
draft.method === "count"
@@ -626,6 +618,7 @@ export function EditingWorkbench({
</p>
)}
<fieldset
ref={taskEditor}
className="ds-task-editor-fieldset"
disabled={draft.archived}
aria-label="Task definitions"
@@ -637,22 +630,22 @@ export function EditingWorkbench({
</p>
)}
{draft.tasks.map((task, index) => (
<div className="ds-edit-task" key={task.id}>
<fieldset className="ds-edit-task" key={task.id}>
<legend className="ds-code">Step {index + 1}</legend>
<div className="ds-spec-heading">
<span className="ds-code">
STEP {String(index + 1).padStart(2, "0")}
</span>
<Button
variant="text"
aria-label={`Remove ${task.name || "unnamed task"}`}
onClick={() =>
onClick={() => {
taskFocus.current = draft.tasks[index + 1]?.id ?? draft.tasks[index - 1]?.id ?? "add";
setDraft({
...draft,
tasks: draft.tasks.filter(
(item) => item.id !== task.id,
),
})
}
});
setNotice(`${task.name || "Unnamed task"} removed from the draft. Save tasks to apply.`);
}}
>
Remove
</Button>
@@ -661,6 +654,7 @@ export function EditingWorkbench({
{(id) => (
<input
id={id}
ref={(element) => { 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 })}
/>
</div>
</fieldset>
))}
<Button
variant="secondary"
data-add-task
disabled={draft.tasks.length >= 100}
onClick={() =>
onClick={() => {
const id = crypto.randomUUID();
taskFocus.current = id;
setDraft({
...draft,
tasks: [
...draft.tasks,
{
id: crypto.randomUUID(),
id,
name: "",
schedule: { type: "daily" },
},
],
})
}
});
setNotice("Task added to the draft. Enter its name and schedule.");
}}
>
Add task +
</Button>
@@ -723,9 +721,10 @@ export function EditingWorkbench({
label="Progress date"
hint="Select a date here or in the calendar below."
>
{(id) => (
{(id, describedBy) => (
<input
id={id}
aria-describedby={describedBy}
type="date"
required
min={EDITOR_START}
@@ -790,9 +789,10 @@ export function EditingWorkbench({
label="Actual count"
hint="Enter the total, not an increment. Zero clears progress; counts may exceed the target."
>
{(id) => (
{(id, describedBy) => (
<input
id={id}
aria-describedby={describedBy}
type="number"
required
min={0}

View File

@@ -0,0 +1,28 @@
import { useId, type ReactNode } from "react";
/** A shared label and hint wrapper for native form controls. */
export function Field({
label,
hint,
id: providedId,
className = "",
children,
}: {
label: ReactNode;
hint?: ReactNode;
id?: string;
className?: string;
children: (id: string, describedBy?: string) => ReactNode;
}) {
const generatedId = useId();
const id = providedId ?? generatedId;
const hintId = hint != null ? `${id}-hint` : undefined;
return (
<div className={`ds-field ${className}`}>
<label htmlFor={id}>{label}</label>
{children(id, hintId)}
{hintId && <small id={hintId}>{hint}</small>}
</div>
);
}

View File

@@ -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({
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className="ds-footnote">
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 habits calendar.
</p>
<div className="ds-color-palettes">
{HABIT_PALETTES.map((palette) => (
@@ -83,19 +83,20 @@ export function HabitColorPicker({
))}
</div>
<div className="ds-custom-color">
<div className="ds-field">
<label htmlFor={`${id}-native`}>Custom color</label>
<Field label="Custom color" id={`${id}-native`}>
{(inputId) => (
<input
id={`${id}-native`}
id={inputId}
type="color"
value={valid ? value : "#426582"}
onInput={(event) => onChange(event.currentTarget.value)}
/>
</div>
<div className="ds-field">
<label htmlFor={`${id}-hex`}>Hex color</label>
)}
</Field>
<Field label="Hex color" id={`${id}-hex`}>
{(inputId) => (
<input
id={`${id}-hex`}
id={inputId}
type="text"
spellCheck={false}
autoComplete="off"
@@ -105,10 +106,11 @@ export function HabitColorPicker({
placeholder="#426582"
value={value}
aria-invalid={!valid}
aria-describedby={!valid ? `${id}-error` : undefined}
aria-describedby={`${id}-hint${!valid ? ` ${id}-error` : ""}`}
onChange={(event) => onChange(event.target.value)}
/>
</div>
)}
</Field>
</div>
{valid && (
<div
@@ -138,10 +140,10 @@ export function HabitColorPicker({
)}
<p className="ds-footnote">
{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 habits 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."}
</p>
{!valid && (
<p id={`${id}-error`} role="alert" className="ds-form-feedback">

View File

@@ -0,0 +1,215 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { EditingWorkbench } from "./EditingWorkbench";
import { HabitColorPicker } from "./HabitColorPicker";
import { demoCalendar } from "./calendar-model";
const dom = new Window({ url: "http://localhost:3000/design-system" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
beforeAll(async () => {
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
function button(text: string) {
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
}
function description(element: Element) {
return (element.getAttribute("aria-describedby") ?? "").split(" ")
.map((id) => document.getElementById(id)?.textContent ?? "").join(" ");
}
test("calendar associates exact keyboard guidance and preserves focus when selection is vetoed", async () => {
let attempted = "";
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" selectedDate="2026-09-03" onSelectDate={(date) => { attempted = date; }} />));
const selected = container.querySelector<HTMLButtonElement>('.ds-day[aria-pressed="true"]')!;
selected.focus();
expect(description(selected)).toContain("Left/right: one week");
expect(description(selected)).not.toContain("of 8 glasses");
await act(async () => selected.dispatchEvent(new dom.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) as unknown as KeyboardEvent));
expect(attempted).toBe("2026-09-02");
expect(document.activeElement).toBe(selected);
expect(selected.tabIndex).toBe(0);
});
test("calendar focuses the accepted date after a controlled month window changes", async () => {
function Calendar() {
const [date, setDate] = useState("2026-09-01");
return <CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" selectedDate={date} onSelectDate={setDate} />;
}
await act(async () => root.render(<Calendar />));
await act(async () => button("3m").click());
const selected = container.querySelector<HTMLButtonElement>('.ds-day[aria-pressed="true"]')!;
selected.focus();
await act(async () => selected.dispatchEvent(new dom.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) as unknown as KeyboardEvent));
expect(document.activeElement?.getAttribute("aria-label")).toStartWith("2026-08-31:");
expect(document.activeElement?.getAttribute("aria-pressed")).toBe("true");
expect((document.activeElement as HTMLElement).tabIndex).toBe(0);
});
test("rejected calendar clicks and repeated arrows do not leave stale focus requests", async () => {
let setExternalDate!: (date: string) => void;
function Calendar() {
const [date, setDate] = useState("2026-09-03");
setExternalDate = setDate;
return <><input aria-label="Other control" /><CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" selectedDate={date} onSelectDate={() => {}} /></>;
}
await act(async () => root.render(<Calendar />));
const selected = container.querySelector<HTMLButtonElement>('.ds-day[aria-pressed="true"]')!;
const rejected = container.querySelector<HTMLButtonElement>('.ds-day[aria-label^="2026-09-02:"]')!;
rejected.focus(); // Browser pointer activation focuses the clicked button first.
await act(async () => rejected.click());
expect(document.activeElement).toBe(selected);
for (let attempt = 0; attempt < 2; attempt++) {
await act(async () => selected.dispatchEvent(new dom.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) as unknown as KeyboardEvent));
expect(document.activeElement).toBe(selected);
}
const other = container.querySelector<HTMLInputElement>('input[aria-label="Other control"]')!;
other.focus();
await act(async () => setExternalDate("2026-09-02"));
expect(document.activeElement).toBe(other);
expect(rejected.getAttribute("aria-pressed")).toBe("true");
});
test("editor controls expose their explanatory hints", async () => {
await act(async () => root.render(<EditingWorkbench />));
expect(description(container.querySelector("input[readonly]")!)).toContain("Method is fixed");
await act(async () => button("Backfill progress").click());
expect(description(container.querySelector('input[type="date"]')!)).toContain("Select a date");
expect(description(container.querySelector('input[type="number"]')!)).toContain("Enter the total, not an increment");
});
test("adding and removing tasks keeps focus in the task editing workflow", async () => {
await act(async () => root.render(<EditingWorkbench />));
await act(async () => button("Tasks").click());
const originalCount = container.querySelectorAll(".ds-edit-task").length;
await act(async () => button("Add task +").click());
const added = container.querySelector<HTMLFieldSetElement>(".ds-edit-task:last-of-type")!;
expect(container.querySelectorAll(".ds-edit-task")).toHaveLength(originalCount + 1);
expect(added.querySelector("legend")?.textContent).toBe(`Step ${originalCount + 1}`);
expect(document.activeElement).toBe(added.querySelector("input"));
await act(async () => added.querySelector<HTMLButtonElement>("button")!.click());
expect(document.activeElement).toBe(container.querySelector(".ds-edit-task:last-of-type input"));
while (container.querySelector(".ds-edit-task")) {
await act(async () => container.querySelector<HTMLButtonElement>(".ds-edit-task button")!.click());
}
expect(document.activeElement).toBe(button("Add task +"));
expect(container.querySelector('[role="status"].ds-save-notice')?.textContent).toContain("removed from the draft");
});
test("invalid custom colors associate both guidance and corrective feedback", async () => {
await act(async () => root.render(<HabitColorPicker value="#bad" onChange={() => {}} />));
const input = container.querySelector('input[type="text"]')!;
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(description(input)).toContain("Choose a suggested shade");
expect(description(input)).toContain("Use a six-digit hex color");
});
test("unified calendar retains every date and navigates across months in every range", async () => {
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
for (const months of [3, 4, 6, 12]) {
await act(async () => button(`${months}m`).click());
expect(container.querySelectorAll(".ds-calendar-grid")).toHaveLength(1);
expect(container.querySelectorAll(".ds-calendar-weekdays")).toHaveLength(1);
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(months);
const dates = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
expect(dates).toHaveLength({ 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
expect(new Set(dates.map(day => day.getAttribute("aria-label"))).size).toBe(dates.length);
expect(dates.filter(day => day.tabIndex === 0)).toHaveLength(1);
const firstDate = new Date(`${dates[0]!.getAttribute("aria-label")!.slice(0, 10)}T00:00:00Z`);
for (const day of dates) {
const date = new Date(`${day.getAttribute("aria-label")!.slice(0, 10)}T12:00:00Z`);
expect(Number(day.style.gridRow)).toBe(date.getUTCDay() + 1);
const elapsed = Math.floor((date.getTime() - firstDate.getTime()) / 86_400_000);
expect(Number(day.style.gridColumn)).toBe(Math.floor((elapsed + firstDate.getUTCDay()) / 7) + 1);
}
}
const monthEnd = container.querySelector<HTMLButtonElement>('[aria-label^="2026-08-31:"]')!;
await act(async () => monthEnd.click());
await act(async () => monthEnd.dispatchEvent(new dom.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }) as unknown as KeyboardEvent));
expect(document.activeElement?.getAttribute("aria-label")).toStartWith("2026-09-01:");
for (const [key, date] of [["ArrowLeft", "2026-08-25"], ["End", "2026-09-30"], ["Home", "2025-10-01"]]) {
await act(async () => document.activeElement!.dispatchEvent(new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent));
expect(document.activeElement?.getAttribute("aria-label")).toStartWith(`${date}:`);
expect(document.activeElement?.getAttribute("aria-pressed")).toBe("true");
}
});
test("custom timeframe applies inclusive dates, supports single days and survives preset switches", async () => {
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
await act(async () => button("Custom").click());
const setDates = (from: string, to: string) => {
container.querySelector<HTMLInputElement>('input[name="from"]')!.value = from;
container.querySelector<HTMLInputElement>('input[name="to"]')!.value = to;
};
setDates("2026-08-31", "2026-09-01");
expect(container.querySelectorAll(".ds-day")).toHaveLength(365);
await act(async () => button("Apply range").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(2);
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Aug 31, 2026");
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("2 days");
expect(container.querySelectorAll('.ds-day[tabindex="0"]')).toHaveLength(1);
await act(async () => button("3m").click());
expect(container.querySelector("form")).toBeNull();
expect(container.querySelectorAll(".ds-day")).toHaveLength(92);
await act(async () => button("Custom").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(2);
expect(container.querySelector<HTMLInputElement>('input[name="from"]')?.value).toBe("2026-08-31");
setDates("2026-09-04", "2026-09-04");
await act(async () => button("Apply range").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("1 day ");
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain("September 4, 2026");
});
test("custom timeframe rejects missing, reversed and unavailable dates without changing the chart", async () => {
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
await act(async () => button("Custom").click());
for (const [from, to, error] of [
["", "2026-09-04", "Choose both dates"],
["2026-09-04", "2026-08-01", "end date must be"],
["2025-01-01", "2026-09-04", "available history"],
]) {
container.querySelector<HTMLInputElement>('input[name="from"]')!.value = from!;
container.querySelector<HTMLInputElement>('input[name="to"]')!.value = to!;
await act(async () => button("Apply range").click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain(error!);
expect(container.querySelectorAll(".ds-day")).toHaveLength(365);
expect(description(container.querySelector("input")!)).toContain(error!);
}
});

View File

@@ -9,8 +9,24 @@ import {
visibleCalendarDays,
MONTH_VIEWS,
legendShades,
calendarTimeline,
} from "./calendar-model";
test("timeline keeps month boundaries in the same week and covers leap days and gaps", () => {
const days = ["2024-02-28", "2024-02-29", "2024-03-01", "2024-03-03", "2024-03-10"]
.map((date) => ({ date, value: 0, target: 1, state: "due" as const }));
const timeline = calendarTimeline(days);
expect(timeline.weeks).toBe(3);
expect(timeline.entries.map(({ column, row }) => [column, row])).toEqual([
[1, 4], [1, 5], [1, 6], [2, 1], [3, 1],
]);
expect(calendarTimeline([])).toEqual({ weeks: 0, entries: [], months: [] });
const year = calendarTimeline(demoCalendar(7, 8));
expect(year.weeks).toBe(53);
expect(year.months).toHaveLength(12);
expect(year.months[3]?.month).toBe("2026-01");
});
test("legends match habit colors and only display possible progress shades", () => {
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
for (const target of [1, 3, 4, 8, 20]) {

View File

@@ -45,6 +45,26 @@ export function visibleCalendarDays(
return days.filter((day) => day.date >= range.from && day.date <= range.to);
}
/** One uninterrupted Sunday-first timeline, including partial boundary weeks. */
export function calendarTimeline(days: CalendarDay[]) {
const first = days[0];
if (!first) return { weeks: 0, entries: [], months: [] };
const start = new Date(`${first.date}T00:00:00Z`);
const origin = start.getTime() - start.getUTCDay() * 86_400_000;
const entries = days.map((day, index) => {
const offset = Math.round((Date.parse(`${day.date}T00:00:00Z`) - origin) / 86_400_000);
return { day, index, column: Math.floor(offset / 7) + 1, row: offset % 7 + 1 };
});
const weeks = entries.at(-1)!.column;
const months = entries.filter(({ day }, index) =>
index === 0 || day.date.slice(0, 7) !== entries[index - 1]!.day.date.slice(0, 7),
).map(({ day, column }) => ({ month: day.date.slice(0, 7), column }))
// A short custom range can cross a month boundary inside a single week.
// The exact date-range caption names both; avoid overlapping axis labels.
.filter((month, index, labels) => index === 0 || month.column !== labels[index - 1]!.column);
return { weeks, entries, months };
}
export const HABIT_COLORS = {
water: "#426582",
reading: "#977344",

View File

@@ -0,0 +1,52 @@
import { expect, test } from "bun:test";
import { Window } from "happy-dom";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
import { Card } from "./Card";
import { ContainerShowcase } from "./ContainerShowcase";
import { Field } from "./Field";
function render(content: ReactNode) {
const window = new Window();
window.document.body.innerHTML = renderToStaticMarkup(content);
return window.document.body;
}
test("Field connects its label and hint to the rendered control", () => {
const body = render(
<Field id="habit" label="Habit name" hint="Keep it short.">
{(id, describedBy) => <input id={id} aria-describedby={describedBy} />}
</Field>,
);
expect(body.querySelector("label")?.htmlFor).toBe("habit");
expect(body.querySelector("input")?.getAttribute("aria-describedby")).toBe("habit-hint");
expect(body.querySelector("#habit-hint")?.textContent).toBe("Keep it short.");
});
test("Field generates unique control IDs and omits absent hint references", () => {
const body = render(<>
<Field label="First">{(id, describedBy) => <input id={id} aria-describedby={describedBy} />}</Field>
<Field label="Second">{(id, describedBy) => <input id={id} aria-describedby={describedBy} />}</Field>
</>);
const controls = [...body.querySelectorAll("input")];
expect(new Set(controls.map((control) => control.id)).size).toBe(2);
expect(controls.every((control) => !control.hasAttribute("aria-describedby"))).toBe(true);
expect(body.querySelectorAll("small")).toHaveLength(0);
});
test("Card preserves its title styling and accessible name at each heading level", () => {
for (const headingLevel of [2, 3, 4, 5, 6] as const) {
const body = render(<Card heading="A clear boundary" headingLevel={headingLevel}>Content</Card>);
const heading = body.querySelector(`h${headingLevel}.ds-card-title`);
expect(heading?.textContent).toBe("A clear boundary");
expect(body.querySelector("article")?.getAttribute("aria-labelledby")).toBe(heading?.id);
}
expect(render(<Card heading="Default" />).querySelector("h3")?.textContent).toBe("Default");
});
test("Layout uses subsection headings above the card titles", () => {
const body = render(<ContainerShowcase />);
expect(body.querySelectorAll("h2")).toHaveLength(1);
expect(body.querySelectorAll("h3")).toHaveLength(2);
expect(body.querySelectorAll("h4.ds-card-title")).toHaveLength(6);
});

View File

@@ -6,65 +6,22 @@ import {
Counter,
SectionHeading,
} from "../components/design-system/primitives";
import { CalendarHeatmap } from "../components/design-system/CalendarHeatmap";
import { HabitChart } from "../components/design-system/HabitChart";
import { EditingWorkbench } from "../components/design-system/EditingWorkbench";
import { CalendarExamples } from "../components/design-system/CalendarExamples";
import { ContainerShowcase } from "../components/design-system/ContainerShowcase";
import type { HabitColors } from "../components/design-system/HabitColorPicker";
import { Field } from "../components/design-system/Field";
import {
combinedProgress,
demoCalendar,
PROGRESS_SHADES,
HABIT_COLORS,
} from "../components/design-system/calendar-model";
import todayImage from "../assets/design/today-v2.png";
import habitImage from "../assets/design/habit-detail.png";
import { DesignSystemTabs } from "../components/design-system/DesignSystemTabs";
type View = "Today" | "Habit detail" | "Combined chart";
export function DesignSystem() {
const navigate = useNavigate();
const [habitColors, setHabitColors] = useState<HabitColors>({ ...HABIT_COLORS });
const [view, setView] = useState<View>("Today");
const [water, setWater] = useState(7);
const [read, setRead] = useState(true);
const [tasks, setTasks] = useState([true, true, false]);
const [movement, setMovement] = useState(10);
const [included, setIncluded] = useState([true, true, true, true]);
const [exampleChecked, setExampleChecked] = useState(false);
const [exampleCount, setExampleCount] = useState(3);
const [habitName, setHabitName] = useState("");
const [savedName, setSavedName] = useState("");
const taskCount = tasks.filter(Boolean).length;
const habits = [
{ value: water, target: 8, due: true },
{ value: Number(read), target: 1, due: true },
{ value: taskCount, target: 3, due: true },
{ value: movement, target: 20, due: true },
];
const total = combinedProgress(habits);
const combined = combinedProgress(
habits.filter((_, index) => included[index]),
);
const isIndividual = view === "Habit detail";
const chartDays = demoCalendar(
isIndividual ? water : combined.completed,
isIndividual ? 8 : combined.due,
).map((day) =>
!isIndividual && combined.due === 0 && day.state !== "future"
? { ...day, state: "not-due" as const }
: day,
);
function resetDemo() {
setWater(7);
setRead(true);
setTasks([true, true, false]);
setMovement(10);
setIncluded([true, true, true, true]);
}
return (
<div className="ds-root" id="top">
<a className="ds-skip-link" href="#ds-main" onClick={(event) => {
@@ -82,18 +39,13 @@ export function DesignSystem() {
<div>
<p className="ds-eyebrow">MINABOT INTERFACE LANGUAGE</p>
<h1 id="ds-title">Design system<span aria-hidden="true">.</span></h1>
<p>Foundations, reusable components, and working examples for what comes next.</p>
</div>
<span className="ds-library-note">Interactive examples · nothing is saved</span>
</header>
<DesignSystemTabs tabs={[
{ id: "foundations", label: "Foundations", content: (
<section className="ds-section ds-split-section" id="foundations">
<SectionHeading number="FOUNDATIONS" title="The essentials.">
A small vocabulary.
<br />
Used with intention.
</SectionHeading>
<SectionHeading number="FOUNDATIONS" title="The essentials." />
<div className="ds-section-content">
<div className="ds-spec-heading">
<h3>Typography</h3>
@@ -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) => <li key={type.className}>
<span className={type.className}>{type.name}</span>
@@ -132,23 +86,27 @@ export function DesignSystem() {
</div>
<div className="ds-palette">
{[
{ 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) => (
<div key={color.name}>
<div
className="ds-swatch"
style={{ background: color.value }}
style={{ background: `var(--ds-${color.token})` }}
/>
<p>
{color.name}
<span className="ds-code">{color.value}</span>
<span className="ds-code">{color.value} · --ds-{color.token}</span>
</p>
</div>
))}
</div>
<p className="ds-footnote">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.</p>
<div className="ds-spec-heading ds-spec-heading--spaced">
<h3>Habit colors</h3>
<span className="ds-code">Identity, not decoration</span>
@@ -182,11 +140,7 @@ export function DesignSystem() {
) },
{ id: "components", label: "Components", content: (
<section className="ds-section ds-split-section" id="components">
<SectionHeading number="COMPONENTS" title="Only what matters.">
Purpose-built primitives.
<br />
Try them for yourself.
</SectionHeading>
<SectionHeading number="COMPONENTS" title="Only what matters." />
<div className="ds-section-content">
<div className="ds-spec-heading">
<h3>Actions</h3>
@@ -214,9 +168,8 @@ export function DesignSystem() {
<Button
variant="text"
onClick={() => {
setView("Habit detail");
navigate({ hash: "#playground" });
document.getElementById("ds-tab-playground")?.focus();
navigate({ hash: "#calendar-states" });
document.getElementById("ds-tab-calendar-states")?.focus();
}}
>
View habit <span aria-hidden="true"></span>
@@ -254,34 +207,40 @@ export function DesignSystem() {
}}
>
<div className="ds-spec-heading">
<label htmlFor="habit-name">Give it a name</label>
<span className="ds-code">Text input</span>
<h3>Give it a name</h3>
<span className="ds-code">Field · text input</span>
</div>
<div className="ds-input-row">
<input
id="habit-name"
required
maxLength={80}
placeholder="e.g. Read ten pages"
value={habitName}
onChange={(event) => {
setHabitName(event.target.value);
setSavedName("");
}}
aria-describedby="name-hint"
/>
<Button
type="submit"
variant="secondary"
disabled={!habitName.trim()}
>
Try it <span aria-hidden="true"></span>
</Button>
</div>
<p id="name-hint" className="ds-footnote" role="status">
{savedName
? `${savedName}” — preview accepted. No habit was created.`
: "Keep it short and personal. This is a form preview, not a new habit."}
<Field
id="habit-name"
label="Habit name"
hint="This previews a name; it does not create a habit."
>
{(id, describedBy) => (
<div className="ds-input-row">
<input
id={id}
required
maxLength={80}
placeholder="e.g. Read ten pages"
value={habitName}
onChange={(event) => {
setHabitName(event.target.value);
setSavedName("");
}}
aria-describedby={describedBy}
/>
<Button
type="submit"
variant="secondary"
disabled={!habitName.trim()}
>
Try it <span aria-hidden="true"></span>
</Button>
</div>
)}
</Field>
<p className="ds-footnote" role="status">
{savedName && `${savedName}” — preview accepted. No habit was created.`}
</p>
</form>
</div>
@@ -291,15 +250,12 @@ export function DesignSystem() {
<ContainerShowcase />
) },
{ id: "calendar-states", label: "Calendars", content: (
<>
<section className="ds-section ds-split-section" id="calendar-states">
<SectionHeading
number="PROGRESS LANGUAGE"
title="Every shade counts."
>
Progress is a spectrum.
<br />
Completion is a clear rule.
</SectionHeading>
/>
<div className="ds-section-content">
<div className="ds-spec-heading">
<h3>One glass at a time</h3>
@@ -340,258 +296,8 @@ export function DesignSystem() {
</p>
</div>
</section>
) },
{ id: "playground", label: "Playground", content: (
<section
className="ds-section"
id="playground"
aria-labelledby="playground-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">IN PRACTICE</span>
<h2 id="playground-title">
A little, <em>every day.</em>
</h2>
</div>
<span className="ds-demo-note">
<span aria-hidden="true"></span> Interactive demo · not saved
</span>
</div>
<div className="ds-preview-toolbar">
<div
className="ds-view-switch"
role="group"
aria-label="Preview view"
>
{(["Today", "Habit detail", "Combined chart"] as const).map(
(item) => (
<button
type="button"
key={item}
aria-pressed={view === item}
onClick={() => setView(item)}
>
{item}
</button>
),
)}
</div>
<Button variant="text" onClick={resetDemo}>
Reset demo <span aria-hidden="true"></span>
</Button>
</div>
<div className="ds-preview-heading">
<div>
<p className="ds-eyebrow">FRIDAY, SEPTEMBER 4, 2026</p>
<h3>
{view === "Today"
? "Your habits, at a glance."
: isIndividual
? "Drink water"
: "Your rhythm, together."}
</h3>
</div>
<p aria-live="polite">
{view === "Today"
? `${total.completed} of ${total.due} complete`
: isIndividual
? "Daily · 8 glasses"
: combined.due
? `${combined.completed} of ${combined.due} habits complete`
: "No habits selected"}
</p>
</div>
{view === "Today" ? (
<div className="ds-habit-chart-grid">
<HabitChart
name="Drink water"
method="Count target"
value={water}
target={8}
unit="glasses"
color={habitColors.water}
>
<Counter
label="glasses of water"
value={water}
target={8}
onChange={setWater}
/>
</HabitChart>
<HabitChart
name="Read a little"
method="Manual checkbox"
value={Number(read)}
target={1}
unit="reading session"
color={habitColors.reading}
>
<Checkbox
label="Mark reading done"
className="ds-checkbox--icon"
checked={read}
onChange={(event) => setRead(event.target.checked)}
/>
</HabitChart>
<HabitChart
name="Evening reset"
method="Task-based"
value={taskCount}
target={3}
unit="tasks"
color={habitColors.tasks}
tasks={["Clear desk", "Plan tomorrow", "Stretch"].map(
(label, index) => (
<Checkbox
key={label}
label={label}
checked={tasks[index]}
onChange={(event) =>
setTasks((current) =>
current.map((done, i) =>
i === index ? event.target.checked : done,
),
)
}
/>
),
)}
/>
<HabitChart
name="Move a little"
method="Count target"
value={movement}
target={20}
unit="minutes"
color={habitColors.movement}
>
<Counter
label="minutes of movement"
value={movement}
target={20}
onChange={setMovement}
/>
</HabitChart>
<p className="ds-footnote ds-habit-chart-grid-note">
Each habit has its own rhythm. Expand tasks to log your day;
only fully completed habits count in a combined chart.
</p>
</div>
) : (
<div className="ds-chart-preview">
{isIndividual ? (
<div className="ds-detail-count">
<span className="ds-big-value">
{water}
<span className="ds-muted"> / 8</span>
<small>glasses</small>
</span>
<Counter
label="detail glasses"
value={water}
target={8}
onChange={setWater}
/>
</div>
) : (
<div
className="ds-chart-selection"
role="group"
aria-label="Included habits"
>
{[
"Drink water",
"Read a little",
"Evening reset",
"Move a little",
].map((label, index) => (
<Checkbox
key={label}
label={label}
checked={included[index]}
onChange={(event) =>
setIncluded((current) =>
current.map((checked, i) =>
i === index ? event.target.checked : checked,
),
)
}
/>
))}
</div>
)}
<CalendarHeatmap
key={view}
days={chartDays}
unit={isIndividual ? "glasses" : "habits complete"}
label={
isIndividual
? "Drink water progress calendar"
: "Combined habit calendar"
}
color={isIndividual ? habitColors.water : "#111111"}
/>
<p className="ds-footnote">
{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."}
</p>
</div>
)}
</section>
) },
{ id: "editing", label: "Editing", content: (
<EditingWorkbench onColorsChange={setHabitColors} />
) },
{ id: "views", label: "References", content: (
<section className="ds-section" id="views">
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">VISUAL DIRECTION</span>
<h2>From idea to interface.</h2>
</div>
<p className="ds-muted">
Two imagegen studies, grounded in the PRD.
</p>
</div>
<div className="ds-reference-grid">
<figure>
<a href={todayImage} target="_blank" rel="noreferrer">
<img
src={todayImage}
alt="Generated Today view: four colored habit charts in a two-column grid with an expanded task accordion, serif headings, and no cards"
loading="lazy"
/>
</a>
<figcaption>
<span>01 Today</span>
<span className="ds-muted">
Daily progress <span aria-hidden="true"></span>
</span>
</figcaption>
</figure>
<figure>
<a href={habitImage} target="_blank" rel="noreferrer">
<img
src={habitImage}
alt="Generated Drink water detail view: large seven-of-eight count and monochromatic calendar"
loading="lazy"
/>
</a>
<figcaption>
<span>02 Habit detail</span>
<span className="ds-muted">
A closer look <span aria-hidden="true"></span>
</span>
</figcaption>
</figure>
</div>
<p className="ds-footnote">
Visual references, not authoritative calendar data. The interactive
components above implement exact counts and distinct date states.
</p>
</section>
<CalendarExamples />
</>
) },
]} />
<footer className="ds-footer">

View File

@@ -17,18 +17,19 @@
--ds-ink: #111;
--ds-secondary: #666;
--ds-rule: #dedede;
--ds-control-border: #767676;
--ds-paper: #fff;
--ds-surface: #f5f5f5;
--ds-empty: #eee;
--ds-serif: "Instrument Serif", Georgia, serif;
--ds-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
position: relative;
margin: -8px;
min-height: 100vh;
color: var(--ds-ink);
background: var(--ds-paper);
@apply type-body;
font-family: -apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-family: var(--ds-sans);
-webkit-font-smoothing: antialiased;
}
.ds-root *,
@@ -83,7 +84,7 @@
}
.ds-eyebrow {
@apply type-eyebrow;
color: #737373;
color: var(--ds-secondary);
}
.ds-header {
max-width: 1280px;
@@ -137,10 +138,10 @@
.ds-library-note { max-width: 180px; text-align: right; }
.ds-tab-bar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
padding: 6px;
background: #f5f5f5;
background: var(--ds-surface);
}
.ds-tab-bar [role="tab"] {
@apply type-body;
@@ -149,7 +150,7 @@
border: 1px solid transparent;
padding: 10px 8px;
}
.ds-tab-bar [role="tab"]:hover { background: #ebebeb; color: var(--ds-ink); }
.ds-tab-bar [role="tab"]:hover { background: var(--ds-surface); color: var(--ds-ink); }
.ds-tab-bar [aria-selected="true"] {
background: var(--ds-paper);
color: var(--ds-ink);
@@ -326,7 +327,7 @@
color: var(--ds-paper);
}
.ds-button:hover:not(:disabled) {
background: #333;
background: var(--ds-secondary);
}
.ds-button-icon { width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
.ds-button--secondary {
@@ -337,21 +338,21 @@
background: transparent;
color: var(--ds-ink);
border-color: transparent;
padding-left: 0;
padding-right: 0;
}
.ds-button--secondary:hover:not(:disabled),
.ds-button--text:hover:not(:disabled) {
background: #f3f3f3;
background: var(--ds-surface);
}
.ds-counter {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.ds-counter .ds-button {
width: 36px;
min-height: 40px;
width: 44px;
min-width: 44px;
min-height: 44px;
@apply type-control;
font-weight: 300;
padding: 0;
@@ -365,7 +366,7 @@
display: inline-flex;
align-items: center;
gap: 11px;
min-height: 36px;
min-height: 44px;
cursor: pointer;
@apply type-small;
}
@@ -375,8 +376,8 @@
width: 17px;
height: 17px;
flex: 0 0 17px;
border: 1px solid #777;
background: #fff;
border: 1px solid var(--ds-control-border);
background: var(--ds-paper);
display: grid;
place-content: center;
cursor: pointer;
@@ -389,8 +390,8 @@
content: "";
width: 8px;
height: 4px;
border-left: 1px solid #fff;
border-bottom: 1px solid #fff;
border-left: 1px solid var(--ds-paper);
border-bottom: 1px solid var(--ds-paper);
transform: rotate(-45deg) translateY(-1px);
}
.ds-checkbox input:disabled {
@@ -399,6 +400,8 @@
}
.ds-checkbox--icon {
padding: 10px;
min-width: 44px;
justify-content: center;
}
.ds-checkbox--icon span {
position: absolute;
@@ -449,12 +452,12 @@
box-shadow: none;
overflow-wrap: anywhere;
}
.ds-card--soft { background: #f5f5f5; }
.ds-card--soft { background: var(--ds-surface); }
.ds-card--outlined {
background: var(--ds-paper);
border-color: var(--ds-rule);
}
.ds-card-header h3 { @apply type-title; }
.ds-card-title { @apply type-title; }
.ds-card-header .ds-eyebrow { margin-bottom: 12px; }
.ds-card-body { display: grid; gap: 20px; }
.ds-card-body > p { color: var(--ds-secondary); }
@@ -505,7 +508,7 @@
}
.ds-spec-heading h3,
.ds-spec-heading label {
@apply type-small;
@apply type-ui-heading;
}
.ds-spec-heading--spaced {
margin-top: 36px;
@@ -533,7 +536,7 @@
}
.ds-palette {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.ds-swatch {
@@ -550,9 +553,11 @@
display: block;
@apply type-small;
margin-top: 3px;
overflow-wrap: anywhere;
}
.ds-spacing {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 30px;
}
@@ -579,13 +584,14 @@
border-top: 1px solid var(--ds-rule);
padding: 24px 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.ds-component-row h3 {
@apply type-small;
margin-bottom: 3px;
@apply type-ui-heading;
margin-bottom: 8px;
}
.ds-example-form {
border-top: 1px solid var(--ds-rule);
@@ -593,24 +599,19 @@
}
.ds-input-row {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.ds-input-row input {
flex: 1;
flex: 1 1 12rem;
min-width: 0;
border: 0;
border-bottom: 1px solid #888;
padding: 10px 0;
background: var(--ds-paper);
color: var(--ds-ink);
outline-offset: 4px;
}
.ds-input-row input::placeholder {
color: var(--ds-secondary);
}
.ds-shade-scale {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 14px;
}
.ds-shade-scale > div {
@@ -627,6 +628,8 @@
}
.ds-scale-caption {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
justify-content: space-between;
@apply type-small;
color: var(--ds-secondary);
@@ -645,12 +648,12 @@
gap: 13px;
}
.ds-neutral-states h4 {
@apply type-small;
@apply type-ui-heading;
}
.ds-neutral-states p {
@apply type-small;
color: var(--ds-secondary);
margin-top: 3px;
margin-top: 8px;
}
.ds-state-square {
flex: 0 0 22px;
@@ -658,7 +661,7 @@
height: 22px;
}
.ds-state-square--future {
background: #eeeeee;
background: var(--ds-empty);
}
.ds-reference-grid {
display: grid;
@@ -704,6 +707,8 @@
}
.ds-detail-count {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--ds-rule);
@@ -723,10 +728,28 @@
gap: 26px;
padding: 4px 0 22px;
}
.ds-calendar-scroll {
overflow-x: auto;
padding: 5px;
margin: -5px;
.ds-calendar {
min-width: 0;
container-type: inline-size;
--calendar-gap: 4px;
}
/* Shared axes and consecutive weeks; landscape fits without a scroll container. */
.ds-calendar-viewport {
padding: 4px;
}
.ds-calendar-timeline {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 12px 10px;
width: 100%;
max-width: calc(46px + var(--calendar-weeks) * 24px + (var(--calendar-weeks) - 1) * var(--calendar-gap));
}
.ds-calendar-months {
display: grid;
grid-column: 2;
grid-template-columns: repeat(var(--calendar-weeks), minmax(0, 1fr));
gap: var(--calendar-gap);
min-width: 0;
}
.ds-calendar-range-toolbar {
display: flex;
@@ -742,11 +765,29 @@
}
.ds-month-views {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.ds-calendar-custom-range {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
margin-bottom: 22px;
}
.ds-calendar-custom-range .ds-field {
flex: 1 1 160px;
margin-bottom: 0;
}
.ds-calendar-custom-range input { width: 100%; }
.ds-calendar-range-error {
flex-basis: 100%;
@apply type-small;
color: var(--ds-ink);
}
.ds-month-views button {
min-height: 32px;
min-width: 32px;
min-height: 44px;
min-width: 44px;
padding: 5px 7px;
color: var(--ds-secondary);
background: transparent;
@@ -756,32 +797,25 @@
}
.ds-month-views button:hover {
color: var(--ds-ink);
background: #f3f3f3;
background: var(--ds-surface);
}
.ds-month-views button[aria-pressed="true"] {
color: var(--ds-ink);
border-bottom-color: var(--ds-ink);
}
.ds-calendar-inner {
min-width: 600px;
}
.ds-calendar-months {
display: grid;
grid-template-columns: repeat(26, 1fr);
margin-left: 38px;
.ds-calendar-month-label {
min-width: 0;
@apply type-small;
color: var(--ds-secondary);
height: 28px;
}
.ds-calendar-body {
display: flex;
gap: 10px;
}
.ds-calendar-weekdays {
display: grid;
grid-template-rows: repeat(7, 1fr);
width: 36px;
flex-shrink: 0;
grid-column: 1;
grid-template-rows: repeat(7, minmax(0, 1fr));
min-height: 0;
height: 100%;
contain: size;
gap: var(--calendar-gap);
@apply type-small;
color: var(--ds-secondary);
align-items: center;
@@ -797,18 +831,29 @@
}
.ds-calendar-grid {
display: grid;
grid-column: 2;
grid-template-columns: repeat(var(--calendar-weeks), minmax(0, 1fr));
grid-template-rows: repeat(7, 1fr);
grid-auto-flow: column;
gap: 6px;
flex: 1;
min-width: 0;
gap: var(--calendar-gap);
}
.ds-day {
padding: 0;
aspect-ratio: 1;
border: 1px solid transparent;
width: 100%;
min-width: 0;
min-height: 0;
}
/* Keep a full-size date target, but reserve filled squares for tracked days. */
/* Only narrow portrait screens scroll, preserving comfortable date targets. */
@media (max-width: 600px) and (orientation: portrait) {
.ds-calendar-viewport { overflow-x: auto; }
.ds-day { min-width: 24px; min-height: 24px; }
.ds-calendar-timeline {
min-width: calc(46px + var(--calendar-weeks) * 24px + (var(--calendar-weeks) - 1) * var(--calendar-gap));
}
}
/* Reserve filled squares for tracked days. */
.ds-day--not-due,
.ds-legend-swatch--not-due,
.ds-state-square--not-due {
@@ -825,7 +870,7 @@
width: 3px;
height: 3px;
border-radius: 50%;
background: #888;
background: var(--ds-secondary);
transform: translate(-50%, -50%);
pointer-events: none;
}
@@ -890,7 +935,7 @@
top: 0;
left: 24px;
padding: 10px;
background: white;
background: var(--ds-paper);
transform: translateY(-110%);
z-index: 2;
}
@@ -957,7 +1002,7 @@
gap: 30px;
}
.ds-section-heading p br {
display: none;
display: initial;
}
.ds-view-switch {
gap: 18px;
@@ -980,7 +1025,7 @@
gap: 2px;
}
.ds-counter .ds-button {
width: 32px;
width: 44px;
}
.ds-task-count {
min-width: 106px;
@@ -992,6 +1037,7 @@
gap: 3px 20px;
}
.ds-palette {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 13px;
}
.ds-swatch {
@@ -1049,14 +1095,15 @@
padding-top: 20px;
}
.ds-habit-chart:nth-child(n + 3),
.ds-habit-chart:has(.ds-calendar[data-months="12"]) + .ds-habit-chart {
.ds-habit-chart:has(.ds-calendar:is([data-months="12"], [data-months="custom"])) + .ds-habit-chart {
border-top: 1px solid var(--ds-rule);
}
.ds-habit-chart:has(.ds-calendar[data-months="12"]) {
.ds-habit-chart:has(.ds-calendar:is([data-months="12"], [data-months="custom"])) {
grid-column: 1 / -1;
}
.ds-habit-chart-heading {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 10px;
@@ -1086,7 +1133,7 @@
gap: 4px;
}
.ds-habit-chart-heading .ds-counter .ds-button {
width: 28px;
width: 44px;
}
.ds-habit-chart-progress {
display: flex;
@@ -1099,12 +1146,6 @@
grid-column: 1 / -1;
margin-top: -12px !important;
}
.ds-calendar--compact .ds-calendar-inner {
min-width: 380px;
}
.ds-calendar--compact .ds-calendar-grid {
gap: 5px;
}
.ds-calendar--compact .ds-date-inspector {
flex-direction: column;
gap: 5px;
@@ -1168,6 +1209,10 @@
height: 10px;
}
@media (max-width: 760px) {
.ds-split-section {
grid-template-columns: minmax(0, 1fr);
gap: 30px;
}
.ds-habit-chart + .ds-habit-chart {
border-top: 1px solid var(--ds-rule);
}
@@ -1175,43 +1220,30 @@
grid-template-columns: minmax(0, 1fr);
gap: 32px;
}
.ds-calendar--compact .ds-calendar-inner {
max-width: 520px;
}
.ds-habit-chart-heading .ds-counter {
gap: 8px;
}
}
/* A full year gets a full row; fit every week instead of clipping or scrolling. */
.ds-calendar[data-months="12"] .ds-calendar-inner {
width: 100%;
max-width: none;
}
.ds-calendar[data-months="12"] .ds-calendar-grid {
min-width: 0;
gap: clamp(2px, 0.4vw, 6px);
}
.ds-legend-scale { flex-wrap: wrap; }
/* Editing patterns use the same open layout, rules, and neutral controls. */
.ds-color-picker { border: 0; border-top: 1px solid var(--ds-rule); padding: 20px 0 0; margin: 26px 0; min-width: 0; }
.ds-color-picker legend { padding: 0 12px 0 0; @apply type-small; }
.ds-color-picker legend { padding: 0 12px 0 0; @apply type-ui-heading; }
.ds-color-picker > .ds-footnote { margin-top: 0 !important; }
.ds-color-palettes { display: flex; flex-wrap: wrap; gap: 20px 28px; margin: 20px 0; }
.ds-color-palette > span { @apply type-small; color: var(--ds-secondary); }
.ds-color-swatches { display: flex; gap: 4px; margin-top: 8px; }
.ds-color-swatches button { width: 38px; min-height: 44px; padding: 4px; border: 1px solid transparent; background: transparent; }
.ds-color-swatches button { display: grid; place-items: center; width: 44px; min-width: 44px; min-height: 44px; padding: 4px; border: 1px solid transparent; background: transparent; }
.ds-color-swatches button[aria-pressed="true"] { border-color: var(--ds-ink); }
.ds-color-swatches button > span { display: grid; place-content: center; height: 28px; width: 28px; color: white; @apply type-body; }
.ds-custom-color { display: flex; gap: 16px; align-items: start; }
.ds-color-swatches button > span { display: grid; place-content: center; height: 28px; width: 28px; color: var(--ds-paper); @apply type-body; }
.ds-custom-color { display: flex; flex-wrap: wrap; gap: 16px; align-items: start; }
.ds-custom-color .ds-field { margin-bottom: 0; }
.ds-custom-color .ds-field:last-child { width: 180px; }
.ds-custom-color input[type="color"] { width: 80px; height: 44px; padding: 5px; cursor: pointer; }
.ds-color-live-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 22px; @apply type-small; }
.ds-color-preview-shades { display: flex; gap: 4px; }
.ds-color-preview-shades > span { width: 18px; height: 18px; border: 1px solid #dedede; }
.ds-color-preview-shades > span { width: 18px; height: 18px; border: 1px solid var(--ds-rule); }
.ds-edit-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
@@ -1229,12 +1261,12 @@
.ds-edit-meta { display: flex; align-items: center; gap: 10px; margin-top: 24px; overflow-wrap: anywhere; }
.ds-habit-dot { width: 8px; height: 8px; flex: 0 0 8px; }
.ds-field { display: grid; min-width: 0; gap: 8px; margin-bottom: 22px; }
.ds-field label, .ds-weekday-field legend { @apply type-small; }
.ds-field label, .ds-weekday-field legend { @apply type-ui-heading; }
.ds-field :is(input, select) {
width: 100%;
min-width: 0;
min-height: 44px;
border: 1px solid #999;
border: 1px solid var(--ds-control-border);
padding: 10px 12px;
font: inherit;
@apply type-body;
@@ -1249,13 +1281,14 @@
.ds-edit-content .ds-checkbox { @apply type-small; }
.ds-weekday-field, .ds-task-editor-fieldset { border: 0; padding: 0; margin: 0 0 20px; min-width: 0; }
.ds-weekdays { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.ds-weekdays button { min-height: 44px; min-width: 44px; padding: 8px; border: 1px solid var(--ds-rule); background: white; color: var(--ds-secondary); }
.ds-weekdays button[aria-pressed="true"] { color: white; background: var(--ds-ink); border-color: var(--ds-ink); }
.ds-weekdays button { min-height: 44px; min-width: 44px; padding: 8px; border: 1px solid var(--ds-control-border); background: var(--ds-paper); color: var(--ds-secondary); }
.ds-weekdays button[aria-pressed="true"] { color: var(--ds-paper); background: var(--ds-ink); border-color: var(--ds-ink); }
.ds-form-actions { display: flex; flex-wrap: wrap; gap: 24px; align-items: center; }
.ds-form-feedback { min-height: 24px; margin: 20px 0 12px !important; color: var(--ds-secondary); @apply type-small; overflow-wrap: anywhere; }
.ds-form-feedback[role="alert"] { border-left: 2px solid var(--ds-ink); padding-left: 12px; color: var(--ds-ink); }
.ds-save-notice { margin-bottom: 0 !important; }
.ds-edit-task { border-top: 1px solid var(--ds-rule); padding: 16px 0 10px; }
.ds-edit-task { min-width: 0; margin: 0; border: 0; border-top: 1px solid var(--ds-rule); padding: 16px 0 10px; }
.ds-edit-task > legend { padding: 0 12px 0 0; @apply type-ui-heading; }
.ds-edit-task .ds-spec-heading { margin-bottom: 16px; }
.ds-empty-state { padding: 24px 0; color: var(--ds-secondary); }
.ds-history-requirement { display: flex; justify-content: space-between; gap: 16px; border-block: 1px solid var(--ds-rule); padding: 18px 0; margin-bottom: 24px; }
@@ -1284,4 +1317,20 @@
@media (max-width: 640px) {
.ds-header,
.ds-preview-toolbar { flex-wrap: wrap; }
.ds-view-switch { flex-wrap: wrap; gap: 0 18px; }
}
@media (forced-colors: active) {
.ds-checkbox input {
appearance: auto;
background: Canvas;
border-color: ButtonText;
}
.ds-checkbox input:checked::after { content: none; }
.ds-root :is(button, input):disabled { color: GrayText; opacity: 1; }
.ds-day { border-color: ButtonText; }
.ds-day[aria-pressed="true"] { outline-color: Highlight; }
.ds-tab-bar [aria-selected="true"],
.ds-month-views [aria-pressed="true"],
.ds-weekdays [aria-pressed="true"] { border-color: Highlight; }
}

View File

@@ -5,6 +5,8 @@
--text-copy--line-height: 1.5;
--text-caption: 0.875rem;
--text-caption--line-height: 1.5;
--text-ui-heading: 1.125rem;
--text-ui-heading--line-height: 1.5;
--text-lead: 1.25rem;
--text-lead--line-height: 1.5;
--text-title: 2rem;
@@ -17,6 +19,12 @@
@utility type-body { @apply text-copy; }
@utility type-small { @apply text-caption; }
@utility type-ui-heading {
@apply text-ui-heading;
font-family: var(--ds-sans);
font-weight: 500;
letter-spacing: normal;
}
@utility type-label {
@apply text-caption;
font-weight: 500;

View File

@@ -3,6 +3,16 @@ import { readFileSync } from "node:fs";
const css = (name: string) => readFileSync(new URL(name, import.meta.url), "utf8");
function tokenLuminance(source: string, token: string) {
let hex = source.match(new RegExp(`--ds-${token}: #([\\da-f]+);`))![1]!;
if (hex.length === 3) hex = [...hex].map((c) => c + c).join("");
const channels = [0, 2, 4].map((start) => {
const value = parseInt(hex.slice(start, start + 2), 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
}
describe("shared typography contract", () => {
test("all component and page styles use the fixed shared type utilities", () => {
for (const file of ["design-system.css"]) {
@@ -15,24 +25,50 @@ describe("shared typography contract", () => {
expect(typography).not.toMatch(/clamp\(|\d(?:vw|vh|px)\b/);
expect(typography).toContain("--text-copy: 1rem");
expect(typography).toContain("--text-caption: 0.875rem");
for (const role of ["body", "small", "label", "lead", "title", "section", "display", "control"]) {
for (const role of ["body", "small", "label", "ui-heading", "eyebrow", "lead", "title", "section", "display", "control"]) {
expect(typography).toContain(`@utility type-${role}`);
}
});
test("text colors meet WCAG AA normal-text contrast on paper", () => {
const source = css("design-system.css");
const luminance = (token: string) => {
let hex = source.match(new RegExp(`--ds-${token}: #([\\da-f]+);`))![1]!;
if (hex.length === 3) hex = [...hex].map((c) => c + c).join("");
const channels = [0, 2, 4].map((start) => {
const value = parseInt(hex.slice(start, start + 2), 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
};
for (const color of ["ink", "secondary"]) {
expect((luminance("paper") + 0.05) / (luminance(color) + 0.05)).toBeGreaterThanOrEqual(4.5);
expect((tokenLuminance(source, "paper") + 0.05) / (tokenLuminance(source, color) + 0.05)).toBeGreaterThanOrEqual(4.5);
}
});
test("UI headings are larger than supporting text and reset editorial typography", () => {
const rule = css("typography.css").match(/@utility type-ui-heading\s*\{([^}]+)\}/)![1]!;
expect(rule).toContain("@apply text-ui-heading");
const source = css("typography.css");
const size = (role: string) => Number(source.match(new RegExp(`--text-${role}: ([\\d.]+)rem`))![1]);
expect(size("ui-heading")).toBe(1.125);
expect(size("ui-heading")).toBeGreaterThan(size("caption"));
expect(rule).toContain("font-family: var(--ds-sans)");
expect(rule).toContain("font-weight: 500");
expect(rule).toContain("letter-spacing: normal");
expect(css("design-system.css")).toMatch(/\.ds-spec-heading label\s*\{\s*@apply type-ui-heading/);
});
test("control boundaries contrast with paper and use a shared token", () => {
const source = css("design-system.css");
const ratio = (tokenLuminance(source, "paper") + 0.05) / (tokenLuminance(source, "control-border") + 0.05);
expect(ratio).toBeGreaterThanOrEqual(3);
const fieldRule = source.match(/\.ds-field :is\(input, select\)\s*\{([^}]+)\}/)![1]!;
expect(fieldRule).toContain("border: 1px solid var(--ds-control-border)");
});
test("shared controls retain usable targets and native forced-colors checkboxes", () => {
const source = css("design-system.css");
const counterRule = source.match(/\.ds-counter \.ds-button\s*\{([^}]+)\}/)![1]!;
expect(counterRule).toContain("min-width: 44px");
expect(counterRule).toContain("min-height: 44px");
const dayRule = source.match(/\.ds-day\s*\{([^}]+)\}/)![1]!;
expect(dayRule).toContain("min-width: 0");
expect(dayRule).toContain("aspect-ratio: 1");
expect(source).toContain("--calendar-gap: 4px;");
expect(source).toContain("@media (max-width: 600px) and (orientation: portrait)");
expect(source).toContain(".ds-day { min-width: 24px; min-height: 24px; }");
expect(source).toMatch(/@media \(forced-colors: active\)\s*\{\s*\.ds-checkbox input\s*\{\s*appearance: auto/);
});
});