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:
57
README.md
57
README.md
@@ -19,21 +19,29 @@ Open http://127.0.0.1:3000. Set `PORT` to use a different port.
|
||||
|
||||
### Design system preview
|
||||
|
||||
The library uses seven tabs: Foundations, Components, Layout, Calendars, Playground,
|
||||
Editing, and References. Foundations is the default. Each tab has a URL hash;
|
||||
existing section links still work. Arrow keys, Home, and End navigate the tabs.
|
||||
Switching tabs preserves unsaved demo state; browser back/forward restores the section.
|
||||
The library uses four tabs: Foundations, Components, Layout, and Calendars.
|
||||
Foundations is the default. Arrow keys, Home, and End navigate the tabs; URL
|
||||
hashes and browser back/forward restore the section. Switching tabs preserves
|
||||
local example state. Old `#playground` links open `#calendar-states`; removed
|
||||
`#editing` and `#views` links resolve to `#foundations`.
|
||||
|
||||
Open `/design-system#editing` for interactive habit settings, task recurrence,
|
||||
and dated progress corrections. Habit colors include Earth, Coast, Dusk, Forest,
|
||||
Citrus, Blossom, Jewel, and Slate palettes plus a native picker and hex input. Editors support save/cancel,
|
||||
archive/restore, and an in-session correction history. The backfill calendar
|
||||
uses frozen historical requirements, independent of edits to today's settings.
|
||||
These are unsaved, local UI examples with a fixed September 4, 2026 demo clock;
|
||||
they do not call the habit API or change account data. Reload or Reset editor
|
||||
clears the preview. Habit colors preview immediately across the editor and the
|
||||
daily/detail charts; Save keeps the color in the demo and Cancel restores it.
|
||||
Other settings and progress remain independent of the daily tracking playground.
|
||||
Open `/design-system#calendar-states` for progress states and three working
|
||||
calendar examples: a count target, a manual checkbox, and combined completion.
|
||||
Changing water or reading progress updates today's combined score. Each calendar
|
||||
supports 3/4/6/12-month ranges, a Custom start/end date range within available
|
||||
history, date inspection, and keyboard navigation. Custom dates are inclusive;
|
||||
Apply range updates the chart. Dates
|
||||
form one continuous weekly chart with shared month and weekday labels and a fixed
|
||||
4px gap between dates across every timeframe. Landscape
|
||||
views fit the available width without chart scrollbars; narrow portrait views
|
||||
retain 24px date targets in a local horizontal scroller. Primary controls use
|
||||
44px targets, and custom checkboxes retain native semantics in forced-color mode.
|
||||
These examples use a fixed September 4, 2026 demo date and do not call the API;
|
||||
reload or Reset examples restores their initial progress.
|
||||
|
||||
The Playground, Editing, and References showcase tabs have been removed.
|
||||
Reusable calendar, editing, color-picker components and reference assets remain
|
||||
available for future pages.
|
||||
|
||||
The previous Landing, Home, About, and Settings pages have been removed. The
|
||||
backend API, authentication infrastructure, database, and reusable components remain
|
||||
@@ -52,7 +60,26 @@ in rem. At 640px and below, display steps to 48/54 and section to 36/45; body an
|
||||
supporting text never shrink. Use these classes directly or with `@apply`, not
|
||||
bespoke font sizes or viewport-based type. See `/design-system#foundations` for
|
||||
live specimens. Reuse `SectionHeading`, `Button`, `ButtonLink`, and the shared
|
||||
`DiscordSignInButton` for headings and calls to action.
|
||||
`DiscordSignInButton` for headings and calls to action. All default button variants,
|
||||
including text/ghost buttons and links, share 10px vertical and 19px horizontal
|
||||
padding. Ghost changes only colors and border visibility; counter buttons retain
|
||||
their explicit 44px square sizing.
|
||||
Use `type-ui-heading` (18/27, medium system sans) for interface headings, field
|
||||
labels, and form legends; it stays visibly larger than 14/21 supporting text at
|
||||
every breakpoint. Preserve serif `type-title` and `type-section` for editorial
|
||||
headings. Use `type-eyebrow` for 12/18 section markers. `Field` supplies a control ID and hint ID through its
|
||||
render callback; apply both to the native control. `Card.headingLevel` defaults
|
||||
to 3 and can be set to match its surrounding document hierarchy without changing
|
||||
its appearance.
|
||||
|
||||
Interface colors use the existing neutral palette through `--ds-ink`,
|
||||
`--ds-secondary`, `--ds-control-border`, `--ds-rule`, `--ds-paper`,
|
||||
`--ds-surface` (the existing #F5F5F5 fill), and `--ds-empty` (the existing #EEEEEE
|
||||
calendar fill). Use secondary for muted text and primary hover, surface for
|
||||
neutral hover/grouping, and control-border for actionable boundaries. Keep the
|
||||
calendar progress ramp and habit identity colors separate. Foundations lists the
|
||||
values and roles. Supporting copy should explain a consequence, constraint, or
|
||||
interaction; omit prose that merely restates a heading or visible control.
|
||||
|
||||
```sh
|
||||
bun run typecheck
|
||||
|
||||
80
docs/design/audit-2026-09-04.md
Normal file
80
docs/design/audit-2026-09-04.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
64
src/components/design-system/CalendarExamples.tsx
Normal file
64
src/components/design-system/CalendarExamples.tsx
Normal 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 today’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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="There’s no catching up.">
|
||||
<Card headingLevel={4} span="wide" eyebrow="A GENTLE REMINDER" heading="There’s 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
28
src/components/design-system/Field.tsx
Normal file
28
src/components/design-system/Field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 habit’s 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 habit’s calendar. Cancel keeps your current color."
|
||||
: "Previews update immediately, including the charts above. Save to keep this color in the demo; Cancel to revert."}
|
||||
? "Save to apply this color; Cancel restores the saved color."
|
||||
: "Charts preview changes immediately. Save keeps the demo color; Cancel reverts it."}
|
||||
</p>
|
||||
{!valid && (
|
||||
<p id={`${id}-error`} role="alert" className="ds-form-feedback">
|
||||
|
||||
215
src/components/design-system/accessibility.test.tsx
Normal file
215
src/components/design-system/accessibility.test.tsx
Normal 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!);
|
||||
}
|
||||
});
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
52
src/components/design-system/structure.test.tsx
Normal file
52
src/components/design-system/structure.test.tsx
Normal 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);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user