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
|
### Design system preview
|
||||||
|
|
||||||
The library uses seven tabs: Foundations, Components, Layout, Calendars, Playground,
|
The library uses four tabs: Foundations, Components, Layout, and Calendars.
|
||||||
Editing, and References. Foundations is the default. Each tab has a URL hash;
|
Foundations is the default. Arrow keys, Home, and End navigate the tabs; URL
|
||||||
existing section links still work. Arrow keys, Home, and End navigate the tabs.
|
hashes and browser back/forward restore the section. Switching tabs preserves
|
||||||
Switching tabs preserves unsaved demo state; browser back/forward restores the section.
|
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,
|
Open `/design-system#calendar-states` for progress states and three working
|
||||||
and dated progress corrections. Habit colors include Earth, Coast, Dusk, Forest,
|
calendar examples: a count target, a manual checkbox, and combined completion.
|
||||||
Citrus, Blossom, Jewel, and Slate palettes plus a native picker and hex input. Editors support save/cancel,
|
Changing water or reading progress updates today's combined score. Each calendar
|
||||||
archive/restore, and an in-session correction history. The backfill calendar
|
supports 3/4/6/12-month ranges, a Custom start/end date range within available
|
||||||
uses frozen historical requirements, independent of edits to today's settings.
|
history, date inspection, and keyboard navigation. Custom dates are inclusive;
|
||||||
These are unsaved, local UI examples with a fixed September 4, 2026 demo clock;
|
Apply range updates the chart. Dates
|
||||||
they do not call the habit API or change account data. Reload or Reset editor
|
form one continuous weekly chart with shared month and weekday labels and a fixed
|
||||||
clears the preview. Habit colors preview immediately across the editor and the
|
4px gap between dates across every timeframe. Landscape
|
||||||
daily/detail charts; Save keeps the color in the demo and Cancel restores it.
|
views fit the available width without chart scrollbars; narrow portrait views
|
||||||
Other settings and progress remain independent of the daily tracking playground.
|
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
|
The previous Landing, Home, About, and Settings pages have been removed. The
|
||||||
backend API, authentication infrastructure, database, and reusable components remain
|
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
|
supporting text never shrink. Use these classes directly or with `@apply`, not
|
||||||
bespoke font sizes or viewport-based type. See `/design-system#foundations` for
|
bespoke font sizes or viewport-based type. See `/design-system#foundations` for
|
||||||
live specimens. Reuse `SectionHeading`, `Button`, `ButtonLink`, and the shared
|
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
|
```sh
|
||||||
bun run typecheck
|
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(container.querySelector(".home-root")).toBeNull();
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
expect(container.querySelectorAll('[role="tablist"]')).toHaveLength(1);
|
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);
|
expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1);
|
||||||
for (const tab of container.querySelectorAll('[role="tab"]')) {
|
for (const tab of container.querySelectorAll('[role="tab"]')) {
|
||||||
const panel = document.getElementById(tab.getAttribute("aria-controls")!);
|
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 () => {
|
test("calendar examples update individual and combined progress without API requests", async () => {
|
||||||
await render("/design-system#playground");
|
await render("/design-system#calendar-states");
|
||||||
const tabs = Array.from(container.querySelectorAll<HTMLButtonElement>("button"));
|
const calendars = container.querySelectorAll<HTMLElement>("#ds-panel-calendar-states .ds-calendar");
|
||||||
const detail = tabs.find(button => button.textContent?.trim() === "Habit detail")!;
|
expect(calendars).toHaveLength(3);
|
||||||
expect(detail).toBeDefined();
|
const inspected = (index: number) => calendars[index]!.querySelector(".ds-date-inspector")!.textContent!;
|
||||||
await act(async () => detail.click());
|
expect(inspected(0)).toContain("7 of 8 glasses");
|
||||||
expect(detail.getAttribute("aria-pressed")).toBe("true");
|
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();
|
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 () => {
|
test("defaults to foundations and each tab exposes only its own panel", async () => {
|
||||||
await render();
|
await render();
|
||||||
expect(panel().id).toBe("ds-panel-foundations");
|
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);
|
await clickTab(id);
|
||||||
expect(panel().id).toBe(`ds-panel-${id}`);
|
expect(panel().id).toBe(`ds-panel-${id}`);
|
||||||
expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1);
|
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 () => {
|
test("supports arrow keys, Home, End, and wraparound with focus", async () => {
|
||||||
await render();
|
await render();
|
||||||
await press("foundations", "ArrowLeft");
|
await press("foundations", "ArrowLeft");
|
||||||
expect(document.activeElement).toBe(tab("views"));
|
expect(document.activeElement).toBe(tab("calendar-states"));
|
||||||
expect(panel().id).toBe("ds-panel-views");
|
expect(panel().id).toBe("ds-panel-calendar-states");
|
||||||
await press("views", "ArrowRight");
|
await press("calendar-states", "ArrowRight");
|
||||||
expect(document.activeElement).toBe(tab("foundations"));
|
expect(document.activeElement).toBe(tab("foundations"));
|
||||||
await press("foundations", "End");
|
await press("foundations", "End");
|
||||||
expect(document.activeElement).toBe(tab("views"));
|
expect(document.activeElement).toBe(tab("calendar-states"));
|
||||||
await press("views", "Home");
|
await press("calendar-states", "Home");
|
||||||
expect(document.activeElement).toBe(tab("foundations"));
|
expect(document.activeElement).toBe(tab("foundations"));
|
||||||
expect(panel().id).toBe("ds-panel-foundations");
|
expect(panel().id).toBe("ds-panel-foundations");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("honors deep links and browser history", async () => {
|
test("honors deep links and browser history", async () => {
|
||||||
await render("/design-system#editing");
|
await render("/design-system#calendar-states");
|
||||||
expect(panel().id).toBe("ds-panel-editing");
|
expect(panel().id).toBe("ds-panel-calendar-states");
|
||||||
await clickTab("containers");
|
await clickTab("containers");
|
||||||
const historyButton = (label: string) => Array.from(container.querySelectorAll("button")).find(button => button.textContent === label)!;
|
const historyButton = (label: string) => Array.from(container.querySelectorAll("button")).find(button => button.textContent === label)!;
|
||||||
await act(async () => historyButton("History back").click());
|
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());
|
await act(async () => historyButton("History forward").click());
|
||||||
expect(panel().id).toBe("ds-panel-containers");
|
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 render("/design-system#components");
|
||||||
await act(async () => panel().querySelector<HTMLButtonElement>('[aria-label="Increase example count"]')!.click());
|
await act(async () => panel().querySelector<HTMLButtonElement>('[aria-label="Increase example count"]')!.click());
|
||||||
await clickTab("editing");
|
await clickTab("calendar-states");
|
||||||
await clickTab("components");
|
await clickTab("components");
|
||||||
expect(panel().querySelector("output")?.textContent).toBe("4 / 8");
|
expect(panel().querySelector("output")?.textContent).toBe("4 / 8");
|
||||||
const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!;
|
const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!;
|
||||||
await act(async () => viewHabit.click());
|
await act(async () => viewHabit.click());
|
||||||
expect(panel().id).toBe("ds-panel-playground");
|
expect(panel().id).toBe("ds-panel-calendar-states");
|
||||||
expect(panel().querySelector('.ds-view-switch [aria-pressed="true"]')?.textContent).toBe("Habit detail");
|
expect(panel().querySelectorAll(".ds-calendar")).toHaveLength(3);
|
||||||
expect(document.activeElement).toBe(tab("playground"));
|
expect(document.activeElement).toBe(tab("calendar-states"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("retains child component state and skip link does not switch tabs", async () => {
|
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");
|
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 () => {
|
test("an unknown section safely falls back to foundations", async () => {
|
||||||
await render("/design-system#unknown");
|
await render("/design-system#unknown");
|
||||||
expect(panel().id).toBe("ds-panel-foundations");
|
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 { CalendarLegend } from "./CalendarLegend";
|
||||||
|
import { Field } from "./Field";
|
||||||
|
import { Button } from "./primitives";
|
||||||
import {
|
import {
|
||||||
describeDay,
|
describeDay,
|
||||||
|
calendarTimeline,
|
||||||
progressShade,
|
progressShade,
|
||||||
MONTH_VIEWS,
|
MONTH_VIEWS,
|
||||||
visibleCalendarDays,
|
visibleCalendarDays,
|
||||||
@@ -32,7 +35,9 @@ export function CalendarHeatmap({
|
|||||||
historyLabel?: string;
|
historyLabel?: string;
|
||||||
legend?: ReactNode;
|
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 =
|
const latestDate =
|
||||||
allDays.findLast((day) => day.state !== "future")?.date ??
|
allDays.findLast((day) => day.state !== "future")?.date ??
|
||||||
allDays.at(-1)?.date;
|
allDays.at(-1)?.date;
|
||||||
@@ -47,46 +52,55 @@ export function CalendarHeatmap({
|
|||||||
if (onSelectDate) onSelectDate(date);
|
if (onSelectDate) onSelectDate(date);
|
||||||
else setLocalSelectedDate(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 =
|
const selected =
|
||||||
days.find((day) => day.date === selectedDate) ??
|
days.find((day) => day.date === selectedDate) ??
|
||||||
days.find((day) => day.date === anchor) ??
|
days.find((day) => day.date === anchor) ??
|
||||||
days[0];
|
days[0];
|
||||||
const offset = days[0]
|
// Date keys and the shared timeline remain stable when the chart resizes.
|
||||||
? new Date(`${days[0].date}T12:00:00Z`).getUTCDay()
|
const timeline = calendarTimeline(days);
|
||||||
: 0;
|
|
||||||
const weeks = Math.ceil((days.length + offset) / 7);
|
|
||||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
const scrollArea = useRef<HTMLDivElement | null>(null);
|
|
||||||
const inspectorId = useId();
|
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;
|
const selectedIndex = selected ? days.indexOf(selected) : -1;
|
||||||
useEffect(() => {
|
// Controlled editors may reject a selection to preserve an unsaved draft.
|
||||||
const container = scrollArea.current;
|
// Only move focus after that date is actually selected, using the new window.
|
||||||
const button = buttons.current[selectedIndex];
|
useLayoutEffect(() => {
|
||||||
if (!container || !button) return;
|
if (pendingFocusDate.current !== null)
|
||||||
const cellBounds = button.getBoundingClientRect();
|
buttons.current[selectedIndex]?.focus();
|
||||||
const bounds = container.getBoundingClientRect();
|
pendingFocusDate.current = null;
|
||||||
if (cellBounds.right > bounds.right - 5)
|
}, [focusRequest, selectedIndex, selected?.date]);
|
||||||
container.scrollLeft += cellBounds.right - bounds.right + 5;
|
function selectAndFocus(date: string) {
|
||||||
else if (cellBounds.left < bounds.left + 5)
|
pendingFocusDate.current = date;
|
||||||
container.scrollLeft += cellBounds.left - bounds.left - 5;
|
// A rejected controlled selection still needs a render to restore click
|
||||||
}, [months, selectedIndex]);
|
// 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>;
|
if (!selected) return <p>No calendar dates to display.</p>;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||||
data-months={months}
|
data-months={months}
|
||||||
|
style={{ "--calendar-weeks": timeline.weeks } as CSSProperties}
|
||||||
>
|
>
|
||||||
<div className="ds-calendar-range-toolbar">
|
<div className="ds-calendar-range-toolbar">
|
||||||
<span className="ds-calendar-range-label" aria-live="polite">
|
<span className="ds-calendar-range-label" aria-live="polite">
|
||||||
{new Date(`${days[0]!.date}T12:00:00Z`).toLocaleDateString("en", {
|
{new Date(`${days[0]!.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||||
month: "short",
|
month: "short",
|
||||||
|
day: months === "custom" ? "numeric" : undefined,
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
})}{" "}
|
})}{" "}
|
||||||
–{" "}
|
–{" "}
|
||||||
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
|
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||||
month: "short",
|
month: "short",
|
||||||
|
day: months === "custom" ? "numeric" : undefined,
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
})}
|
})}
|
||||||
@@ -107,126 +121,160 @@ export function CalendarHeatmap({
|
|||||||
{value}m
|
{value}m
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div
|
aria-pressed={months === "custom"}
|
||||||
ref={scrollArea}
|
onClick={() => {
|
||||||
className="ds-calendar-scroll"
|
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
|
||||||
role="group"
|
setRangeError("");
|
||||||
aria-label={label}
|
setMonths("custom");
|
||||||
>
|
|
||||||
<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))`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{days.map(
|
Custom
|
||||||
(day, index) =>
|
</button>
|
||||||
(index === 0 || day.date.endsWith("-01")) && (
|
</div>
|
||||||
<span
|
</div>
|
||||||
key={day.date}
|
{months === "custom" && customRange && (
|
||||||
style={{ gridColumn: Math.floor((index + offset) / 7) + 1 }}
|
<form
|
||||||
>
|
className="ds-calendar-custom-range"
|
||||||
{new Date(`${day.date}T12:00:00Z`).toLocaleDateString(
|
aria-label={`${label} custom timeframe`}
|
||||||
"en",
|
noValidate
|
||||||
{ month: "short", timeZone: "UTC" },
|
onSubmit={(event) => {
|
||||||
)}
|
event.preventDefault();
|
||||||
</span>
|
const form = event.currentTarget;
|
||||||
),
|
const start = form.elements.namedItem("from") as HTMLInputElement;
|
||||||
)}
|
const end = form.elements.namedItem("to") as HTMLInputElement;
|
||||||
</div>
|
const isAvailable = (input: HTMLInputElement) =>
|
||||||
<div className="ds-calendar-body">
|
input.validity.valid && input.value !== "" &&
|
||||||
<div className="ds-calendar-weekdays" aria-hidden="true">
|
input.value >= allDays[0]!.date && input.value <= allDays.at(-1)!.date;
|
||||||
<span>Mon</span>
|
if (!isAvailable(start) || !isAvailable(end)) {
|
||||||
<span>Wed</span>
|
setRangeError("Choose both dates within the available history.");
|
||||||
<span>Fri</span>
|
(!isAvailable(start) ? start : end).focus();
|
||||||
</div>
|
return;
|
||||||
<div
|
}
|
||||||
className="ds-calendar-grid"
|
if (start.value > end.value) {
|
||||||
style={{
|
setRangeError("The end date must be on or after the start date.");
|
||||||
gridTemplateColumns: `repeat(${weeks}, 1fr)`,
|
end.focus();
|
||||||
}}
|
return;
|
||||||
>
|
}
|
||||||
{Array.from({ length: offset }, (_, index) => (
|
if (!allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
|
||||||
<span key={`padding-${index}`} aria-hidden="true" />
|
setRangeError("There are no calendar dates in this range.");
|
||||||
))}
|
return;
|
||||||
{days.map((day, index) => (
|
}
|
||||||
<button
|
setCustomRange({ from: start.value, to: end.value });
|
||||||
key={day.date}
|
setRangeError("");
|
||||||
ref={(element) => {
|
}}
|
||||||
buttons.current[index] = element;
|
>
|
||||||
}}
|
{(["from", "to"] as const).map((name) => (
|
||||||
type="button"
|
<Field key={name} label={name === "from" ? "Start date" : "End date"}>
|
||||||
className={`ds-day ds-day--${day.state}`}
|
{(id) => (
|
||||||
style={{
|
<input
|
||||||
backgroundColor:
|
id={id}
|
||||||
day.state === "not-due"
|
name={name}
|
||||||
? "transparent"
|
type="date"
|
||||||
: day.state === "future"
|
required
|
||||||
? emptyColor
|
min={allDays[0]!.date}
|
||||||
: progressShade(day, color),
|
max={allDays.at(-1)!.date}
|
||||||
}}
|
defaultValue={customRange[name]}
|
||||||
aria-label={`${day.date}: ${describeDay(day, unit)}`}
|
aria-invalid={rangeError ? true : undefined}
|
||||||
title={`${day.date}: ${describeDay(day, unit)}`}
|
aria-describedby={rangeError ? rangeErrorId : undefined}
|
||||||
aria-pressed={day.date === selected.date}
|
onChange={() => setRangeError("")}
|
||||||
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();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
|
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
|
||||||
<div className="ds-calendar-caption">
|
<div className="ds-calendar-caption">
|
||||||
<span>
|
<span>
|
||||||
{months} months{" "}
|
{months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
|
||||||
<span className="ds-muted">
|
<span className="ds-muted">
|
||||||
/{" "}
|
/{" "}
|
||||||
{historyLabel ??
|
{historyLabel ??
|
||||||
(compact ? "Demo history" : "Illustrative history")}
|
(compact ? "Demo history" : "Illustrative history")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="ds-muted">
|
<span id={instructionsId} className="ds-muted">
|
||||||
{compact
|
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
|
||||||
? "Select a day · Arrow keys"
|
|
||||||
: "Select a day to inspect · Arrow keys to move"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
|
<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({
|
export function Card({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
heading,
|
heading,
|
||||||
|
headingLevel = 3,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
variant = "soft",
|
variant = "soft",
|
||||||
@@ -13,11 +14,13 @@ export function Card({
|
|||||||
}: HTMLAttributes<HTMLElement> & {
|
}: HTMLAttributes<HTMLElement> & {
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
heading: ReactNode;
|
heading: ReactNode;
|
||||||
|
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
variant?: "soft" | "outlined";
|
variant?: "soft" | "outlined";
|
||||||
span?: "standard" | "wide" | "featured";
|
span?: "standard" | "wide" | "featured";
|
||||||
}) {
|
}) {
|
||||||
const headingId = useId();
|
const headingId = useId();
|
||||||
|
const Heading = `h${headingLevel}` as const;
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
aria-labelledby={headingId}
|
aria-labelledby={headingId}
|
||||||
@@ -26,7 +29,7 @@ export function Card({
|
|||||||
>
|
>
|
||||||
<header className="ds-card-header">
|
<header className="ds-card-header">
|
||||||
{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}
|
{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}
|
||||||
<h3 id={headingId}>{heading}</h3>
|
<Heading id={headingId} className="ds-card-title">{heading}</Heading>
|
||||||
</header>
|
</header>
|
||||||
{children && <div className="ds-card-body">{children}</div>}
|
{children && <div className="ds-card-body">{children}</div>}
|
||||||
{footer && <footer className="ds-card-footer">{footer}</footer>}
|
{footer && <footer className="ds-card-footer">{footer}</footer>}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export function ContainerShowcase() {
|
|||||||
<span className="ds-code">Card · soft / outlined</span>
|
<span className="ds-code">Card · soft / outlined</span>
|
||||||
</div>
|
</div>
|
||||||
<CardGrid>
|
<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>
|
<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>
|
||||||
<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>
|
<p>A fine border defines a standalone group on white. Useful when a form or a focused task needs its own space.</p>
|
||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
@@ -31,26 +31,24 @@ export function ContainerShowcase() {
|
|||||||
</div>
|
</div>
|
||||||
<CardGrid layout="bento" aria-label="Interactive bento layout example">
|
<CardGrid layout="bento" aria-label="Interactive bento layout example">
|
||||||
<Card
|
<Card
|
||||||
|
headingLevel={4}
|
||||||
span="featured"
|
span="featured"
|
||||||
eyebrow="TODAY / YOUR OWN PACE"
|
eyebrow="TODAY / YOUR OWN PACE"
|
||||||
heading={<>Small things, <em>adding up.</em></>}
|
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">
|
<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 className="type-display">{Number(water === 8) + Number(read)}<span className="type-title ds-muted"> / 2</span></p>
|
||||||
<p>habits complete</p>
|
<p>habits complete</p>
|
||||||
</div>
|
</div>
|
||||||
<p>Make room for a glass of water and a few pages. A little progress belongs here, too.</p>
|
|
||||||
</Card>
|
</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} />
|
<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>
|
<p className="type-small" role="status">{water === 8 ? "Complete for today" : `${8 - water} glasses to go`}</p>
|
||||||
</Card>
|
</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)} />
|
<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>
|
||||||
<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>
|
<p>Come back to today. Your next small step is enough.</p>
|
||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</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>
|
<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>
|
<Button variant="text" onClick={() => { setWater(3); setRead(false); }}>Reset card examples ↺</Button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
|
||||||
type SystemTab = { id: string; label: string; content: ReactNode };
|
type SystemTab = { id: string; label: string; content: ReactNode };
|
||||||
@@ -7,7 +7,13 @@ export function DesignSystemTabs({ tabs }: { tabs: SystemTab[] }) {
|
|||||||
const { hash } = useLocation();
|
const { hash } = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const buttons = useRef<Array<HTMLButtonElement | null>>([]);
|
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;
|
const active = selected < 0 ? 0 : selected;
|
||||||
|
|
||||||
function select(index: number) {
|
function select(index: number) {
|
||||||
@@ -44,7 +50,7 @@ export function DesignSystemTabs({ tabs }: { tabs: SystemTab[] }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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) => (
|
{tabs.map((tab, index) => (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
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 type { HabitConfig, Schedule } from "../../habits/contracts";
|
||||||
import { Button, Checkbox } from "./primitives";
|
import { Button, Checkbox } from "./primitives";
|
||||||
|
import { Field } from "./Field";
|
||||||
import { CalendarHeatmap } from "./CalendarHeatmap";
|
import { CalendarHeatmap } from "./CalendarHeatmap";
|
||||||
import {
|
import {
|
||||||
HabitColorPicker,
|
HabitColorPicker,
|
||||||
@@ -18,25 +19,6 @@ import {
|
|||||||
validateTasks,
|
validateTasks,
|
||||||
} from "./editing-model";
|
} 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"];
|
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
export function ScheduleEditor({
|
export function ScheduleEditor({
|
||||||
@@ -212,6 +194,9 @@ export function EditingWorkbench({
|
|||||||
onColorsChange?: (colors: HabitColors) => void;
|
onColorsChange?: (colors: HabitColors) => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<EditorTab>("Habit settings");
|
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 [habits, setHabits] = useState(() => structuredClone(EDITOR_HABITS));
|
||||||
const [selectedId, setSelectedId] = useState("water");
|
const [selectedId, setSelectedId] = useState("water");
|
||||||
const selected = habits.find((habit) => habit.id === selectedId)!;
|
const selected = habits.find((habit) => habit.id === selectedId)!;
|
||||||
@@ -254,6 +239,12 @@ export function EditingWorkbench({
|
|||||||
JSON.stringify(taskChecks) !== JSON.stringify(savedChecks));
|
JSON.stringify(taskChecks) !== JSON.stringify(savedChecks));
|
||||||
const dirty = tab === "Backfill progress" ? progressDirty : configDirty;
|
const dirty = tab === "Backfill progress" ? progressDirty : configDirty;
|
||||||
const blocked = backfillError(selectedId, date, progress);
|
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) {
|
function loadProgress(id: string, nextDate: string) {
|
||||||
const original = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
|
const original = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
|
||||||
@@ -508,9 +499,10 @@ export function EditingWorkbench({
|
|||||||
label="Completion method"
|
label="Completion method"
|
||||||
hint="Method is fixed in this preview. Choose another habit to try its editor."
|
hint="Method is fixed in this preview. Choose another habit to try its editor."
|
||||||
>
|
>
|
||||||
{(id) => (
|
{(id, describedBy) => (
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
readOnly
|
readOnly
|
||||||
value={
|
value={
|
||||||
draft.method === "count"
|
draft.method === "count"
|
||||||
@@ -626,6 +618,7 @@ export function EditingWorkbench({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<fieldset
|
<fieldset
|
||||||
|
ref={taskEditor}
|
||||||
className="ds-task-editor-fieldset"
|
className="ds-task-editor-fieldset"
|
||||||
disabled={draft.archived}
|
disabled={draft.archived}
|
||||||
aria-label="Task definitions"
|
aria-label="Task definitions"
|
||||||
@@ -637,22 +630,22 @@ export function EditingWorkbench({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{draft.tasks.map((task, index) => (
|
{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">
|
<div className="ds-spec-heading">
|
||||||
<span className="ds-code">
|
|
||||||
STEP {String(index + 1).padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
aria-label={`Remove ${task.name || "unnamed task"}`}
|
aria-label={`Remove ${task.name || "unnamed task"}`}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
taskFocus.current = draft.tasks[index + 1]?.id ?? draft.tasks[index - 1]?.id ?? "add";
|
||||||
setDraft({
|
setDraft({
|
||||||
...draft,
|
...draft,
|
||||||
tasks: draft.tasks.filter(
|
tasks: draft.tasks.filter(
|
||||||
(item) => item.id !== task.id,
|
(item) => item.id !== task.id,
|
||||||
),
|
),
|
||||||
})
|
});
|
||||||
}
|
setNotice(`${task.name || "Unnamed task"} removed from the draft. Save tasks to apply.`);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Remove −
|
Remove −
|
||||||
</Button>
|
</Button>
|
||||||
@@ -661,6 +654,7 @@ export function EditingWorkbench({
|
|||||||
{(id) => (
|
{(id) => (
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
|
ref={(element) => { taskFields.current[task.id] = element; }}
|
||||||
required
|
required
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
value={task.name}
|
value={task.name}
|
||||||
@@ -674,24 +668,28 @@ export function EditingWorkbench({
|
|||||||
value={task.schedule}
|
value={task.schedule}
|
||||||
onChange={(schedule) => updateTask(task.id, { schedule })}
|
onChange={(schedule) => updateTask(task.id, { schedule })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
data-add-task
|
||||||
disabled={draft.tasks.length >= 100}
|
disabled={draft.tasks.length >= 100}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
taskFocus.current = id;
|
||||||
setDraft({
|
setDraft({
|
||||||
...draft,
|
...draft,
|
||||||
tasks: [
|
tasks: [
|
||||||
...draft.tasks,
|
...draft.tasks,
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id,
|
||||||
name: "",
|
name: "",
|
||||||
schedule: { type: "daily" },
|
schedule: { type: "daily" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
}
|
setNotice("Task added to the draft. Enter its name and schedule.");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Add task +
|
Add task +
|
||||||
</Button>
|
</Button>
|
||||||
@@ -723,9 +721,10 @@ export function EditingWorkbench({
|
|||||||
label="Progress date"
|
label="Progress date"
|
||||||
hint="Select a date here or in the calendar below."
|
hint="Select a date here or in the calendar below."
|
||||||
>
|
>
|
||||||
{(id) => (
|
{(id, describedBy) => (
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
min={EDITOR_START}
|
min={EDITOR_START}
|
||||||
@@ -790,9 +789,10 @@ export function EditingWorkbench({
|
|||||||
label="Actual count"
|
label="Actual count"
|
||||||
hint="Enter the total, not an increment. Zero clears progress; counts may exceed the target."
|
hint="Enter the total, not an increment. Zero clears progress; counts may exceed the target."
|
||||||
>
|
>
|
||||||
{(id) => (
|
{(id, describedBy) => (
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
|
aria-describedby={describedBy}
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
min={0}
|
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 { useId } from "react";
|
||||||
import { HABIT_COLORS, progressShade } from "./calendar-model";
|
import { HABIT_COLORS, progressShade } from "./calendar-model";
|
||||||
|
import { Field } from "./Field";
|
||||||
|
|
||||||
export type HabitColors = {
|
export type HabitColors = {
|
||||||
-readonly [K in keyof typeof HABIT_COLORS]: string;
|
-readonly [K in keyof typeof HABIT_COLORS]: string;
|
||||||
@@ -51,8 +52,7 @@ export function HabitColorPicker({
|
|||||||
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
|
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
|
||||||
<legend>Habit color</legend>
|
<legend>Habit color</legend>
|
||||||
<p id={`${id}-hint`} className="ds-footnote">
|
<p id={`${id}-hint`} className="ds-footnote">
|
||||||
A color for this habit and its calendar. Choose a suggested shade or
|
Choose a suggested shade or enter a custom color for this habit’s calendar.
|
||||||
make it your own.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="ds-color-palettes">
|
<div className="ds-color-palettes">
|
||||||
{HABIT_PALETTES.map((palette) => (
|
{HABIT_PALETTES.map((palette) => (
|
||||||
@@ -83,19 +83,20 @@ export function HabitColorPicker({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="ds-custom-color">
|
<div className="ds-custom-color">
|
||||||
<div className="ds-field">
|
<Field label="Custom color" id={`${id}-native`}>
|
||||||
<label htmlFor={`${id}-native`}>Custom color</label>
|
{(inputId) => (
|
||||||
<input
|
<input
|
||||||
id={`${id}-native`}
|
id={inputId}
|
||||||
type="color"
|
type="color"
|
||||||
value={valid ? value : "#426582"}
|
value={valid ? value : "#426582"}
|
||||||
onInput={(event) => onChange(event.currentTarget.value)}
|
onInput={(event) => onChange(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="ds-field">
|
</Field>
|
||||||
<label htmlFor={`${id}-hex`}>Hex color</label>
|
<Field label="Hex color" id={`${id}-hex`}>
|
||||||
|
{(inputId) => (
|
||||||
<input
|
<input
|
||||||
id={`${id}-hex`}
|
id={inputId}
|
||||||
type="text"
|
type="text"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -105,10 +106,11 @@ export function HabitColorPicker({
|
|||||||
placeholder="#426582"
|
placeholder="#426582"
|
||||||
value={value}
|
value={value}
|
||||||
aria-invalid={!valid}
|
aria-invalid={!valid}
|
||||||
aria-describedby={!valid ? `${id}-error` : undefined}
|
aria-describedby={`${id}-hint${!valid ? ` ${id}-error` : ""}`}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
{valid && (
|
{valid && (
|
||||||
<div
|
<div
|
||||||
@@ -138,10 +140,10 @@ export function HabitColorPicker({
|
|||||||
)}
|
)}
|
||||||
<p className="ds-footnote">
|
<p className="ds-footnote">
|
||||||
{mode === "create"
|
{mode === "create"
|
||||||
? "Your chosen color is saved with your habit and used in its progress calendar."
|
? "Saved with your habit."
|
||||||
: mode === "edit"
|
: mode === "edit"
|
||||||
? "Save changes to apply this color to your habit’s calendar. Cancel keeps your current color."
|
? "Save to apply this color; Cancel restores the saved color."
|
||||||
: "Previews update immediately, including the charts above. Save to keep this color in the demo; Cancel to revert."}
|
: "Charts preview changes immediately. Save keeps the demo color; Cancel reverts it."}
|
||||||
</p>
|
</p>
|
||||||
{!valid && (
|
{!valid && (
|
||||||
<p id={`${id}-error`} role="alert" className="ds-form-feedback">
|
<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,
|
visibleCalendarDays,
|
||||||
MONTH_VIEWS,
|
MONTH_VIEWS,
|
||||||
legendShades,
|
legendShades,
|
||||||
|
calendarTimeline,
|
||||||
} from "./calendar-model";
|
} 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", () => {
|
test("legends match habit colors and only display possible progress shades", () => {
|
||||||
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
|
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
|
||||||
for (const target of [1, 3, 4, 8, 20]) {
|
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);
|
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 = {
|
export const HABIT_COLORS = {
|
||||||
water: "#426582",
|
water: "#426582",
|
||||||
reading: "#977344",
|
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,
|
Counter,
|
||||||
SectionHeading,
|
SectionHeading,
|
||||||
} from "../components/design-system/primitives";
|
} from "../components/design-system/primitives";
|
||||||
import { CalendarHeatmap } from "../components/design-system/CalendarHeatmap";
|
import { CalendarExamples } from "../components/design-system/CalendarExamples";
|
||||||
import { HabitChart } from "../components/design-system/HabitChart";
|
|
||||||
import { EditingWorkbench } from "../components/design-system/EditingWorkbench";
|
|
||||||
import { ContainerShowcase } from "../components/design-system/ContainerShowcase";
|
import { ContainerShowcase } from "../components/design-system/ContainerShowcase";
|
||||||
import type { HabitColors } from "../components/design-system/HabitColorPicker";
|
import { Field } from "../components/design-system/Field";
|
||||||
import {
|
import {
|
||||||
combinedProgress,
|
|
||||||
demoCalendar,
|
|
||||||
PROGRESS_SHADES,
|
PROGRESS_SHADES,
|
||||||
HABIT_COLORS,
|
HABIT_COLORS,
|
||||||
} from "../components/design-system/calendar-model";
|
} 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";
|
import { DesignSystemTabs } from "../components/design-system/DesignSystemTabs";
|
||||||
|
|
||||||
type View = "Today" | "Habit detail" | "Combined chart";
|
|
||||||
|
|
||||||
export function DesignSystem() {
|
export function DesignSystem() {
|
||||||
const navigate = useNavigate();
|
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 [exampleChecked, setExampleChecked] = useState(false);
|
||||||
const [exampleCount, setExampleCount] = useState(3);
|
const [exampleCount, setExampleCount] = useState(3);
|
||||||
const [habitName, setHabitName] = useState("");
|
const [habitName, setHabitName] = useState("");
|
||||||
const [savedName, setSavedName] = 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 (
|
return (
|
||||||
<div className="ds-root" id="top">
|
<div className="ds-root" id="top">
|
||||||
<a className="ds-skip-link" href="#ds-main" onClick={(event) => {
|
<a className="ds-skip-link" href="#ds-main" onClick={(event) => {
|
||||||
@@ -82,18 +39,13 @@ export function DesignSystem() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="ds-eyebrow">MINABOT — INTERFACE LANGUAGE</p>
|
<p className="ds-eyebrow">MINABOT — INTERFACE LANGUAGE</p>
|
||||||
<h1 id="ds-title">Design system<span aria-hidden="true">.</span></h1>
|
<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>
|
</div>
|
||||||
<span className="ds-library-note">Interactive examples · nothing is saved</span>
|
<span className="ds-library-note">Interactive examples · nothing is saved</span>
|
||||||
</header>
|
</header>
|
||||||
<DesignSystemTabs tabs={[
|
<DesignSystemTabs tabs={[
|
||||||
{ id: "foundations", label: "Foundations", content: (
|
{ id: "foundations", label: "Foundations", content: (
|
||||||
<section className="ds-section ds-split-section" id="foundations">
|
<section className="ds-section ds-split-section" id="foundations">
|
||||||
<SectionHeading number="FOUNDATIONS" title="The essentials.">
|
<SectionHeading number="FOUNDATIONS" title="The essentials." />
|
||||||
A small vocabulary.
|
|
||||||
<br />
|
|
||||||
Used with intention.
|
|
||||||
</SectionHeading>
|
|
||||||
<div className="ds-section-content">
|
<div className="ds-section-content">
|
||||||
<div className="ds-spec-heading">
|
<div className="ds-spec-heading">
|
||||||
<h3>Typography</h3>
|
<h3>Typography</h3>
|
||||||
@@ -117,8 +69,10 @@ export function DesignSystem() {
|
|||||||
{ name: "Component title", className: "type-title", spec: "32/40px" },
|
{ name: "Component title", className: "type-title", spec: "32/40px" },
|
||||||
{ name: "Lead text", className: "type-lead", spec: "20/30px" },
|
{ name: "Lead text", className: "type-lead", spec: "20/30px" },
|
||||||
{ name: "Body & actions", className: "type-body", spec: "16/24px" },
|
{ 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: "Supporting text", className: "type-small", spec: "14/21px" },
|
||||||
{ name: "SECTION LABEL", className: "type-label", spec: "14/21px · medium weight" },
|
{ 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" },
|
{ name: "− / +", className: "type-control", spec: "24/30px · counter symbols" },
|
||||||
].map((type) => <li key={type.className}>
|
].map((type) => <li key={type.className}>
|
||||||
<span className={type.className}>{type.name}</span>
|
<span className={type.className}>{type.name}</span>
|
||||||
@@ -132,23 +86,27 @@ export function DesignSystem() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ds-palette">
|
<div className="ds-palette">
|
||||||
{[
|
{[
|
||||||
{ name: "Ink", value: "#111111" },
|
{ name: "Ink", value: "#111111", token: "ink" },
|
||||||
{ name: "Secondary", value: "#666666" },
|
{ name: "Secondary", value: "#666666", token: "secondary" },
|
||||||
{ name: "Rule", value: "#DEDEDE" },
|
{ name: "Control border", value: "#767676", token: "control-border" },
|
||||||
{ name: "Paper", value: "#FFFFFF" },
|
{ 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) => (
|
].map((color) => (
|
||||||
<div key={color.name}>
|
<div key={color.name}>
|
||||||
<div
|
<div
|
||||||
className="ds-swatch"
|
className="ds-swatch"
|
||||||
style={{ background: color.value }}
|
style={{ background: `var(--ds-${color.token})` }}
|
||||||
/>
|
/>
|
||||||
<p>
|
<p>
|
||||||
{color.name}
|
{color.name}
|
||||||
<span className="ds-code">{color.value}</span>
|
<span className="ds-code">{color.value} · --ds-{color.token}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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">
|
<div className="ds-spec-heading ds-spec-heading--spaced">
|
||||||
<h3>Habit colors</h3>
|
<h3>Habit colors</h3>
|
||||||
<span className="ds-code">Identity, not decoration</span>
|
<span className="ds-code">Identity, not decoration</span>
|
||||||
@@ -182,11 +140,7 @@ export function DesignSystem() {
|
|||||||
) },
|
) },
|
||||||
{ id: "components", label: "Components", content: (
|
{ id: "components", label: "Components", content: (
|
||||||
<section className="ds-section ds-split-section" id="components">
|
<section className="ds-section ds-split-section" id="components">
|
||||||
<SectionHeading number="COMPONENTS" title="Only what matters.">
|
<SectionHeading number="COMPONENTS" title="Only what matters." />
|
||||||
Purpose-built primitives.
|
|
||||||
<br />
|
|
||||||
Try them for yourself.
|
|
||||||
</SectionHeading>
|
|
||||||
<div className="ds-section-content">
|
<div className="ds-section-content">
|
||||||
<div className="ds-spec-heading">
|
<div className="ds-spec-heading">
|
||||||
<h3>Actions</h3>
|
<h3>Actions</h3>
|
||||||
@@ -214,9 +168,8 @@ export function DesignSystem() {
|
|||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setView("Habit detail");
|
navigate({ hash: "#calendar-states" });
|
||||||
navigate({ hash: "#playground" });
|
document.getElementById("ds-tab-calendar-states")?.focus();
|
||||||
document.getElementById("ds-tab-playground")?.focus();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
View habit <span aria-hidden="true">↗</span>
|
View habit <span aria-hidden="true">↗</span>
|
||||||
@@ -254,34 +207,40 @@ export function DesignSystem() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="ds-spec-heading">
|
<div className="ds-spec-heading">
|
||||||
<label htmlFor="habit-name">Give it a name</label>
|
<h3>Give it a name</h3>
|
||||||
<span className="ds-code">Text input</span>
|
<span className="ds-code">Field · text input</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ds-input-row">
|
<Field
|
||||||
<input
|
id="habit-name"
|
||||||
id="habit-name"
|
label="Habit name"
|
||||||
required
|
hint="This previews a name; it does not create a habit."
|
||||||
maxLength={80}
|
>
|
||||||
placeholder="e.g. Read ten pages"
|
{(id, describedBy) => (
|
||||||
value={habitName}
|
<div className="ds-input-row">
|
||||||
onChange={(event) => {
|
<input
|
||||||
setHabitName(event.target.value);
|
id={id}
|
||||||
setSavedName("");
|
required
|
||||||
}}
|
maxLength={80}
|
||||||
aria-describedby="name-hint"
|
placeholder="e.g. Read ten pages"
|
||||||
/>
|
value={habitName}
|
||||||
<Button
|
onChange={(event) => {
|
||||||
type="submit"
|
setHabitName(event.target.value);
|
||||||
variant="secondary"
|
setSavedName("");
|
||||||
disabled={!habitName.trim()}
|
}}
|
||||||
>
|
aria-describedby={describedBy}
|
||||||
Try it <span aria-hidden="true">↗</span>
|
/>
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
type="submit"
|
||||||
<p id="name-hint" className="ds-footnote" role="status">
|
variant="secondary"
|
||||||
{savedName
|
disabled={!habitName.trim()}
|
||||||
? `“${savedName}” — preview accepted. No habit was created.`
|
>
|
||||||
: "Keep it short and personal. This is a form preview, not a new habit."}
|
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>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,15 +250,12 @@ export function DesignSystem() {
|
|||||||
<ContainerShowcase />
|
<ContainerShowcase />
|
||||||
) },
|
) },
|
||||||
{ id: "calendar-states", label: "Calendars", content: (
|
{ id: "calendar-states", label: "Calendars", content: (
|
||||||
|
<>
|
||||||
<section className="ds-section ds-split-section" id="calendar-states">
|
<section className="ds-section ds-split-section" id="calendar-states">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
number="PROGRESS LANGUAGE"
|
number="PROGRESS LANGUAGE"
|
||||||
title="Every shade counts."
|
title="Every shade counts."
|
||||||
>
|
/>
|
||||||
Progress is a spectrum.
|
|
||||||
<br />
|
|
||||||
Completion is a clear rule.
|
|
||||||
</SectionHeading>
|
|
||||||
<div className="ds-section-content">
|
<div className="ds-section-content">
|
||||||
<div className="ds-spec-heading">
|
<div className="ds-spec-heading">
|
||||||
<h3>One glass at a time</h3>
|
<h3>One glass at a time</h3>
|
||||||
@@ -340,258 +296,8 @@ export function DesignSystem() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) },
|
<CalendarExamples />
|
||||||
{ 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>
|
|
||||||
) },
|
) },
|
||||||
]} />
|
]} />
|
||||||
<footer className="ds-footer">
|
<footer className="ds-footer">
|
||||||
|
|||||||
@@ -17,18 +17,19 @@
|
|||||||
--ds-ink: #111;
|
--ds-ink: #111;
|
||||||
--ds-secondary: #666;
|
--ds-secondary: #666;
|
||||||
--ds-rule: #dedede;
|
--ds-rule: #dedede;
|
||||||
|
--ds-control-border: #767676;
|
||||||
--ds-paper: #fff;
|
--ds-paper: #fff;
|
||||||
|
--ds-surface: #f5f5f5;
|
||||||
|
--ds-empty: #eee;
|
||||||
--ds-serif: "Instrument Serif", Georgia, serif;
|
--ds-serif: "Instrument Serif", Georgia, serif;
|
||||||
|
--ds-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: -8px;
|
margin: -8px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: var(--ds-ink);
|
color: var(--ds-ink);
|
||||||
background: var(--ds-paper);
|
background: var(--ds-paper);
|
||||||
@apply type-body;
|
@apply type-body;
|
||||||
font-family: -apple-system,
|
font-family: var(--ds-sans);
|
||||||
BlinkMacSystemFont,
|
|
||||||
"Segoe UI",
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
.ds-root *,
|
.ds-root *,
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
}
|
}
|
||||||
.ds-eyebrow {
|
.ds-eyebrow {
|
||||||
@apply type-eyebrow;
|
@apply type-eyebrow;
|
||||||
color: #737373;
|
color: var(--ds-secondary);
|
||||||
}
|
}
|
||||||
.ds-header {
|
.ds-header {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
@@ -137,10 +138,10 @@
|
|||||||
.ds-library-note { max-width: 180px; text-align: right; }
|
.ds-library-note { max-width: 180px; text-align: right; }
|
||||||
.ds-tab-bar {
|
.ds-tab-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
background: #f5f5f5;
|
background: var(--ds-surface);
|
||||||
}
|
}
|
||||||
.ds-tab-bar [role="tab"] {
|
.ds-tab-bar [role="tab"] {
|
||||||
@apply type-body;
|
@apply type-body;
|
||||||
@@ -149,7 +150,7 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
padding: 10px 8px;
|
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"] {
|
.ds-tab-bar [aria-selected="true"] {
|
||||||
background: var(--ds-paper);
|
background: var(--ds-paper);
|
||||||
color: var(--ds-ink);
|
color: var(--ds-ink);
|
||||||
@@ -326,7 +327,7 @@
|
|||||||
color: var(--ds-paper);
|
color: var(--ds-paper);
|
||||||
}
|
}
|
||||||
.ds-button:hover:not(:disabled) {
|
.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-icon { width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
|
||||||
.ds-button--secondary {
|
.ds-button--secondary {
|
||||||
@@ -337,21 +338,21 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--ds-ink);
|
color: var(--ds-ink);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
padding-left: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
}
|
||||||
.ds-button--secondary:hover:not(:disabled),
|
.ds-button--secondary:hover:not(:disabled),
|
||||||
.ds-button--text:hover:not(:disabled) {
|
.ds-button--text:hover:not(:disabled) {
|
||||||
background: #f3f3f3;
|
background: var(--ds-surface);
|
||||||
}
|
}
|
||||||
.ds-counter {
|
.ds-counter {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ds-counter .ds-button {
|
.ds-counter .ds-button {
|
||||||
width: 36px;
|
width: 44px;
|
||||||
min-height: 40px;
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
@apply type-control;
|
@apply type-control;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -365,7 +366,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
min-height: 36px;
|
min-height: 44px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@apply type-small;
|
@apply type-small;
|
||||||
}
|
}
|
||||||
@@ -375,8 +376,8 @@
|
|||||||
width: 17px;
|
width: 17px;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
flex: 0 0 17px;
|
flex: 0 0 17px;
|
||||||
border: 1px solid #777;
|
border: 1px solid var(--ds-control-border);
|
||||||
background: #fff;
|
background: var(--ds-paper);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -389,8 +390,8 @@
|
|||||||
content: "";
|
content: "";
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-left: 1px solid #fff;
|
border-left: 1px solid var(--ds-paper);
|
||||||
border-bottom: 1px solid #fff;
|
border-bottom: 1px solid var(--ds-paper);
|
||||||
transform: rotate(-45deg) translateY(-1px);
|
transform: rotate(-45deg) translateY(-1px);
|
||||||
}
|
}
|
||||||
.ds-checkbox input:disabled {
|
.ds-checkbox input:disabled {
|
||||||
@@ -399,6 +400,8 @@
|
|||||||
}
|
}
|
||||||
.ds-checkbox--icon {
|
.ds-checkbox--icon {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
min-width: 44px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.ds-checkbox--icon span {
|
.ds-checkbox--icon span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -449,12 +452,12 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.ds-card--soft { background: #f5f5f5; }
|
.ds-card--soft { background: var(--ds-surface); }
|
||||||
.ds-card--outlined {
|
.ds-card--outlined {
|
||||||
background: var(--ds-paper);
|
background: var(--ds-paper);
|
||||||
border-color: var(--ds-rule);
|
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-header .ds-eyebrow { margin-bottom: 12px; }
|
||||||
.ds-card-body { display: grid; gap: 20px; }
|
.ds-card-body { display: grid; gap: 20px; }
|
||||||
.ds-card-body > p { color: var(--ds-secondary); }
|
.ds-card-body > p { color: var(--ds-secondary); }
|
||||||
@@ -505,7 +508,7 @@
|
|||||||
}
|
}
|
||||||
.ds-spec-heading h3,
|
.ds-spec-heading h3,
|
||||||
.ds-spec-heading label {
|
.ds-spec-heading label {
|
||||||
@apply type-small;
|
@apply type-ui-heading;
|
||||||
}
|
}
|
||||||
.ds-spec-heading--spaced {
|
.ds-spec-heading--spaced {
|
||||||
margin-top: 36px;
|
margin-top: 36px;
|
||||||
@@ -533,7 +536,7 @@
|
|||||||
}
|
}
|
||||||
.ds-palette {
|
.ds-palette {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.ds-swatch {
|
.ds-swatch {
|
||||||
@@ -550,9 +553,11 @@
|
|||||||
display: block;
|
display: block;
|
||||||
@apply type-small;
|
@apply type-small;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.ds-spacing {
|
.ds-spacing {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
}
|
}
|
||||||
@@ -579,13 +584,14 @@
|
|||||||
border-top: 1px solid var(--ds-rule);
|
border-top: 1px solid var(--ds-rule);
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
.ds-component-row h3 {
|
.ds-component-row h3 {
|
||||||
@apply type-small;
|
@apply type-ui-heading;
|
||||||
margin-bottom: 3px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.ds-example-form {
|
.ds-example-form {
|
||||||
border-top: 1px solid var(--ds-rule);
|
border-top: 1px solid var(--ds-rule);
|
||||||
@@ -593,24 +599,19 @@
|
|||||||
}
|
}
|
||||||
.ds-input-row {
|
.ds-input-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.ds-input-row input {
|
.ds-input-row input {
|
||||||
flex: 1;
|
flex: 1 1 12rem;
|
||||||
min-width: 0;
|
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 {
|
.ds-input-row input::placeholder {
|
||||||
color: var(--ds-secondary);
|
color: var(--ds-secondary);
|
||||||
}
|
}
|
||||||
.ds-shade-scale {
|
.ds-shade-scale {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(9, 1fr);
|
grid-template-columns: repeat(9, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.ds-shade-scale > div {
|
.ds-shade-scale > div {
|
||||||
@@ -627,6 +628,8 @@
|
|||||||
}
|
}
|
||||||
.ds-scale-caption {
|
.ds-scale-caption {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@apply type-small;
|
@apply type-small;
|
||||||
color: var(--ds-secondary);
|
color: var(--ds-secondary);
|
||||||
@@ -645,12 +648,12 @@
|
|||||||
gap: 13px;
|
gap: 13px;
|
||||||
}
|
}
|
||||||
.ds-neutral-states h4 {
|
.ds-neutral-states h4 {
|
||||||
@apply type-small;
|
@apply type-ui-heading;
|
||||||
}
|
}
|
||||||
.ds-neutral-states p {
|
.ds-neutral-states p {
|
||||||
@apply type-small;
|
@apply type-small;
|
||||||
color: var(--ds-secondary);
|
color: var(--ds-secondary);
|
||||||
margin-top: 3px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
.ds-state-square {
|
.ds-state-square {
|
||||||
flex: 0 0 22px;
|
flex: 0 0 22px;
|
||||||
@@ -658,7 +661,7 @@
|
|||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
.ds-state-square--future {
|
.ds-state-square--future {
|
||||||
background: #eeeeee;
|
background: var(--ds-empty);
|
||||||
}
|
}
|
||||||
.ds-reference-grid {
|
.ds-reference-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -704,6 +707,8 @@
|
|||||||
}
|
}
|
||||||
.ds-detail-count {
|
.ds-detail-count {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px 24px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-top: 1px solid var(--ds-rule);
|
border-top: 1px solid var(--ds-rule);
|
||||||
@@ -723,10 +728,28 @@
|
|||||||
gap: 26px;
|
gap: 26px;
|
||||||
padding: 4px 0 22px;
|
padding: 4px 0 22px;
|
||||||
}
|
}
|
||||||
.ds-calendar-scroll {
|
.ds-calendar {
|
||||||
overflow-x: auto;
|
min-width: 0;
|
||||||
padding: 5px;
|
container-type: inline-size;
|
||||||
margin: -5px;
|
--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 {
|
.ds-calendar-range-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -742,11 +765,29 @@
|
|||||||
}
|
}
|
||||||
.ds-month-views {
|
.ds-month-views {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
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 {
|
.ds-month-views button {
|
||||||
min-height: 32px;
|
min-height: 44px;
|
||||||
min-width: 32px;
|
min-width: 44px;
|
||||||
padding: 5px 7px;
|
padding: 5px 7px;
|
||||||
color: var(--ds-secondary);
|
color: var(--ds-secondary);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -756,32 +797,25 @@
|
|||||||
}
|
}
|
||||||
.ds-month-views button:hover {
|
.ds-month-views button:hover {
|
||||||
color: var(--ds-ink);
|
color: var(--ds-ink);
|
||||||
background: #f3f3f3;
|
background: var(--ds-surface);
|
||||||
}
|
}
|
||||||
.ds-month-views button[aria-pressed="true"] {
|
.ds-month-views button[aria-pressed="true"] {
|
||||||
color: var(--ds-ink);
|
color: var(--ds-ink);
|
||||||
border-bottom-color: var(--ds-ink);
|
border-bottom-color: var(--ds-ink);
|
||||||
}
|
}
|
||||||
.ds-calendar-inner {
|
.ds-calendar-month-label {
|
||||||
min-width: 600px;
|
min-width: 0;
|
||||||
}
|
|
||||||
.ds-calendar-months {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(26, 1fr);
|
|
||||||
margin-left: 38px;
|
|
||||||
@apply type-small;
|
@apply type-small;
|
||||||
color: var(--ds-secondary);
|
color: var(--ds-secondary);
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
.ds-calendar-body {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
.ds-calendar-weekdays {
|
.ds-calendar-weekdays {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: repeat(7, 1fr);
|
grid-column: 1;
|
||||||
width: 36px;
|
grid-template-rows: repeat(7, minmax(0, 1fr));
|
||||||
flex-shrink: 0;
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
contain: size;
|
||||||
|
gap: var(--calendar-gap);
|
||||||
@apply type-small;
|
@apply type-small;
|
||||||
color: var(--ds-secondary);
|
color: var(--ds-secondary);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -797,18 +831,29 @@
|
|||||||
}
|
}
|
||||||
.ds-calendar-grid {
|
.ds-calendar-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-template-columns: repeat(var(--calendar-weeks), minmax(0, 1fr));
|
||||||
grid-template-rows: repeat(7, 1fr);
|
grid-template-rows: repeat(7, 1fr);
|
||||||
grid-auto-flow: column;
|
min-width: 0;
|
||||||
gap: 6px;
|
gap: var(--calendar-gap);
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
.ds-day {
|
.ds-day {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
width: 100%;
|
||||||
min-width: 0;
|
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-day--not-due,
|
||||||
.ds-legend-swatch--not-due,
|
.ds-legend-swatch--not-due,
|
||||||
.ds-state-square--not-due {
|
.ds-state-square--not-due {
|
||||||
@@ -825,7 +870,7 @@
|
|||||||
width: 3px;
|
width: 3px;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #888;
|
background: var(--ds-secondary);
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -890,7 +935,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 24px;
|
left: 24px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: white;
|
background: var(--ds-paper);
|
||||||
transform: translateY(-110%);
|
transform: translateY(-110%);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
@@ -957,7 +1002,7 @@
|
|||||||
gap: 30px;
|
gap: 30px;
|
||||||
}
|
}
|
||||||
.ds-section-heading p br {
|
.ds-section-heading p br {
|
||||||
display: none;
|
display: initial;
|
||||||
}
|
}
|
||||||
.ds-view-switch {
|
.ds-view-switch {
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -980,7 +1025,7 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.ds-counter .ds-button {
|
.ds-counter .ds-button {
|
||||||
width: 32px;
|
width: 44px;
|
||||||
}
|
}
|
||||||
.ds-task-count {
|
.ds-task-count {
|
||||||
min-width: 106px;
|
min-width: 106px;
|
||||||
@@ -992,6 +1037,7 @@
|
|||||||
gap: 3px 20px;
|
gap: 3px 20px;
|
||||||
}
|
}
|
||||||
.ds-palette {
|
.ds-palette {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 13px;
|
gap: 13px;
|
||||||
}
|
}
|
||||||
.ds-swatch {
|
.ds-swatch {
|
||||||
@@ -1049,14 +1095,15 @@
|
|||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
.ds-habit-chart:nth-child(n + 3),
|
.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);
|
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;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.ds-habit-chart-heading {
|
.ds-habit-chart-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1086,7 +1133,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.ds-habit-chart-heading .ds-counter .ds-button {
|
.ds-habit-chart-heading .ds-counter .ds-button {
|
||||||
width: 28px;
|
width: 44px;
|
||||||
}
|
}
|
||||||
.ds-habit-chart-progress {
|
.ds-habit-chart-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1099,12 +1146,6 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
margin-top: -12px !important;
|
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 {
|
.ds-calendar--compact .ds-date-inspector {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -1168,6 +1209,10 @@
|
|||||||
height: 10px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.ds-split-section {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
.ds-habit-chart + .ds-habit-chart {
|
.ds-habit-chart + .ds-habit-chart {
|
||||||
border-top: 1px solid var(--ds-rule);
|
border-top: 1px solid var(--ds-rule);
|
||||||
}
|
}
|
||||||
@@ -1175,43 +1220,30 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
}
|
}
|
||||||
.ds-calendar--compact .ds-calendar-inner {
|
|
||||||
max-width: 520px;
|
|
||||||
}
|
|
||||||
.ds-habit-chart-heading .ds-counter {
|
.ds-habit-chart-heading .ds-counter {
|
||||||
gap: 8px;
|
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; }
|
.ds-legend-scale { flex-wrap: wrap; }
|
||||||
|
|
||||||
/* Editing patterns use the same open layout, rules, and neutral controls. */
|
/* 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 { 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-picker > .ds-footnote { margin-top: 0 !important; }
|
||||||
.ds-color-palettes { display: flex; flex-wrap: wrap; gap: 20px 28px; margin: 20px 0; }
|
.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-palette > span { @apply type-small; color: var(--ds-secondary); }
|
||||||
.ds-color-swatches { display: flex; gap: 4px; margin-top: 8px; }
|
.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[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-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; gap: 16px; align-items: start; }
|
.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 { margin-bottom: 0; }
|
||||||
.ds-custom-color .ds-field:last-child { width: 180px; }
|
.ds-custom-color .ds-field:last-child { width: 180px; }
|
||||||
.ds-custom-color input[type="color"] { width: 80px; height: 44px; padding: 5px; cursor: pointer; }
|
.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-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 { 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 {
|
.ds-edit-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
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-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-habit-dot { width: 8px; height: 8px; flex: 0 0 8px; }
|
||||||
.ds-field { display: grid; min-width: 0; gap: 8px; margin-bottom: 22px; }
|
.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) {
|
.ds-field :is(input, select) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border: 1px solid #999;
|
border: 1px solid var(--ds-control-border);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@apply type-body;
|
@apply type-body;
|
||||||
@@ -1249,13 +1281,14 @@
|
|||||||
.ds-edit-content .ds-checkbox { @apply type-small; }
|
.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-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 { 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 { 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: white; background: var(--ds-ink); border-color: var(--ds-ink); }
|
.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-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 { 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-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-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-edit-task .ds-spec-heading { margin-bottom: 16px; }
|
||||||
.ds-empty-state { padding: 24px 0; color: var(--ds-secondary); }
|
.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; }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.ds-header,
|
.ds-header,
|
||||||
.ds-preview-toolbar { flex-wrap: wrap; }
|
.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-copy--line-height: 1.5;
|
||||||
--text-caption: 0.875rem;
|
--text-caption: 0.875rem;
|
||||||
--text-caption--line-height: 1.5;
|
--text-caption--line-height: 1.5;
|
||||||
|
--text-ui-heading: 1.125rem;
|
||||||
|
--text-ui-heading--line-height: 1.5;
|
||||||
--text-lead: 1.25rem;
|
--text-lead: 1.25rem;
|
||||||
--text-lead--line-height: 1.5;
|
--text-lead--line-height: 1.5;
|
||||||
--text-title: 2rem;
|
--text-title: 2rem;
|
||||||
@@ -17,6 +19,12 @@
|
|||||||
|
|
||||||
@utility type-body { @apply text-copy; }
|
@utility type-body { @apply text-copy; }
|
||||||
@utility type-small { @apply text-caption; }
|
@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 {
|
@utility type-label {
|
||||||
@apply text-caption;
|
@apply text-caption;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { readFileSync } from "node:fs";
|
|||||||
|
|
||||||
const css = (name: string) => readFileSync(new URL(name, import.meta.url), "utf8");
|
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", () => {
|
describe("shared typography contract", () => {
|
||||||
test("all component and page styles use the fixed shared type utilities", () => {
|
test("all component and page styles use the fixed shared type utilities", () => {
|
||||||
for (const file of ["design-system.css"]) {
|
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).not.toMatch(/clamp\(|\d(?:vw|vh|px)\b/);
|
||||||
expect(typography).toContain("--text-copy: 1rem");
|
expect(typography).toContain("--text-copy: 1rem");
|
||||||
expect(typography).toContain("--text-caption: 0.875rem");
|
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}`);
|
expect(typography).toContain(`@utility type-${role}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("text colors meet WCAG AA normal-text contrast on paper", () => {
|
test("text colors meet WCAG AA normal-text contrast on paper", () => {
|
||||||
const source = css("design-system.css");
|
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"]) {
|
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