diff --git a/README.md b/README.md
index 2c8d40b..daa2be4 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Minabot
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The existing
-React Router client remains a minimal authentication shell; habit frontend UI is deferred.
+React Router client has a public landing page and a connected signed-in habit dashboard.
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
combined calendars, historical corrections, daily resets, and optional count carryover.
@@ -17,7 +17,50 @@ Open http://127.0.0.1:3000. Set `PORT` to use a different port.
- Hono endpoint: `GET /api/health`.
- Unknown API routes return JSON with a 404 status.
-Pages contain a heading, native navigation buttons, and account controls. Tailwind utilities
+### Design system preview
+
+Open `/design-system#editing` for interactive habit settings, task recurrence,
+and dated progress corrections. Habit colors include Earth, Coast, Dusk, Forest,
+Citrus, Blossom, Jewel, and Slate palettes plus a native picker and hex input. Editors support save/cancel,
+archive/restore, and an in-session correction history. The backfill calendar
+uses frozen historical requirements, independent of edits to today's settings.
+These are unsaved, local UI examples with a fixed September 4, 2026 demo clock;
+they do not call the habit API or change account data. Reload or Reset editor
+clears the preview. Habit colors preview immediately across the editor and the
+daily/detail charts; Save keeps the color in the demo and Cancel restores it.
+Other settings and progress remain independent of the daily tracking playground.
+
+Signed-out visitors at `/` see the landing page, with interactive habit calendars
+reused from the design system, a how-it-works introduction, and Discord sign-in.
+The demo is local, unsaved state and never writes to the habit API. Account loading
+and retryable errors are resolved before choosing the public or signed-in home.
+Signing out updates the shared account state and reveals the landing page immediately.
+Discord integration currently covers identity only, not bot commands or reminders.
+
+Signed-in users at `/` get a personalized home with their Discord avatar, tracking
+date/timezone, daily completion summary, manual/count/task check-ins, a remaining
+filter, and per-habit calendar history in the design system's two-column chart grid.
+Each habit has its own heading, schedule, color key, and compact calendar. Daily
+check-ins use compact rows with expandable tasks. New accounts get editable starter ideas and
+a create-habit dialog supporting all three methods, all schedule types, and the shared
+palette/custom color picker. The habit and its color are saved atomically. Progress
+is saved through the habit API; history uses server-provided progress colors and dated units,
+never the landing page's demo data. Visible dashboards refresh every minute and on
+returning to the tab. Loading, retry, expired-session, no-schedule, and all-complete
+states are handled separately. Each habit has Edit and Delete controls: edit its name,
+count target/unit, schedule, and color, or confirm removal from the dashboard using
+the existing archive API, which retains recorded history. Editing keeps the tracking
+method and existing tasks intact. Unscheduled dates use neutral dots; upcoming dates
+share the zero-progress fill while retaining their distinct inspection labels.
+Archive management, task-definition editing, historical corrections, and saved
+combined-chart management remain outside this home view.
+
+For isolated browser QA, run `bun scripts/dashboard-preview.ts` and open the printed
+preview login URL. It serves the real UI and API against an in-memory test database,
+uses a separate test cookie, and discards its data on exit.
+It is a local test harness, not a production sign-in flow.
+
+Secondary pages retain a heading, native navigation buttons, and account controls. Tailwind utilities
and shadcn configuration are available; Tailwind Preflight is omitted to preserve
native browser styling.
diff --git a/bun-env.d.ts b/bun-env.d.ts
index 3111ba5..6d4416c 100644
--- a/bun-env.d.ts
+++ b/bun-env.d.ts
@@ -9,6 +9,10 @@ declare module "*.svg" {
}
declare module "*.css" {}
+declare module "*.png" {
+ const path: string;
+ export default path;
+}
declare module "*.module.css" {
/**
diff --git a/bun.lock b/bun.lock
index 26ca54d..1b73201 100644
--- a/bun.lock
+++ b/bun.lock
@@ -24,6 +24,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
+ "happy-dom": "^20.14.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2",
},
@@ -160,6 +161,10 @@
"@types/react-dom": ["@types/react-dom@19.2.7", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ=="],
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
@@ -202,6 +207,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
+ "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
+
"bun": ["bun@1.4.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.0", "@oven/bun-darwin-x64": "1.4.0", "@oven/bun-freebsd-aarch64": "1.4.0", "@oven/bun-freebsd-x64": "1.4.0", "@oven/bun-linux-aarch64": "1.4.0", "@oven/bun-linux-aarch64-android": "1.4.0", "@oven/bun-linux-aarch64-musl": "1.4.0", "@oven/bun-linux-x64": "1.4.0", "@oven/bun-linux-x64-android": "1.4.0", "@oven/bun-linux-x64-musl": "1.4.0", "@oven/bun-windows-aarch64": "1.4.0", "@oven/bun-windows-x64": "1.4.0" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
@@ -240,12 +247,16 @@
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
+ "happy-dom": ["happy-dom@20.14.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ=="],
+
"hono": ["hono@4.13.5", "", {}, "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
@@ -284,6 +295,10 @@
"use-debounce": ["use-debounce@10.1.1", "", { "peerDependencies": { "react": "*" } }, "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ=="],
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
+ "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
+
"zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
diff --git a/docs/API.md b/docs/API.md
index 8232faf..a245d3e 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -52,6 +52,7 @@ Count habit, with daily resets by default:
```json
{
"name": "Hydration",
+ "color": "#426582",
"method": "count",
"target": 8,
"unit": "glasses",
@@ -86,6 +87,8 @@ A habit has exactly one method. Incompatible settings are rejected. A switch to
Names are trimmed, nonempty and at most 200 characters. Count targets are integers from 1 to 10,000. Logged counts are nonnegative integers up to 1,000,000,000 and can exceed the target; completion intensity caps at full. Units default to `steps` and are at most 80 characters. A task habit supports at most 100 task definitions.
+Creation and habit PATCH accept an optional six-digit hex `color` for all three methods. It is saved atomically as the habit calendar's `mainColor`, separately from dated requirements. Omitting it preserves the default on creation or the current color on edit. Habit PATCH preserves all other calendar settings; the calendar-settings endpoint remains available for replacing the complete settings object.
+
## Schedules
Omitted habit or task schedules default to daily. Both rules must match for a task occurrence to exist.
diff --git a/docs/design/imagegen-prompts.md b/docs/design/imagegen-prompts.md
new file mode 100644
index 0000000..a583bb7
--- /dev/null
+++ b/docs/design/imagegen-prompts.md
@@ -0,0 +1,45 @@
+# Minabot visual studies
+
+Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are visual references, not data-accurate calendar renders. Two independent generations; no input images. The no-card, monochromatic editorial layouts informed the scoped design-system stylesheet and reusable components. Fonts are bundled locally under src/assets/fonts with their OFL license.
+
+## Today
+
+Saved asset: `src/assets/design/today.png`
+
+Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
+
+## Habit detail
+
+Saved asset: `src/assets/design/habit-detail.png`
+
+Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Individual habit detail view. Breadcrumb "Habits / Drink water". Large Instrument Serif heading "Drink water" and small subtitle "Eight glasses. One day at a time." Right aligned text action "Edit habit". Open ruled section with label "Friday, September 4", huge serif "7 / 8" with unit "glasses", minus and plus buttons, text "In progress · 1 glass to go". Next open section "Progress" with GitHub-style 7-row square-cell calendar across Jan–Sep 2026. 8 distinct positive gray steps plus empty due state; not scheduled cells are outlined, future cells faint. Below calendar an inline selected-date inspector "September 4, 2026 · 7 of 8 glasses · Incomplete" and explanatory caption "Partial progress is visible here. This habit counts in a combined chart only at 8 of 8." Bottom thin-rule section "Schedule" with "Every day" and "Changes apply from today. Earlier requirements stay the same." NO cards or boxed panels. Crisp elegant readable interface.
+
+## Interactive implementation
+
+Visit `/design-system`. All examples use local React state and deterministic fixture dates ending September 5, 2026 (demo today is September 4). They do not call the habit APIs or persist user data. The three preview modes share count, checkbox, task, and chart-membership state. Counts are capped at their target for this demo; production over-target policy remains a separate decision. Combined-chart membership filters the illustrative history in the demo only, without defining production membership-history behavior.
+
+Reusable primitives: `Button`, `Checkbox`, `Counter`, `SectionHeading`, `HabitRow`, and `CalendarHeatmap` in `src/components/design-system`. Calendar days provide exact values with keyboard navigation (arrows, Home, End), selected-date inspection, eight positive grayscale steps for target eight, solid-outline not-due dates, and dashed-outline future dates. The existing Nivo/server calendar adapter is unchanged.
+
+Styling is scoped to `.ds-root`; existing application routes retain their styling. No authentication is required to inspect the design system.
+
+## Today revision 2
+
+### Month-range controls
+
+Small-text refinement: the final minimum caption/metadata scale is 12px, with supporting labels at 14px. This covers eyebrows, calendar labels and legends, status copy, specimens, and mobile overrides without changing editorial headings. Legends and narrow-screen toolbars wrap as needed; browser checks confirm no document or legend overflow at 480px.
+
+Chart legends: every live calendar includes a compact color-matched legend directly beneath its cells. It shows the shared zero/upcoming fill, attainable progress shades through completion, and an outlined not-scheduled state. Manual habits show only empty/complete; target-eight habits show all eight positive shades. The shared `CalendarLegend` also appears in detail and combined previews and wraps on narrow screens. Verified in-browser with no legend overflow at 480px; typecheck, build, and all 78 tests pass.
+
+Empty-state visual refinement: upcoming dates and due dates with zero progress now share the same `#eeeeee` fill and transparent border. Upcoming dates no longer have dashed outlines. Their inspection text and accessible labels still say Upcoming, and not-due dates retain their distinct outline. Browser computed styles were compared across all four habit charts; their zero and upcoming fills and borders match exactly.
+
+Annual-layout refinement: selecting 12 months now makes that habit chart span the entire Today grid row. Its calendar uses the available width without a minimum-width overflow or horizontal scrollbar; other ranges retain the two-column layout. Returning to a shorter range restores its normal width without changing progress. Verified annual scrollWidth equals clientWidth at desktop and a 480px narrow viewport, with all 365 dates rendered.
+
+Each live calendar now has its own 3 / 4 / 6 / 12-month selector, defaulting to 6 months. Ranges are whole calendar months including demo today (September 4, 2026): July–September, June–September, April–September, or October 2025–September 2026. Demo fixtures cover all 12 months and preserve values across range changes. Weekday padding aligns partial weeks correctly; remaining dates in September are future states. Long grids scroll within their chart and keep the selected day in view. A selected historical date outside a smaller range temporarily falls back to today and returns when the range expands. Presets are local demo UI state, not saved account preferences. Typecheck, build, and 77 tests pass; browser checks cover all presets, independent chart selections, keyboard activation, and preserved progress.
+
+Validation: `bun run typecheck`, `bun run build`, and all 75 tests pass. Browser checks confirmed four differently colored 182-day calendars, a two-column/two-row desktop grid, a single-column layout at a measured 480px viewport with no document overflow, count and task updates, keyboard accordion expansion/collapse, selected-day keyboard navigation, combined scoring, empty chart selection, local form feedback, and loaded image references. No browser warning/error logs were recorded. Browser viewport overrides were reset after testing.
+
+The updated Today preview uses four independent habit charts in a two-column, multi-row open grid (one column below 760px). Every chart has its own color. The task-based chart exposes today's checkboxes through a native keyboard-accessible details/summary accordion. The reusable `HabitChart` component composes `CalendarHeatmap` and logging controls. Each live chart shows 26 weeks of illustrative history; this deterministic calendar is authoritative over the image mockup's approximate grid geometry.
+
+New saved asset: `src/assets/design/today-v2.png`. Original `today.png` is preserved; the gallery now uses v2. Built-in imagegen prompt:
+
+Use case: ui-mockup. Asset type: high fidelity desktop Minabot habit tracker Today view. Ultra minimalistic flat white website. Instrument Serif regular and italic headings with small system sans labels. Thin horizontal rules, ample whitespace. NO cards, no outer borders around chart groups, no shadows, no rounded panels. Header serif wordmark "minabot", links "Today", "Habits", "Charts". Large title "A little, every day." Date "Friday, September 4". Below show FOUR INDIVIDUAL HABIT CALENDARS in a TWO COLUMN, TWO ROW GRID. Each habit owns its separate GitHub-style 7-row calendar with 13 weeks of square cells, Jun/Jul/Aug/Sep labels. Each calendar has ONE DISTINCT COLOR with intensity shades: Drink water muted blue, Read a little warm ochre, Evening reset muted violet, Move a little muted green. All remaining UI black and white. Top left: Drink water, "7 of 8 glasses", minus/plus logging, blue calendar. Top right: Read a little, "Complete", checked checkbox, ochre calendar. Bottom left: Evening reset, "2 of 3 tasks", violet calendar, accordion labeled "Tasks for today" expanded underneath revealing checked "Clear desk", checked "Plan tomorrow", unchecked "Stretch". Bottom right: Move a little, "10 of 20 minutes", minus/plus logging, green calendar. Separate chart groups with whitespace and top horizontal rules only; absolutely no card containers. Precise editorial elegant layout. At bottom small note "Each chart shows its own progress. Only fully completed habits count in combined charts." Render clean landscape screenshot, no browser frame.
diff --git a/package.json b/package.json
index a3a8beb..02363a4 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
+ "happy-dom": "^20.14.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2"
}
diff --git a/scripts/dashboard-preview.ts b/scripts/dashboard-preview.ts
new file mode 100644
index 0000000..d9a1d6b
--- /dev/null
+++ b/scripts/dashboard-preview.ts
@@ -0,0 +1,57 @@
+// Isolated browser QA server. Never opens or changes the user's database.
+import { fixture } from "../src/habits/test-fixture";
+import { createApi } from "../src/api";
+import index from "../src/index.html";
+
+const f = fixture();
+const origin = "http://127.0.0.1:3107";
+const previewCookie = "minabot_dashboard_preview_session";
+const app = createApi(
+ f.db,
+ {
+ origin,
+ clientId: "",
+ clientSecret: "",
+ cookieSecret: "preview-only-secret-at-least-32-characters",
+ },
+ undefined,
+ () => Date.parse("2026-09-04T12:00:00Z"),
+);
+const server = Bun.serve({
+ hostname: "127.0.0.1",
+ port: 3107,
+ routes: {
+ "/__preview/login": () =>
+ new Response(null, {
+ status: 302,
+ headers: {
+ Location: "/",
+ "Set-Cookie": `${previewCookie}=${"a".repeat(43)}; Path=/; HttpOnly; SameSite=Lax`,
+ },
+ }),
+ "/api/*": async (request) => {
+ // Translate only the preview cookie; never use or overwrite a real session.
+ const cookie = request.headers
+ .get("cookie")
+ ?.split(";")
+ .map((value) => value.trim())
+ .find((value) => value.startsWith(`${previewCookie}=`));
+ const headers = new Headers(request.headers);
+ headers.set(
+ "cookie",
+ cookie ? cookie.replace(previewCookie, "minabot_session") : "",
+ );
+ const response = await app.fetch(new Request(request, { headers }));
+ const setCookie = response.headers.get("set-cookie");
+ if (setCookie)
+ response.headers.set(
+ "set-cookie",
+ setCookie.replace("minabot_session", previewCookie),
+ );
+ return response;
+ },
+ "/*": index,
+ },
+ development: true,
+});
+console.log(`Disposable dashboard preview: ${server.url}__preview/login`);
diff --git a/src/App.test.tsx b/src/App.test.tsx
new file mode 100644
index 0000000..c689811
--- /dev/null
+++ b/src/App.test.tsx
@@ -0,0 +1,692 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ spyOn,
+ test,
+} from "bun:test";
+import { Window } from "happy-dom";
+import { act } from "react";
+import { MemoryRouter } from "react-router";
+import type { Root } from "react-dom/client";
+import { App } from "./App";
+import { fixture } from "./habits/test-fixture";
+import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap";
+
+// A simulated DOM keeps auth regression checks independent of Discord and the local database.
+const dom = new Window({ url: "http://localhost:3000/" });
+const originalGlobals = new Map();
+let createRoot: typeof import("react-dom/client").createRoot;
+let root: Root;
+let container: HTMLDivElement;
+let fetchMock: ReturnType>;
+const account = {
+ id: "test-user",
+ discordId: "123",
+ username: "demo",
+ displayName: "Demo User",
+ avatarUrl: null,
+ timezone: "UTC",
+};
+const emptyToday = {
+ date: "2026-09-04",
+ timezone: "UTC",
+ habits: [],
+ due: 0,
+ completed: 0,
+};
+
+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)[key],
+ });
+ }
+ ({ createRoot } = await import("react-dom/client"));
+});
+
+beforeEach(() => {
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ fetchMock = spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(null, { status: 401 }),
+ );
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ fetchMock.mockRestore();
+});
+
+afterAll(() => {
+ dom.happyDOM.abort();
+ for (const [key, descriptor] of originalGlobals) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else Reflect.deleteProperty(globalThis, key);
+ }
+});
+
+async function render(path = "/") {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+}
+
+async function click(label: string) {
+ const button = Array.from(container.querySelectorAll("button")).find(
+ (element) =>
+ element.textContent?.trim() === label ||
+ element.getAttribute("aria-label") === label,
+ );
+ expect(button).toBeDefined();
+ await act(async () => button!.click());
+}
+
+test("unscheduled dates stay inspectable without a progress-square fill", async () => {
+ await act(async () =>
+ root.render(
+ ,
+ ),
+ );
+ const restDay =
+ container.querySelector(".ds-day--not-due")!;
+ expect(restDay.style.backgroundColor).toBe("transparent");
+ expect(restDay.getAttribute("aria-label")).toContain(
+ "Not included in the score",
+ );
+ expect(
+ container.querySelector(".ds-day--due")!.style.backgroundColor,
+ ).toBe("#ebedf0");
+ expect(
+ container.querySelector(".ds-day--future")!.style
+ .backgroundColor,
+ ).toBe("#ebedf0");
+ expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
+ await act(async () => restDay.click());
+ expect(restDay.getAttribute("aria-pressed")).toBe("true");
+ expect(restDay.tabIndex).toBe(0);
+ expect(container.querySelector(".ds-date-inspector")?.textContent).toContain(
+ "Nothing scheduled",
+ );
+ await act(async () =>
+ restDay.dispatchEvent(
+ new dom.KeyboardEvent("keydown", {
+ key: "ArrowDown",
+ bubbles: true,
+ }) as unknown as KeyboardEvent,
+ ),
+ );
+ expect(
+ container.querySelector(".ds-day--due")?.getAttribute("aria-pressed"),
+ ).toBe("true");
+});
+
+describe("home route authentication", () => {
+ test("signed-out visitors see the landing page with Discord and demo content", async () => {
+ await render();
+ expect(container.querySelector("h1")?.textContent).toBe(
+ "A little today.A rhythm for life.",
+ );
+ expect(container.querySelector("#discord")?.textContent).toContain(
+ "Discord integration currently covers sign-in",
+ );
+ expect(container.querySelector("#demo")).not.toBeNull();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ test("an unresolved account request never flashes the landing page", async () => {
+ let resolve!: (response: Response) => void;
+ fetchMock.mockResolvedValue(Response.json(emptyToday)).mockReturnValueOnce(
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ await render();
+ expect(container.querySelector('[role="status"]')?.textContent).toBe(
+ "Loading your account…",
+ );
+ expect(container.querySelector("#landing-title")).toBeNull();
+ await act(async () => resolve(Response.json(account)));
+ expect(container.querySelector("h1")?.textContent).toBe(
+ "Welcome home,Demo User.",
+ );
+ expect(container.querySelector("#landing-title")).toBeNull();
+ });
+
+ test("signed-in users keep Home and signing out reveals the landing page", async () => {
+ fetchMock
+ .mockResolvedValueOnce(Response.json(account))
+ .mockResolvedValueOnce(Response.json(emptyToday))
+ .mockResolvedValueOnce(Response.json({ ok: true }));
+ await render();
+ expect(container.querySelector("h1")?.textContent).toBe(
+ "Welcome home,Demo User.",
+ );
+ expect(container.textContent).toContain("Demo User");
+ await click("Sign out");
+ expect(fetchMock).toHaveBeenLastCalledWith("/api/auth/logout", {
+ method: "POST",
+ });
+ expect(container.querySelector("#landing-title")).not.toBeNull();
+ });
+
+ test("logout failure keeps the signed-in view and shows an error", async () => {
+ fetchMock
+ .mockResolvedValueOnce(Response.json(account))
+ .mockResolvedValueOnce(Response.json(emptyToday))
+ .mockResolvedValueOnce(new Response(null, { status: 500 }));
+ await render();
+ await click("Sign out");
+ expect(container.querySelector("h1")?.textContent).toBe(
+ "Welcome home,Demo User.",
+ );
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
+ "Could not sign out",
+ );
+ });
+
+ test("an account error is not treated as signed out and can be retried", async () => {
+ fetchMock
+ .mockResolvedValueOnce(new Response(null, { status: 503 }))
+ .mockResolvedValueOnce(new Response(null, { status: 401 }));
+ await render();
+ expect(container.querySelector("#landing-title")).toBeNull();
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
+ "Could not load your account",
+ );
+ await click("Try again");
+ expect(container.querySelector("#landing-title")).not.toBeNull();
+ });
+
+ test("OAuth cancellation remains visible on the landing page", async () => {
+ await render("/?auth_error=denied");
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
+ "Discord sign-in was cancelled",
+ );
+ });
+
+ test("all sign-in calls to action use Discord OAuth with the browser timezone", async () => {
+ const assign = spyOn(dom.location, "assign").mockImplementation(() => {});
+ try {
+ await render();
+ const buttons = Array.from(container.querySelectorAll("button")).filter(
+ (button) => button.textContent?.startsWith("Sign in"),
+ );
+ expect(buttons).toHaveLength(3);
+ for (const button of buttons) await act(async () => button.click());
+ expect(assign).toHaveBeenCalledTimes(3);
+ for (const [destination] of assign.mock.calls) {
+ const url = new URL(destination, "http://localhost:3000");
+ expect(url.pathname).toBe("/api/auth/discord");
+ expect(url.searchParams.get("timezone")).toBe(
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
+ );
+ }
+ } finally {
+ assign.mockRestore();
+ }
+ });
+
+ test("other routes keep their shell and can navigate back to the public home", async () => {
+ await render("/about");
+ expect(container.querySelector("h1")?.textContent).toBe("About");
+ await click("Home");
+ expect(container.querySelector("#landing-title")).not.toBeNull();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ test("the design-system route stays public and independent of account loading", async () => {
+ await render("/design-system/");
+ expect(container.querySelector("#ds-title")).not.toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ test("the demo updates completion, switches views, and resets without API writes", async () => {
+ await render();
+ await click("Increase glasses of water");
+ expect(
+ container.querySelector(".ds-preview-heading")?.textContent,
+ ).toContain("1 of 2 habits complete");
+ expect(
+ container.querySelector(
+ '[aria-label="Increase glasses of water"]',
+ )?.disabled,
+ ).toBe(true);
+ await click("Combined progress");
+ expect(
+ container.querySelector('[aria-label="Combined habit calendar"]'),
+ ).not.toBeNull();
+ const reset = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("Reset demo"),
+ )!;
+ await act(async () => reset.click());
+ expect(
+ container.querySelector(".ds-preview-heading")?.textContent,
+ ).toContain("0 of 2 habits complete");
+ expect(
+ container.querySelector('[aria-label="Increase glasses of water"]'),
+ ).not.toBeNull();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("signed-in dashboard with the real habit API", () => {
+ let api: ReturnType;
+ beforeEach(() => {
+ api = fixture();
+ fetchMock.mockImplementation((async (
+ input: RequestInfo | URL,
+ options?: RequestInit,
+ ) => {
+ const path = String(input).replace(/^\/api/, "");
+ if (path === "/me") return Response.json(account);
+ return api.request(
+ path,
+ options?.method ?? "GET",
+ options?.body ? JSON.parse(String(options.body)) : undefined,
+ );
+ }) as typeof fetch);
+ });
+ afterEach(() => api.close());
+
+ async function input(selector: string, value: string) {
+ const element = container.querySelector(selector)!;
+ expect(element).not.toBeNull();
+ await act(async () => {
+ const prototype =
+ element.tagName === "SELECT"
+ ? dom.HTMLSelectElement.prototype
+ : element.tagName === "TEXTAREA"
+ ? dom.HTMLTextAreaElement.prototype
+ : dom.HTMLInputElement.prototype;
+ Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(
+ element,
+ value,
+ );
+ element.dispatchEvent(
+ new Event(element.tagName === "SELECT" ? "change" : "input", {
+ bubbles: true,
+ }),
+ );
+ });
+ }
+
+ test("empty accounts get useful starters without invented history", async () => {
+ await render();
+ expect(container.textContent).toContain(
+ "No habits yet. No catching up to do.",
+ );
+ expect(container.querySelectorAll(".home-starters > button")).toHaveLength(
+ 3,
+ );
+ expect(container.querySelector("#rhythm")).toBeNull();
+ expect(container.textContent).not.toContain("Demo history");
+ expect(container.textContent).toContain("Europe/Belgrade");
+ });
+
+ test("uses the Discord avatar and falls back only when it fails", async () => {
+ const avatarUrl = "https://cdn.discordapp.com/avatars/123/avatar.png";
+ fetchMock.mockResolvedValueOnce(Response.json({ ...account, avatarUrl }));
+ await render();
+ const avatar =
+ container.querySelector("img.home-avatar")!;
+ expect(avatar.src).toBe(avatarUrl);
+ expect(avatar.alt).toBe("Demo User’s Discord avatar");
+ await act(async () => avatar.dispatchEvent(new Event("error")));
+ expect(container.querySelector("img.home-avatar")).toBeNull();
+ expect(container.querySelector(".home-avatar")?.textContent).toBe("D");
+ });
+
+ test("a starter is editable and creates a persistent first habit", async () => {
+ await render();
+ await act(async () =>
+ container
+ .querySelector(".home-starters > button")!
+ .click(),
+ );
+ expect(
+ container.querySelector("#habit-name")!.value,
+ ).toBe("Read a little");
+ await input("#habit-name", "Read five pages");
+ await click("Dusk #79618d");
+ await act(async () =>
+ container
+ .querySelector("dialog form")!
+ .dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ ),
+ );
+ expect(container.querySelector("dialog")).toBeNull();
+ const created = (await api.json("/habits")).habits[0];
+ expect(created.name).toBe("Read five pages");
+ expect(
+ (await api.json(`/habits/${created.id}/calendar-settings`)).mainColor,
+ ).toBe("#79618d");
+ expect(container.querySelector(".home-habit")?.textContent).toContain(
+ "Read five pages",
+ );
+ expect(container.textContent).toContain("Your recorded progress");
+ expect(container.textContent).not.toContain("No habits yet");
+ const chart = container.querySelector(`#history-${created.id}`)!;
+ expect(chart.classList.contains("ds-habit-chart")).toBe(true);
+ expect(
+ chart.querySelector(".ds-habit-chart-heading h4 > span")!
+ .style.backgroundColor,
+ ).toBe("#79618d");
+ });
+
+ test("edit controls prefill settings, cancel safely, and persist validated changes", async () => {
+ const habit = await api.json(
+ "/habits",
+ "POST",
+ {
+ name: "Water",
+ method: "count",
+ target: 8,
+ unit: "glasses",
+ carryPartialProgress: true,
+ color: "#426582",
+ schedule: { type: "interval", every: 2, anchor: "2026-09-04" },
+ },
+ 201,
+ );
+ await render();
+ await click("Edit Water");
+ expect(
+ container.querySelector("#habit-target")!.value,
+ ).toBe("8");
+ expect(
+ container.querySelector("#habit-method")!.disabled,
+ ).toBe(true);
+ await input("#habit-name", "Not saved");
+ await click("Cancel");
+ expect((await api.json(`/habits/${habit.id}`)).name).toBe("Water");
+ await click("Edit Water");
+ await input("#habit-name", "Drink water");
+ await input("#habit-target", "10");
+ await click("Dusk #79618d");
+ const submit = async () =>
+ act(async () =>
+ container
+ .querySelector("dialog form")!
+ .dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ ),
+ );
+ fetchMock.mockResolvedValueOnce(
+ Response.json({ error: "Try saving again" }, { status: 503 }),
+ );
+ await submit();
+ expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
+ "Try saving again",
+ );
+ expect(
+ container.querySelector("#habit-name")!.value,
+ ).toBe("Drink water");
+ await submit();
+ expect(container.querySelector("dialog")).toBeNull();
+ const updated = await api.json(`/habits/${habit.id}`);
+ expect(updated.name).toBe("Drink water");
+ expect(updated.target).toBe(10);
+ expect(updated.carryPartialProgress).toBe(true);
+ expect(updated.schedule).toEqual(habit.schedule);
+ expect(
+ (await api.json(`/habits/${habit.id}/calendar-settings`)).mainColor,
+ ).toBe("#79618d");
+ expect(
+ container.querySelector(`#history-${habit.id} h4`)?.textContent,
+ ).toBe("Drink water");
+ });
+
+ test("editing a task habit preserves task IDs and schedules", async () => {
+ const habit = await api.json(
+ "/habits",
+ "POST",
+ {
+ name: "Reset",
+ method: "tasks",
+ tasks: [
+ { name: "Clear desk", schedule: { type: "weekdays", days: [5] } },
+ ],
+ },
+ 201,
+ );
+ await render();
+ await click("Edit Reset");
+ await input("#habit-name", "Evening reset");
+ await act(async () =>
+ container
+ .querySelector("dialog form")!
+ .dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ ),
+ );
+ expect(container.querySelector("dialog")).toBeNull();
+ const updated = await api.json(`/habits/${habit.id}`);
+ expect(updated.name).toBe("Evening reset");
+ expect(updated.tasks).toEqual(habit.tasks);
+ });
+
+ test("deletion requires confirmation, supports retry, and handles an empty dashboard", async () => {
+ const habit = await api.json(
+ "/habits",
+ "POST",
+ { name: "Read", method: "manual" },
+ 201,
+ );
+ await render();
+ await click("Delete Read");
+ expect(container.querySelector("dialog")?.textContent).toContain(
+ "not permanently erased",
+ );
+ expect((await api.json("/habits")).habits).toHaveLength(1);
+ await click("Keep habit");
+ expect(container.querySelector("dialog")).toBeNull();
+ await click("Delete Read");
+ fetchMock.mockResolvedValueOnce(
+ Response.json({ error: "Could not delete" }, { status: 503 }),
+ );
+ await click("Delete habit");
+ expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
+ "Could not delete",
+ );
+ expect(container.querySelector(`#history-${habit.id}`)).not.toBeNull();
+ await click("Delete habit");
+ expect(container.querySelector("dialog")).toBeNull();
+ expect(container.querySelector(`#history-${habit.id}`)).toBeNull();
+ expect(container.textContent).toContain("No habits yet");
+ expect((await api.json("/habits")).habits).toHaveLength(0);
+ expect((await api.json(`/habits/${habit.id}`)).archived).toBe(true);
+ });
+
+ test("all tracking methods save, undo, and exclude unscheduled habits", async () => {
+ const manual = await api.json(
+ "/habits",
+ "POST",
+ { name: "Read", method: "manual" },
+ 201,
+ );
+ const count = await api.json(
+ "/habits",
+ "POST",
+ { name: "Water", method: "count", target: 8, unit: "glasses" },
+ 201,
+ );
+ const tasks = await api.json(
+ "/habits",
+ "POST",
+ { name: "Reset", method: "tasks", tasks: [{ name: "Clear desk" }] },
+ 201,
+ );
+ await api.json(
+ "/habits",
+ "POST",
+ {
+ name: "Sunday walk",
+ method: "manual",
+ schedule: { type: "weekdays", days: [0] },
+ },
+ 201,
+ );
+ await render();
+ expect(container.querySelectorAll(".home-habit")).toHaveLength(3);
+ expect(
+ container.querySelectorAll(
+ "#rhythm .ds-habit-chart-grid > .ds-habit-chart",
+ ),
+ ).toHaveLength(4);
+ expect(container.querySelector("#history-habit")).toBeNull();
+ expect(
+ container.querySelectorAll("#rhythm .ds-calendar--compact"),
+ ).toHaveLength(4);
+ expect(
+ container.querySelector(".home-task-details")?.hasAttribute("open"),
+ ).toBe(false);
+ expect(container.querySelector(".home-off-day")?.textContent).toContain(
+ "Sunday walk",
+ );
+ await act(async () =>
+ container
+ .querySelector(`#habit-${manual.id}`)!
+ .closest("article")!
+ .querySelector('input[type="checkbox"]')!
+ .click(),
+ );
+ expect((await api.json("/today")).completed).toBe(1);
+ await click("Increase Water (glasses)");
+ expect((await api.json(`/habits/${count.id}/days/2026-09-04`)).value).toBe(
+ 1,
+ );
+ await act(async () =>
+ container
+ .querySelector(`#habit-${tasks.id}`)!
+ .closest("article")!
+ .querySelector('input[type="checkbox"]')!
+ .click(),
+ );
+ expect((await api.json("/today")).completed).toBe(2);
+ await click("Remaining 1");
+ expect(container.querySelectorAll(".home-habit")).toHaveLength(1);
+ await click("All today 3");
+ await act(async () =>
+ container
+ .querySelector(`#habit-${manual.id}`)!
+ .closest("article")!
+ .querySelector('input[type="checkbox"]')!
+ .click(),
+ );
+ expect((await api.json("/today")).completed).toBe(1);
+ });
+
+ test("all-complete and no-schedule days are different from having no habits", async () => {
+ const habit = await api.json(
+ "/habits",
+ "POST",
+ { name: "Read", method: "manual" },
+ 201,
+ );
+ await api.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", {
+ done: true,
+ });
+ await render();
+ expect(container.textContent).toContain(
+ "Everything scheduled for today is complete",
+ );
+ await click("Remaining 0");
+ expect(container.textContent).toContain("You’re all caught up.");
+ await api.json(`/habits/${habit.id}`, "PATCH", {
+ schedule: { type: "weekdays", days: [0] },
+ });
+ await act(async () => window.dispatchEvent(new Event("focus")));
+ expect(container.textContent).toContain("Nothing is scheduled today");
+ expect(container.textContent).not.toContain("No habits yet");
+ });
+
+ test("failed logging preserves progress and offers a refresh", async () => {
+ await api.json(
+ "/habits",
+ "POST",
+ { name: "Water", method: "count", target: 8, unit: "glasses" },
+ 201,
+ );
+ await render();
+ fetchMock.mockResolvedValueOnce(
+ Response.json({ error: "Could not save progress" }, { status: 503 }),
+ );
+ await click("Increase Water (glasses)");
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
+ "Could not save progress",
+ );
+ expect((await api.json("/today")).habits[0].value).toBe(0);
+ await click("Try again");
+ await click("Increase Water (glasses)");
+ expect((await api.json("/today")).habits[0].value).toBe(1);
+ });
+
+ test("a failed initial load is not shown as an empty account", async () => {
+ fetchMock
+ .mockResolvedValueOnce(Response.json(account))
+ .mockResolvedValueOnce(new Response(null, { status: 503 }));
+ await render();
+ expect(container.querySelector('[role="alert"]')).not.toBeNull();
+ expect(container.textContent).not.toContain("No habits yet");
+ await click("Try again");
+ expect(container.textContent).toContain("No habits yet");
+ });
+});
diff --git a/src/App.tsx b/src/App.tsx
index 5059900..d6bf752 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,10 +3,37 @@ import { AuthControls } from "./components/AuthControls";
import { Home } from "./pages/Home";
import { About } from "./pages/About";
import { Settings } from "./pages/Settings";
+import { DesignSystem } from "./pages/DesignSystem";
+import { AuthProvider, useAuth } from "./components/AuthProvider";
+import { Landing } from "./pages/Landing";
+import { Button } from "./components/design-system/primitives";
export function App() {
+ const { pathname } = useLocation();
+ if (pathname === "/design-system" || pathname === "/design-system/") return ;
+ return ;
+}
+
+function AppRoutes() {
const navigate = useNavigate();
const { pathname } = useLocation();
+ const { user, loading, accountError, retry } = useAuth();
+
+ if (pathname === "/") {
+ if (loading || accountError) return (
+
+
+ minabot.
+ {loading ? Loading your account…
: <>
+ {accountError}
+ Try again
+ >}
+
+
+ );
+ if (!user) return ;
+ return ;
+ }
return (
<>
@@ -14,6 +41,7 @@ export function App() {
navigate("/")}>Home {" "}
navigate("/about")}>About {" "}
navigate("/settings")}>Settings
+ {" "} navigate("/design-system")}>Design system
diff --git a/src/assets/design/habit-detail.png b/src/assets/design/habit-detail.png
new file mode 100644
index 0000000..bfefefb
Binary files /dev/null and b/src/assets/design/habit-detail.png differ
diff --git a/src/assets/design/today-v2.png b/src/assets/design/today-v2.png
new file mode 100644
index 0000000..9854981
Binary files /dev/null and b/src/assets/design/today-v2.png differ
diff --git a/src/assets/design/today.png b/src/assets/design/today.png
new file mode 100644
index 0000000..66e0f0b
Binary files /dev/null and b/src/assets/design/today.png differ
diff --git a/src/assets/fonts/InstrumentSerif-Italic.ttf b/src/assets/fonts/InstrumentSerif-Italic.ttf
new file mode 100644
index 0000000..192c3ed
Binary files /dev/null and b/src/assets/fonts/InstrumentSerif-Italic.ttf differ
diff --git a/src/assets/fonts/InstrumentSerif-Regular.ttf b/src/assets/fonts/InstrumentSerif-Regular.ttf
new file mode 100644
index 0000000..36fac00
Binary files /dev/null and b/src/assets/fonts/InstrumentSerif-Regular.ttf differ
diff --git a/src/assets/fonts/OFL.txt b/src/assets/fonts/OFL.txt
new file mode 100644
index 0000000..669ce79
--- /dev/null
+++ b/src/assets/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/components/AuthControls.tsx b/src/components/AuthControls.tsx
index dda4f64..4e8c77a 100644
--- a/src/components/AuthControls.tsx
+++ b/src/components/AuthControls.tsx
@@ -1,58 +1,7 @@
-import { useEffect, useState } from "react";
-import { useLocation } from "react-router";
-import type { PublicUser } from "../shared/user";
-
-const authErrors: Record = {
- not_configured: "Discord sign-in is not configured yet.",
- invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
- denied: "Discord sign-in was cancelled. You can try again when ready.",
- invalid_code: "Discord did not return a sign-in code. Please try again.",
- discord_unavailable: "Could not complete Discord sign-in. Please try again.",
-};
+import { useAuth } from "./AuthProvider";
export function AuthControls() {
- const [user, setUser] = useState(null);
- const [loading, setLoading] = useState(true);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState("");
- const { search } = useLocation();
- const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
-
- useEffect(() => {
- const controller = new AbortController();
- async function loadUser() {
- try {
- const response = await fetch("/api/me", { signal: controller.signal });
- if (response.status === 401) return;
- if (!response.ok) throw new Error("Could not load your account. Please reload to try again.");
- setUser(await response.json());
- } catch (error) {
- if (!controller.signal.aborted) setError(error instanceof Error ? error.message : "Could not load your account.");
- } finally {
- if (!controller.signal.aborted) setLoading(false);
- }
- }
- void loadUser();
- return () => controller.abort();
- }, []);
-
- function signIn() {
- let timezone = "UTC";
- try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
- window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
- }
-
- async function signOut() {
- setBusy(true);
- setError("");
- try {
- const response = await fetch("/api/auth/logout", { method: "POST" });
- if (!response.ok) throw new Error("Could not sign out. Please try again.");
- setUser(null);
- } catch (error) {
- setError(error instanceof Error ? error.message : "Could not sign out.");
- } finally { setBusy(false); }
- }
+ const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
return (
@@ -62,8 +11,9 @@ export function AuthControls() {
{busy ? "Signing out…" : "Sign out"} {" "}
View my profile
- ) : Sign in with Discord
}
- {(error || signInError) && {error || signInError}
}
+ ) : !accountError && Sign in with Discord
}
+ {error && {error}
}
+ {accountError && Try again }
);
}
diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx
new file mode 100644
index 0000000..24d3976
--- /dev/null
+++ b/src/components/AuthProvider.tsx
@@ -0,0 +1,91 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
+import { useLocation } from "react-router";
+import type { PublicUser } from "../shared/user";
+
+const authErrors: Record = {
+ not_configured: "Discord sign-in is not configured yet.",
+ invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
+ denied: "Discord sign-in was cancelled. You can try again when ready.",
+ invalid_code: "Discord did not return a sign-in code. Please try again.",
+ discord_unavailable: "Could not complete Discord sign-in. Please try again.",
+};
+
+function signIn() {
+ let timezone = "UTC";
+ try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
+ window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
+}
+
+type AuthState = {
+ user: PublicUser | null;
+ loading: boolean;
+ busy: boolean;
+ accountError: string;
+ error: string;
+ signIn: () => void;
+ signOut: () => Promise;
+ retry: () => void;
+};
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState(false);
+ const [accountError, setAccountError] = useState("");
+ const [actionError, setActionError] = useState("");
+ const [attempt, setAttempt] = useState(0);
+ const { search } = useLocation();
+ const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
+
+ useEffect(() => {
+ const controller = new AbortController();
+ async function loadUser() {
+ try {
+ const response = await fetch("/api/me", { signal: controller.signal });
+ if (controller.signal.aborted) return;
+ if (response.status === 401) { setUser(null); return; }
+ if (!response.ok) throw new Error("Could not load your account. Please try again.");
+ const account: PublicUser = await response.json();
+ if (!controller.signal.aborted) setUser(account);
+ } catch (error) {
+ if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
+ } finally {
+ if (!controller.signal.aborted) setLoading(false);
+ }
+ }
+ void loadUser();
+ return () => controller.abort();
+ }, [attempt]);
+
+ function retry() {
+ setAccountError("");
+ setLoading(true);
+ setAttempt((current) => current + 1);
+ }
+
+ async function signOut() {
+ setBusy(true);
+ setActionError("");
+ try {
+ const response = await fetch("/api/auth/logout", { method: "POST" });
+ if (!response.ok) throw new Error("Could not sign out. Please try again.");
+ setUser(null);
+ } catch (error) {
+ setActionError(error instanceof Error ? error.message : "Could not sign out.");
+ } finally { setBusy(false); }
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const auth = useContext(AuthContext);
+ if (!auth) throw new Error("useAuth must be used within AuthProvider");
+ return auth;
+}
diff --git a/src/components/CreateHabit.tsx b/src/components/CreateHabit.tsx
new file mode 100644
index 0000000..fc98222
--- /dev/null
+++ b/src/components/CreateHabit.tsx
@@ -0,0 +1,261 @@
+import { useEffect, useRef, useState, type FormEvent } from "react";
+import { Button } from "./design-system/primitives";
+import { ScheduleEditor } from "./design-system/EditingWorkbench";
+import {
+ habitInput,
+ habitPatch,
+ type HabitConfig,
+ type Schedule,
+} from "../habits/contracts";
+import { habitRequest } from "../lib/dashboard";
+import { HabitColorPicker } from "./design-system/HabitColorPicker";
+
+export type HabitStarter = {
+ name: string;
+ method: "manual" | "count" | "tasks";
+ target?: number;
+ unit?: string;
+ tasks?: string;
+ color?: string;
+};
+
+export function CreateHabit({
+ date,
+ starter,
+ onClose,
+ onCreated,
+ editing,
+}: {
+ date: string;
+ starter: HabitStarter;
+ onClose: () => void;
+ onCreated: (name: string) => void;
+ editing?: { id: string; config: HabitConfig };
+}) {
+ const dialog = useRef(null);
+ const saving = useRef(false);
+ const [name, setName] = useState(starter.name);
+ const [method, setMethod] = useState(starter.method);
+ const [target, setTarget] = useState(starter.target ?? 8);
+ const [unit, setUnit] = useState(starter.unit ?? "glasses");
+ const [tasks, setTasks] = useState(starter.tasks ?? "");
+ const [color, setColor] = useState(starter.color ?? "#58765b");
+ const [schedule, setSchedule] = useState(
+ editing?.config.schedule ?? { type: "daily" },
+ );
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ const previous = document.activeElement as HTMLElement | null;
+ dialog.current?.showModal();
+ dialog.current?.querySelector("#habit-name")?.focus();
+ return () => {
+ previous?.focus();
+ };
+ }, []);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (saving.current) return;
+ const taskNames = tasks
+ .split("\n")
+ .map((name) => name.trim())
+ .filter(Boolean);
+ if (!editing && method === "tasks" && !taskNames.length) {
+ setError("Add at least one task to get started.");
+ return;
+ }
+ const input = {
+ name,
+ method,
+ schedule,
+ color,
+ ...(method === "count"
+ ? { target, unit }
+ : method === "tasks" && !editing
+ ? { tasks: taskNames.map((name) => ({ name })) }
+ : {}),
+ };
+ const parsed = editing
+ ? habitPatch.safeParse(input)
+ : habitInput.safeParse(input);
+ if (!parsed.success) {
+ setError(parsed.error.issues.map((issue) => issue.message).join(" "));
+ return;
+ }
+ saving.current = true;
+ setBusy(true);
+ setError("");
+ try {
+ await habitRequest(editing ? `/habits/${editing.id}` : "/habits", {
+ method: editing ? "PATCH" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(parsed.data),
+ });
+ onCreated(parsed.data.name!);
+ } catch (error) {
+ setError(
+ error instanceof Error ? error.message : "Could not save your habit.",
+ );
+ saving.current = false;
+ setBusy(false);
+ }
+ }
+
+ return (
+ {
+ event.preventDefault();
+ if (!saving.current) onClose();
+ }}
+ >
+
+
+ {editing ? "SHAPE YOUR RHYTHM" : "A LITTLE ROOM FOR SOMETHING GOOD"}
+
+
+ ×
+
+
+
+ {editing ? (
+ <>
+ Edit your habit.
+ >
+ ) : (
+ <>
+ Make it yours.
+ >
+ )}
+
+
+ {editing
+ ? "Changes start today. Earlier targets and schedules stay as they were."
+ : "Start small. You don’t need a perfect plan."}
+
+
+
+ );
+}
diff --git a/src/components/DeleteHabit.tsx b/src/components/DeleteHabit.tsx
new file mode 100644
index 0000000..3e6a64f
--- /dev/null
+++ b/src/components/DeleteHabit.tsx
@@ -0,0 +1,73 @@
+import { useEffect, useRef, useState } from "react";
+import { habitRequest, type TodayHabit } from "../lib/dashboard";
+import { Button } from "./design-system/primitives";
+
+export function DeleteHabit({
+ habit,
+ onClose,
+ onDeleted,
+}: {
+ habit: TodayHabit;
+ onClose: () => void;
+ onDeleted: () => void;
+}) {
+ const dialog = useRef(null);
+ const saving = useRef(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ useEffect(() => {
+ const previous = document.activeElement as HTMLElement | null;
+ dialog.current?.showModal();
+ return () => previous?.focus();
+ }, []);
+ async function remove() {
+ if (saving.current) return;
+ saving.current = true;
+ setBusy(true);
+ setError("");
+ try {
+ await habitRequest(`/habits/${habit.habitId}`, { method: "DELETE" });
+ onDeleted();
+ } catch (error) {
+ setError(
+ error instanceof Error ? error.message : "Could not delete your habit.",
+ );
+ saving.current = false;
+ setBusy(false);
+ }
+ }
+ return (
+ {
+ event.preventDefault();
+ if (!saving.current) onClose();
+ }}
+ >
+ MAKE A LITTLE ROOM
+
+ Delete this habit?
+
+
+ {habit.name} will leave your dashboard and stop future
+ check-ins. Your recorded history is kept, not permanently erased.
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ Keep habit
+
+ void remove()}>
+ {busy ? "Deleting…" : "Delete habit"}
+
+
+
+ );
+}
diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx
new file mode 100644
index 0000000..077ac93
--- /dev/null
+++ b/src/components/HabitHistory.tsx
@@ -0,0 +1,156 @@
+import { useEffect, useState } from "react";
+import type { CalendarResponse } from "../shared/calendar";
+import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
+import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
+import { monthWindow } from "./design-system/calendar-model";
+import { Button } from "./design-system/primitives";
+import { HabitChart } from "./design-system/HabitChart";
+import { CalendarLegend } from "./design-system/CalendarLegend";
+import { shade } from "../habits/calendar";
+
+export function HabitHistory({
+ habit,
+ date,
+ revision,
+ onEdit,
+ onDelete,
+ disabled = false,
+}: {
+ habit: TodayHabit;
+ date: string;
+ revision: number;
+ onEdit: (color: string) => void;
+ onDelete: () => void;
+ disabled?: boolean;
+}) {
+ const [calendar, setCalendar] = useState(null);
+ const [error, setError] = useState("");
+ const [attempt, setAttempt] = useState(0);
+ useEffect(() => {
+ const controller = new AbortController();
+ setError("");
+ const range = monthWindow(date, 12);
+ habitRequest(
+ `/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
+ { signal: controller.signal },
+ )
+ .then((result) => {
+ if (!controller.signal.aborted) setCalendar(result);
+ })
+ .catch((error) => {
+ if (!controller.signal.aborted) setError(error.message);
+ });
+ return () => controller.abort();
+ }, [habit.habitId, date, revision, attempt]);
+
+ const days =
+ calendar?.days.map((day) => ({
+ date: day.date,
+ color: day.color,
+ unit: day.habits[0]?.unit,
+ value: day.habits[0]?.value ?? 0,
+ target: day.habits[0]?.target ?? 0,
+ state: day.future
+ ? ("future" as const)
+ : day.due
+ ? ("due" as const)
+ : ("not-due" as const),
+ })) ?? [];
+ const steps = Math.max(
+ 1,
+ calendar?.days.find((day) => day.date === date)?.shadeCount ??
+ habit.target ??
+ 1,
+ );
+ const shades = calendar
+ ? [
+ calendar.settings.emptyColor,
+ ...Array.from(
+ { length: Math.min(8, steps) },
+ (_, index) =>
+ shade(
+ Math.ceil(((index + 1) * steps) / Math.min(8, steps)) / steps,
+ steps,
+ calendar.settings,
+ true,
+ ).color,
+ ),
+ ]
+ : [];
+ const chart = error ? (
+
+
{error}
+
setAttempt((value) => value + 1)}
+ >
+ Retry history
+
+
+ ) : !calendar ? (
+
+ Loading your habit history…
+
+ ) : (
+
+ }
+ />
+ );
+ return (
+
+
+ onEdit(calendar!.settings.mainColor)}
+ >
+ Edit
+
+
+ Delete
+
+
+
+ );
+}
diff --git a/src/components/LandingDemo.tsx b/src/components/LandingDemo.tsx
new file mode 100644
index 0000000..a5b26d0
--- /dev/null
+++ b/src/components/LandingDemo.tsx
@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { Button, Checkbox, Counter } from "./design-system/primitives";
+import { HabitChart } from "./design-system/HabitChart";
+import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
+import { combinedProgress, demoCalendar, HABIT_COLORS } from "./design-system/calendar-model";
+
+export function LandingDemo() {
+ const [water, setWater] = useState(7);
+ const [tasks, setTasks] = useState([true, true, false]);
+ const [view, setView] = useState<"Today" | "Combined progress">("Today");
+ const taskCount = tasks.filter(Boolean).length;
+ const total = combinedProgress([
+ { value: water, target: 8, due: true },
+ { value: taskCount, target: 3, due: true },
+ ]);
+
+ return (
+
+
+
+ 01 / A LITTLE, EVERY DAY
+
Small steps. Visible progress.
+
+
Interactive demo · nothing is saved
+
+
+
+ {(["Today", "Combined progress"] as const).map((item) => (
+ setView(item)}>{item}
+ ))}
+
+
{ setWater(7); setTasks([true, true, false]); setView("Today"); }}>
+ Reset demo ↺
+
+
+
+
+
A SAMPLE DAY / SEPTEMBER 4, 2026
+
{view === "Today" ? "Make a little room for yourself." : "See your habits, together."}
+
+
{total.completed} of {total.due} habits complete
+
+ {view === "Today" ? (
+
+
+
+
+ (
+ {
+ const checked = event.target.checked;
+ setTasks((current) => current.map((done, i) => i === index ? checked : done));
+ }} />
+ ))}
+ />
+
+ ) : (
+
+ )}
+
+ {view === "Today"
+ ? "Try adding a glass of water, or open the evening tasks and tick off a small win. Select any day to take a closer look."
+ : "Each fully completed habit counts equally. Partial progress appears in its own calendar; unscheduled habits stay out of the combined score."}
+
+
+ );
+}
diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx
new file mode 100644
index 0000000..594339a
--- /dev/null
+++ b/src/components/design-system/CalendarHeatmap.tsx
@@ -0,0 +1,245 @@
+import { useEffect, useId, useRef, useState, type ReactNode } from "react";
+import { CalendarLegend } from "./CalendarLegend";
+import {
+ describeDay,
+ progressShade,
+ MONTH_VIEWS,
+ visibleCalendarDays,
+ type CalendarDay,
+ type MonthView,
+} from "./calendar-model";
+
+export function CalendarHeatmap({
+ days: allDays,
+ unit,
+ label,
+ color = "#111111",
+ emptyColor = "#eeeeee",
+ compact = false,
+ selectedDate: controlledDate,
+ onSelectDate,
+ historyLabel,
+ legend,
+}: {
+ days: CalendarDay[];
+ unit: string;
+ label: string;
+ color?: string;
+ emptyColor?: string;
+ compact?: boolean;
+ selectedDate?: string;
+ onSelectDate?: (date: string) => void;
+ historyLabel?: string;
+ legend?: ReactNode;
+}) {
+ const [months, setMonths] = useState(6);
+ const latestDate =
+ allDays.findLast((day) => day.state !== "future")?.date ??
+ allDays.at(-1)?.date;
+ // A date selected in the editor must stay visible even outside the current window.
+ const anchor =
+ controlledDate && allDays.some((day) => day.date === controlledDate)
+ ? controlledDate
+ : latestDate;
+ const [localSelectedDate, setLocalSelectedDate] = useState(anchor);
+ const selectedDate = controlledDate ?? localSelectedDate;
+ function setSelectedDate(date: string) {
+ if (onSelectDate) onSelectDate(date);
+ else setLocalSelectedDate(date);
+ }
+ const days = anchor ? visibleCalendarDays(allDays, months, anchor) : [];
+ const selected =
+ days.find((day) => day.date === selectedDate) ??
+ days.find((day) => day.date === anchor) ??
+ days[0];
+ const offset = days[0]
+ ? new Date(`${days[0].date}T12:00:00Z`).getUTCDay()
+ : 0;
+ const weeks = Math.ceil((days.length + offset) / 7);
+ const buttons = useRef<(HTMLButtonElement | null)[]>([]);
+ const scrollArea = useRef(null);
+ const inspectorId = useId();
+ const selectedIndex = selected ? days.indexOf(selected) : -1;
+ useEffect(() => {
+ const container = scrollArea.current;
+ const button = buttons.current[selectedIndex];
+ if (!container || !button) return;
+ const cellBounds = button.getBoundingClientRect();
+ const bounds = container.getBoundingClientRect();
+ if (cellBounds.right > bounds.right - 5)
+ container.scrollLeft += cellBounds.right - bounds.right + 5;
+ else if (cellBounds.left < bounds.left + 5)
+ container.scrollLeft += cellBounds.left - bounds.left - 5;
+ }, [months, selectedIndex]);
+ if (!selected) return No calendar dates to display.
;
+ return (
+
+
+
+ {new Date(`${days[0]!.date}T12:00:00Z`).toLocaleDateString("en", {
+ month: "short",
+ year: "numeric",
+ timeZone: "UTC",
+ })}{" "}
+ –{" "}
+ {new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
+ month: "short",
+ year: "numeric",
+ timeZone: "UTC",
+ })}
+
+
+ {MONTH_VIEWS.map((value) => (
+ setMonths(value)}
+ >
+ {value}m
+
+ ))}
+
+
+
+
+
+ {days.map(
+ (day, index) =>
+ (index === 0 || day.date.endsWith("-01")) && (
+
+ {new Date(`${day.date}T12:00:00Z`).toLocaleDateString(
+ "en",
+ { month: "short", timeZone: "UTC" },
+ )}
+
+ ),
+ )}
+
+
+
+ Mon
+ Wed
+ Fri
+
+
+ {Array.from({ length: offset }, (_, index) => (
+
+ ))}
+ {days.map((day, index) => (
+ {
+ buttons.current[index] = element;
+ }}
+ type="button"
+ className={`ds-day ds-day--${day.state}`}
+ style={{
+ backgroundColor:
+ day.state === "not-due"
+ ? "transparent"
+ : day.state === "future"
+ ? emptyColor
+ : progressShade(day, color),
+ }}
+ aria-label={`${day.date}: ${describeDay(day, unit)}`}
+ title={`${day.date}: ${describeDay(day, unit)}`}
+ aria-pressed={day.date === selected.date}
+ aria-describedby={inspectorId}
+ tabIndex={day.date === selected.date ? 0 : -1}
+ onClick={() => setSelectedDate(day.date)}
+ onKeyDown={(event) => {
+ const offset = {
+ ArrowDown: 1,
+ ArrowUp: -1,
+ ArrowRight: 7,
+ ArrowLeft: -7,
+ }[event.key];
+ const next =
+ event.key === "Home"
+ ? 0
+ : event.key === "End"
+ ? days.length - 1
+ : offset !== undefined
+ ? Math.max(
+ 0,
+ Math.min(days.length - 1, index + offset),
+ )
+ : undefined;
+ if (next !== undefined) {
+ event.preventDefault();
+ setSelectedDate(days[next]!.date);
+ buttons.current[next]?.focus();
+ }
+ }}
+ />
+ ))}
+
+
+
+
+ {legend ??
}
+
+
+ {months} months{" "}
+
+ /{" "}
+ {historyLabel ??
+ (compact ? "Demo history" : "Illustrative history")}
+
+
+
+ {compact
+ ? "Select a day · Arrow keys"
+ : "Select a day to inspect · Arrow keys to move"}
+
+
+
+
+ {new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ timeZone: "UTC",
+ })}
+
+ {describeDay(selected, unit)}
+
+
+ );
+}
diff --git a/src/components/design-system/CalendarLegend.tsx b/src/components/design-system/CalendarLegend.tsx
new file mode 100644
index 0000000..8c2b596
--- /dev/null
+++ b/src/components/design-system/CalendarLegend.tsx
@@ -0,0 +1,53 @@
+import { legendShades, type CalendarDay } from "./calendar-model";
+
+export function CalendarLegend({
+ days,
+ color,
+ label,
+ shades: suppliedShades,
+}: {
+ days: CalendarDay[];
+ color: string;
+ label: string;
+ shades?: string[];
+}) {
+ const shades = suppliedShades ?? legendShades(days, color);
+ return (
+
+
+ 0 / Upcoming
+ 2
+ ? "Empty, then increasing partial progress through complete"
+ : shades.length === 2
+ ? "Empty or complete"
+ : "Empty"
+ }
+ >
+ {shades.map((shade) => (
+
+ ))}
+
+ {shades.length > 1 && Complete }
+
+
+
+ Not scheduled
+
+
+ );
+}
diff --git a/src/components/design-system/EditingWorkbench.tsx b/src/components/design-system/EditingWorkbench.tsx
new file mode 100644
index 0000000..aba9c18
--- /dev/null
+++ b/src/components/design-system/EditingWorkbench.tsx
@@ -0,0 +1,909 @@
+import { useEffect, useId, useState, type ReactNode } from "react";
+import type { HabitConfig, Schedule } from "../../habits/contracts";
+import { Button, Checkbox } from "./primitives";
+import { CalendarHeatmap } from "./CalendarHeatmap";
+import {
+ HabitColorPicker,
+ isHabitColor,
+ previewHabitColors,
+ type HabitColors,
+} from "./HabitColorPicker";
+import {
+ EDITOR_HABITS,
+ EDITOR_START,
+ EDITOR_TODAY,
+ backfillError,
+ historicalDays,
+ validateEditor,
+ validateTasks,
+} from "./editing-model";
+
+function Field({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: (id: string) => ReactNode;
+}) {
+ const id = useId();
+ return (
+
+ {label}
+ {children(id)}
+ {hint && {hint} }
+
+ );
+}
+
+const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+export function ScheduleEditor({
+ value,
+ onChange,
+ anchorDate = EDITOR_TODAY,
+}: {
+ value: Schedule;
+ onChange: (schedule: Schedule) => void;
+ anchorDate?: string;
+}) {
+ return (
+
+
+ {(id) => (
+ {
+ const type = event.target.value;
+ onChange(
+ type === "daily"
+ ? { type }
+ : type === "weekdays"
+ ? { type, days: [1, 2, 3, 4, 5] }
+ : type === "interval"
+ ? { type, every: 2, anchor: anchorDate }
+ : {
+ type: "weekly",
+ every: 1,
+ weekday: 1,
+ anchor: anchorDate,
+ },
+ );
+ }}
+ >
+ Every day
+ Selected weekdays
+ Every few days
+ Every few weeks
+
+ )}
+
+ {value.type === "weekdays" && (
+
+ Scheduled days · choose at least one
+
+ {WEEKDAYS.map((day, index) => (
+
+ onChange({
+ ...value,
+ days: value.days.includes(index)
+ ? value.days.filter((item) => item !== index)
+ : [...value.days, index].sort(),
+ })
+ }
+ >
+ {day}
+
+ ))}
+
+
+ )}
+ {(value.type === "interval" || value.type === "weekly") && (
+
+
+ {(id) => (
+
+ onChange({ ...value, every: event.target.valueAsNumber })
+ }
+ />
+ )}
+
+
+ {(id) => (
+
+ onChange({ ...value, anchor: event.currentTarget.value })
+ }
+ />
+ )}
+
+
+ )}
+ {value.type === "weekly" && (
+
+ {(id) => (
+
+ onChange({ ...value, weekday: Number(event.target.value) })
+ }
+ >
+ {WEEKDAYS.map((day, index) => (
+
+ {day}
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
+
+function SaveBar({
+ dirty,
+ error,
+ onCancel,
+ label = "Save changes",
+ disabled = false,
+}: {
+ dirty: boolean;
+ error: string | null;
+ onCancel: () => void;
+ label?: string;
+ disabled?: boolean;
+}) {
+ return (
+ <>
+
+ {error || (dirty ? "Unsaved changes" : "No unsaved changes")}
+
+
+
+ {label} ↗
+
+
+ Cancel
+
+
+ >
+ );
+}
+
+type EditorTab = "Habit settings" | "Tasks" | "Backfill progress";
+type TaskConfig = Extract;
+type Correction = {
+ habitId: string;
+ name: string;
+ date: string;
+ before: number;
+ after: number;
+ target: number;
+ unit: string;
+};
+
+export function EditingWorkbench({
+ onColorsChange,
+}: {
+ onColorsChange?: (colors: HabitColors) => void;
+}) {
+ const [tab, setTab] = useState("Habit settings");
+ const [habits, setHabits] = useState(() => structuredClone(EDITOR_HABITS));
+ const [selectedId, setSelectedId] = useState("water");
+ const selected = habits.find((habit) => habit.id === selectedId)!;
+ const [draftColor, setDraftColor] = useState(selected.color);
+ const previewColor = isHabitColor(draftColor) ? draftColor : selected.color;
+ useEffect(() => {
+ onColorsChange?.(previewHabitColors(habits, selectedId, previewColor));
+ }, [habits, selectedId, previewColor, onColorsChange]);
+ const [draft, setDraft] = useState(() =>
+ structuredClone(selected.config),
+ );
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState("");
+ const [corrections, setCorrections] = useState([]);
+ const [entries, setEntries] = useState<
+ Record
+ >({});
+ const [date, setDate] = useState("2026-09-03");
+ const historical = EDITOR_HABITS.find(
+ (habit) => habit.id === selectedId,
+ )!.config;
+ const [progress, setProgress] = useState(
+ () => historicalDays("water").find((day) => day.date === date)!.value,
+ );
+ const [taskChecks, setTaskChecks] = useState([]);
+ const entryKey = `${selectedId}:${date}`;
+ const day = historicalDays(selectedId).find((day) => day.date === date);
+ const savedProgress = entries[entryKey]?.value ?? day?.value ?? 0;
+ const savedChecks =
+ entries[entryKey]?.tasks ??
+ (historical.method === "tasks"
+ ? historical.tasks.map((_, index) => index < savedProgress)
+ : []);
+ const configDirty =
+ JSON.stringify(draft) !== JSON.stringify(selected.config) ||
+ draftColor.toLowerCase() !== selected.color.toLowerCase();
+ const progressDirty =
+ progress !== savedProgress ||
+ (historical.method === "tasks" &&
+ JSON.stringify(taskChecks) !== JSON.stringify(savedChecks));
+ const dirty = tab === "Backfill progress" ? progressDirty : configDirty;
+ const blocked = backfillError(selectedId, date, progress);
+
+ function loadProgress(id: string, nextDate: string) {
+ const original = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
+ const entry = entries[`${id}:${nextDate}`];
+ const value =
+ entry?.value ??
+ historicalDays(id).find((day) => day.date === nextDate)?.value ??
+ 0;
+ setProgress(value);
+ setTaskChecks(
+ entry?.tasks ??
+ (original.method === "tasks"
+ ? original.tasks.map((_, index) => index < value)
+ : []),
+ );
+ setError(null);
+ }
+ function canLeave() {
+ return !dirty || window.confirm("Discard unsaved changes in this preview?");
+ }
+ function selectHabit(id: string) {
+ if (!canLeave()) return;
+ setSelectedId(id);
+ setDraft(structuredClone(habits.find((habit) => habit.id === id)!.config));
+ setDraftColor(habits.find((habit) => habit.id === id)!.color);
+ loadProgress(id, date);
+ setNotice("");
+ }
+ function selectTab(next: EditorTab) {
+ if (next === tab || !canLeave()) return;
+ const id = next === "Tasks" ? "tasks" : selectedId;
+ setTab(next);
+ setSelectedId(id);
+ setDraft(structuredClone(habits.find((habit) => habit.id === id)!.config));
+ setDraftColor(habits.find((habit) => habit.id === id)!.color);
+ loadProgress(id, date);
+ setNotice("");
+ }
+ function selectDate(next: string) {
+ if (next === date || !canLeave()) return;
+ setDate(next);
+ loadProgress(selectedId, next);
+ setNotice("");
+ }
+ function saveConfig() {
+ if (!isHabitColor(draftColor)) {
+ setError("Use a six-digit hex color, such as #426582.");
+ return;
+ }
+ const validation =
+ tab === "Tasks" && draft.method === "tasks"
+ ? validateTasks(draft.tasks)
+ : validateEditor(draft);
+ if (validation) {
+ setError(validation);
+ return;
+ }
+ const clean = {
+ ...draft,
+ name: draft.name.trim(),
+ ...(draft.method === "count" ? { unit: draft.unit.trim() } : {}),
+ ...(draft.method === "tasks"
+ ? {
+ tasks: draft.tasks.map((task) => ({
+ ...task,
+ name: task.name.trim(),
+ })),
+ }
+ : {}),
+ } as HabitConfig;
+ setHabits((current) =>
+ current.map((habit) =>
+ habit.id === selectedId
+ ? {
+ ...habit,
+ config: structuredClone(clean),
+ color: draftColor.toLowerCase(),
+ }
+ : habit,
+ ),
+ );
+ setDraft(clean);
+ setDraftColor(draftColor.toLowerCase());
+ setError(null);
+ setNotice(
+ `${tab === "Tasks" ? "Tasks" : clean.name} saved in this preview. Effective September 4; earlier dates are unchanged.`,
+ );
+ }
+ function updateTask(id: string, patch: Partial) {
+ if (draft.method === "tasks")
+ setDraft({
+ ...draft,
+ tasks: draft.tasks.map((task) =>
+ task.id === id ? { ...task, ...patch } : task,
+ ),
+ });
+ setError(null);
+ }
+
+ return (
+
+
+
+ 02 / EDITING & HISTORY
+
+ Room for real life.
+
+
+
+ Independent editor demo · not saved
+
+
+
+
+ {(["Habit settings", "Tasks", "Backfill progress"] as const).map(
+ (item) => (
+ selectTab(item)}
+ >
+ {item}
+
+ ),
+ )}
+
+
{
+ if (
+ !window.confirm(
+ "Reset all editor changes and corrections? This only affects the preview.",
+ )
+ )
+ return;
+ setHabits(structuredClone(EDITOR_HABITS));
+ setSelectedId("water");
+ setDraft(structuredClone(EDITOR_HABITS[0]!.config));
+ setDraftColor(EDITOR_HABITS[0]!.color);
+ setTab("Habit settings");
+ setEntries({});
+ setCorrections([]);
+ setDate("2026-09-03");
+ setProgress(
+ historicalDays("water").find((day) => day.date === "2026-09-03")!
+ .value,
+ );
+ setTaskChecks([]);
+ setError(null);
+ setNotice("Editor demo reset.");
+ }}
+ >
+ Reset editor ↺
+
+
+
+
+
+ {tab === "Backfill progress"
+ ? "A DATE, NOT A RESET"
+ : "MAKE IT YOURS"}
+
+
+ {tab === "Habit settings"
+ ? "Shape your rhythm."
+ : tab === "Tasks"
+ ? "Small steps, clearly defined."
+ : "A missed log is not a missed day."}
+
+
+ {tab === "Habit settings"
+ ? "Adjust a name, target, or schedule. Changes start today; your history stays as it was."
+ : tab === "Tasks"
+ ? "Give each task its own repeat rule. It is due only when both the habit and task schedules match."
+ : "Choose a past date and enter what actually happened. That date keeps its original requirements."}
+
+ {tab !== "Tasks" && (
+
+ {(id) => (
+ selectHabit(event.target.value)}
+ >
+ {habits.map((habit) => (
+
+ {habit.config.name}
+ {habit.config.archived ? " · Archived" : ""}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ {tab === "Backfill progress"
+ ? historical.name
+ : selected.config.name}
+
+
+
+ Demo clock · September 4, 2026
+
+ Tracking timezone · Europe/Belgrade
+
+
+
+ {tab === "Habit settings" && (
+
{
+ event.preventDefault();
+ saveConfig();
+ }}
+ >
+
+
Edit habit
+ Effective today
+
+
+ {(id) => (
+ {
+ setDraft({ ...draft, name: event.target.value });
+ setError(null);
+ }}
+ />
+ )}
+
+ {
+ setDraftColor(color);
+ setError(null);
+ setNotice("");
+ }}
+ />
+
+ {(id) => (
+
+ )}
+
+ {draft.method === "count" && (
+ <>
+
+
+ {(id) => (
+
+ setDraft({
+ ...draft,
+ target: event.target.valueAsNumber,
+ })
+ }
+ />
+ )}
+
+
+ {(id) => (
+
+ setDraft({ ...draft, unit: event.target.value })
+ }
+ />
+ )}
+
+
+
+ setDraft({
+ ...draft,
+ carryPartialProgress: event.target.checked,
+ })
+ }
+ />
+
+ Completed counts reset. Earlier explicit logs are never
+ overwritten.
+
+ >
+ )}
+
+ {
+ setDraft({ ...draft, schedule });
+ setError(null);
+ }}
+ />
+
+
+
+ setDraft({ ...draft, archived: event.target.checked })
+ }
+ />
+
+ Stops tracking from today. History is kept. Uncheck and save
+ to restore.
+
+
+ {
+ setDraft(structuredClone(selected.config));
+ setDraftColor(selected.color);
+ setError(null);
+ }}
+ />
+
+ )}
+ {tab === "Tasks" && draft.method === "tasks" && (
+
{
+ event.preventDefault();
+ saveConfig();
+ }}
+ >
+
+
Edit tasks
+
+ {draft.tasks.length} / 100 tasks
+
+
+ {draft.archived && (
+
+ This habit is archived. Restore it in Habit settings before
+ editing tasks.
+
+ )}
+
+ {draft.tasks.length === 0 && (
+
+ No tasks yet. Add a first step; a habit with no due tasks is
+ not scored.
+
+ )}
+ {draft.tasks.map((task, index) => (
+
+
+
+ STEP {String(index + 1).padStart(2, "0")}
+
+
+ setDraft({
+ ...draft,
+ tasks: draft.tasks.filter(
+ (item) => item.id !== task.id,
+ ),
+ })
+ }
+ >
+ Remove −
+
+
+
+ {(id) => (
+
+ updateTask(task.id, { name: event.target.value })
+ }
+ />
+ )}
+
+
updateTask(task.id, { schedule })}
+ />
+
+ ))}
+ = 100}
+ onClick={() =>
+ setDraft({
+ ...draft,
+ tasks: [
+ ...draft.tasks,
+ {
+ id: crypto.randomUUID(),
+ name: "",
+ schedule: { type: "daily" },
+ },
+ ],
+ })
+ }
+ >
+ Add task +
+
+
+
+ Removing a task takes effect when you save. Earlier checklists
+ and their completion records remain available for backfilling.
+
+ {
+ setDraft(structuredClone(selected.config));
+ setDraftColor(selected.color);
+ setError(null);
+ }}
+ label="Save tasks"
+ />
+
+ )}
+ {tab === "Backfill progress" && (
+ <>
+
+
Correct a past day
+ Historical snapshot
+
+
+ {(id) => (
+ selectDate(event.currentTarget.value)}
+ />
+ )}
+
+
{
+ event.preventDefault();
+ if (blocked) {
+ setError(blocked);
+ return;
+ }
+ const correction = {
+ habitId: selectedId,
+ name: historical.name,
+ date,
+ before: savedProgress,
+ after: progress,
+ target: day!.target,
+ unit:
+ historical.method === "count"
+ ? historical.unit
+ : historical.method === "tasks"
+ ? "tasks"
+ : "session",
+ };
+ setEntries((current) => ({
+ ...current,
+ [entryKey]: { value: progress, tasks: [...taskChecks] },
+ }));
+ setCorrections((current) => [correction, ...current]);
+ setError(null);
+ setNotice(
+ `Correction saved in this preview for ${date}. The calendar below is updated.`,
+ );
+ }}
+ >
+ {blocked && (
+
+ {blocked}
+
+ )}
+ {day?.state === "due" && date < EDITOR_TODAY && (
+ <>
+
+ Required on {date}
+
+ {day.target}{" "}
+ {historical.method === "count"
+ ? historical.unit
+ : historical.method === "tasks"
+ ? "tasks"
+ : "session"}
+
+
+ {historical.method === "count" ? (
+
+ {(id) => (
+ {
+ setProgress(event.target.valueAsNumber);
+ setError(null);
+ }}
+ />
+ )}
+
+ ) : historical.method === "manual" ? (
+
+ setProgress(Number(event.target.checked))
+ }
+ />
+ ) : (
+
+ {historical.tasks.map((task, index) => (
+ {
+ const next = taskChecks.map((done, i) =>
+ i === index ? event.target.checked : done,
+ );
+ setTaskChecks(next);
+ setProgress(next.filter(Boolean).length);
+ }}
+ />
+ ))}
+
+ )}
+
+ {savedProgress} →{" "}
+ {Number.isFinite(progress) ? progress : "—"} /{" "}
+ {day.target}
+
+ {progress >= day.target ? "Complete" : "Incomplete"}
+
+
+ >
+ )}
+ loadProgress(selectedId, date)}
+ />
+
+
+ ({
+ ...item,
+ value:
+ entries[`${selectedId}:${item.date}`]?.value ??
+ item.value,
+ }))}
+ unit={
+ historical.method === "count"
+ ? historical.unit
+ : historical.method === "tasks"
+ ? "tasks"
+ : "session"
+ }
+ label="Backfill history"
+ selectedDate={date}
+ onSelectDate={selectDate}
+ />
+
+
+ This preview updates only the calendar here. In the connected
+ app, corrections also recalculate combined charts and inherited
+ counts.
+
+ >
+ )}
+
+ {notice}
+
+
+
+ {corrections.length > 0 && (
+
+
+
Correction history
+ This preview only · newest first
+
+
+ {corrections.map((item, index) => (
+
+ {item.date}
+ {item.name}
+
+ {item.before} → {item.after} / {item.target} {item.unit}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/design-system/HabitChart.tsx b/src/components/design-system/HabitChart.tsx
new file mode 100644
index 0000000..b966bfe
--- /dev/null
+++ b/src/components/design-system/HabitChart.tsx
@@ -0,0 +1,84 @@
+import { useId, type ReactNode } from "react";
+import { CalendarHeatmap } from "./CalendarHeatmap";
+import { demoCalendar } from "./calendar-model";
+
+/** Open chart section: intentionally no card surface or enclosing border. */
+export function HabitChart({
+ name,
+ method,
+ value,
+ target,
+ unit,
+ color,
+ children,
+ tasks,
+ calendar,
+ schedule = "Every day",
+ due = true,
+ id,
+}: {
+ name: string;
+ method: string;
+ value: number;
+ target: number;
+ unit: string;
+ color: string;
+ children?: ReactNode;
+ tasks?: ReactNode;
+ calendar?: ReactNode;
+ schedule?: string;
+ due?: boolean;
+ id?: string;
+}) {
+ const headingId = useId();
+ return (
+
+
+
+
+ {due
+ ? `${value} of ${target} ${unit}`
+ : "A day off isn’t a missed day."}
+
+
+ {!due
+ ? "Not scheduled"
+ : value >= target
+ ? "Complete"
+ : "In progress"}
+
+
+ {calendar ?? (
+
+ )}
+ {tasks && (
+
+
+ Tasks for today{" "}
+
+ {value} / {target}
+
+
+ {tasks}
+
+ )}
+
+ );
+}
diff --git a/src/components/design-system/HabitColorPicker.test.ts b/src/components/design-system/HabitColorPicker.test.ts
new file mode 100644
index 0000000..959e19d
--- /dev/null
+++ b/src/components/design-system/HabitColorPicker.test.ts
@@ -0,0 +1,48 @@
+import { expect, test } from "bun:test";
+import {
+ HABIT_PALETTES,
+ isHabitColor,
+ previewHabitColors,
+} from "./HabitColorPicker";
+import { HABIT_COLORS } from "./calendar-model";
+
+test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
+ const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
+ expect(HABIT_PALETTES.length).toBe(8);
+ expect(HABIT_PALETTES.every((palette) => palette.colors.length === 4)).toBe(true);
+ expect(new Set(colors).size).toBe(32);
+ expect(colors.every(isHabitColor)).toBe(true);
+});
+
+test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {
+ for (const color of ["#123456", "#abcdef", "#ABCDEF", "#ffffff", "#000000"])
+ expect(isHabitColor(color)).toBe(true);
+ for (const color of ["", "red", "123456", "#fff", "#gggggg", "#12345678"])
+ expect(isHabitColor(color)).toBe(false);
+});
+
+test("a draft color updates only its habit and cancel restores the saved color", () => {
+ const habits = [
+ { id: "water", color: HABIT_COLORS.water },
+ { id: "reading", color: "#123456" },
+ ];
+ const preview = previewHabitColors(habits, "water", "#9c647c");
+ expect(preview.water).toBe("#9c647c");
+ expect(preview.reading).toBe("#123456");
+ expect(preview.movement).toBe(HABIT_COLORS.movement);
+ expect(previewHabitColors(habits, "water", habits[0]!.color).water).toBe(
+ HABIT_COLORS.water,
+ );
+ expect(habits[0]!.color).toBe(HABIT_COLORS.water);
+});
+
+test("invalid drafts fall back to saved colors and saved edits survive selecting another habit", () => {
+ const habits = [
+ { id: "water", color: "#9c647c" },
+ { id: "reading", color: HABIT_COLORS.reading },
+ ];
+ expect(previewHabitColors(habits, "water", "#zz").water).toBe("#9c647c");
+ expect(previewHabitColors(habits, "reading", "#654321").water).toBe(
+ "#9c647c",
+ );
+});
diff --git a/src/components/design-system/HabitColorPicker.tsx b/src/components/design-system/HabitColorPicker.tsx
new file mode 100644
index 0000000..5c19765
--- /dev/null
+++ b/src/components/design-system/HabitColorPicker.tsx
@@ -0,0 +1,153 @@
+import { useId } from "react";
+import { HABIT_COLORS, progressShade } from "./calendar-model";
+
+export type HabitColors = {
+ -readonly [K in keyof typeof HABIT_COLORS]: string;
+};
+
+export function previewHabitColors(
+ habits: { id: string; color: string }[],
+ selectedId: string,
+ draftColor: string,
+): HabitColors {
+ const colors: HabitColors = { ...HABIT_COLORS };
+ for (const habit of habits) {
+ if (!Object.hasOwn(colors, habit.id)) continue;
+ colors[habit.id as keyof HabitColors] =
+ habit.id === selectedId && isHabitColor(draftColor)
+ ? draftColor
+ : habit.color;
+ }
+ return colors;
+}
+
+export const HABIT_PALETTES = [
+ { name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
+ { name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
+ { name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
+ { name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
+ { name: "Citrus", colors: ["#b45309", "#a16207", "#9a5b35", "#7a801c"] },
+ { name: "Blossom", colors: ["#b05276", "#a34e62", "#a65f5a", "#8e5572"] },
+ { name: "Jewel", colors: ["#6d28a8", "#1d678d", "#087f73", "#a12e54"] },
+ { name: "Slate", colors: ["#475569", "#52616b", "#65605b", "#404047"] },
+] as const;
+
+export function isHabitColor(value: string) {
+ return /^#[0-9a-f]{6}$/i.test(value);
+}
+
+export function HabitColorPicker({
+ value,
+ onChange,
+ mode = "demo",
+}: {
+ value: string;
+ onChange: (color: string) => void;
+ mode?: "demo" | "create" | "edit";
+}) {
+ const id = useId();
+ const valid = isHabitColor(value);
+ return (
+
+ Habit color
+
+ A color for this habit and its calendar. Choose a suggested shade or
+ make it your own.
+
+
+ {HABIT_PALETTES.map((palette) => (
+
+
{palette.name}
+
+ {palette.colors.map((color) => (
+ onChange(color)}
+ >
+
+ {value.toLowerCase() === color ? "✓" : ""}
+
+
+ ))}
+
+
+ ))}
+
+
+ {valid && (
+
+
Calendar preview
+
+ {Array.from({ length: 9 }, (_, count) => (
+
+ ))}
+
+
0 → complete
+
+ )}
+
+ {mode === "create"
+ ? "Your chosen color is saved with your habit and used in its progress calendar."
+ : mode === "edit"
+ ? "Save changes to apply this color to your habit’s calendar. Cancel keeps your current color."
+ : "Previews update immediately, including the charts above. Save to keep this color in the demo; Cancel to revert."}
+
+ {!valid && (
+
+ Use a six-digit hex color, such as #426582.
+
+ )}
+
+ );
+}
diff --git a/src/components/design-system/calendar-model.test.ts b/src/components/design-system/calendar-model.test.ts
new file mode 100644
index 0000000..599891a
--- /dev/null
+++ b/src/components/design-system/calendar-model.test.ts
@@ -0,0 +1,124 @@
+import { expect, test } from "bun:test";
+import {
+ combinedProgress,
+ demoCalendar,
+ describeDay,
+ progressShade,
+ PROGRESS_SHADES,
+ monthWindow,
+ visibleCalendarDays,
+ MONTH_VIEWS,
+ legendShades,
+} from "./calendar-model";
+
+test("legends match habit colors and only display possible progress shades", () => {
+ for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
+ for (const target of [1, 3, 4, 8, 20]) {
+ const shades = legendShades(demoCalendar(0, target), color);
+ expect(shades).toHaveLength(Math.min(target, 8) + 1);
+ expect(shades[0]).toBe(PROGRESS_SHADES[0]);
+ expect(shades.at(-1)).toBe(color);
+ for (let value = 0; value <= target; value++) {
+ expect(shades).toContain(
+ progressShade({ date: "", value, target, state: "due" }, color),
+ );
+ }
+ }
+ }
+ expect(legendShades(demoCalendar(0, 0))).toEqual([PROGRESS_SHADES[0]]);
+ expect(legendShades([])).toEqual([PROGRESS_SHADES[0]]);
+});
+
+test("eight glasses have eight positive shades plus empty; 7/8 remains incomplete", () => {
+ const days = Array.from({ length: 9 }, (_, value) => ({
+ date: "2026-09-04",
+ value,
+ target: 8,
+ state: "due" as const,
+ }));
+ expect(new Set(days.map((day) => progressShade(day))).size).toBe(9);
+ for (const color of ["#426582", "#977344", "#79618d", "#58765b"]) {
+ expect(new Set(days.map((day) => progressShade(day, color))).size).toBe(9);
+ expect(progressShade(days[8]!, color)).toBe(color);
+ }
+ expect(describeDay(days[7]!, "glasses")).toBe("7 of 8 glasses · Incomplete");
+ expect(progressShade({ ...days[8]!, value: 10 })).toBe(PROGRESS_SHADES[8]);
+});
+
+test("combined progress counts only completed due habits with equal weights", () => {
+ expect(
+ combinedProgress([
+ { value: 7, target: 8, due: true },
+ { value: 1, target: 1, due: true },
+ { value: 2, target: 3, due: true },
+ { value: 0, target: 1, due: false },
+ ]),
+ ).toEqual({ completed: 1, due: 3 });
+ expect(combinedProgress([{ value: 0, target: 0, due: true }])).toEqual({
+ completed: 0,
+ due: 0,
+ });
+});
+
+test("future and zero share a fill while inspection retains their distinct meaning", () => {
+ const days = demoCalendar(7, 8);
+ expect(days).toHaveLength(365);
+ expect(days.find((day) => day.date === "2026-09-04")?.value).toBe(7);
+ expect(
+ describeDay(
+ days.find((day) => day.state === "not-due")!,
+ "glasses",
+ ),
+ ).toContain("Nothing scheduled");
+ expect(describeDay(days.at(-1)!, "glasses")).toContain("Upcoming");
+ const zero = {
+ date: "2026-09-04",
+ value: 0,
+ target: 8,
+ state: "due" as const,
+ };
+ for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
+ expect(progressShade(days.at(-1)!, color)).toBe(progressShade(zero, color));
+ expect(progressShade(days.at(-1)!, color)).toBe(PROGRESS_SHADES[0]);
+ }
+ expect(describeDay(zero, "glasses")).toBe("0 of 8 glasses · Incomplete");
+});
+
+test("month presets include exact whole months and cross year and leap-year boundaries", () => {
+ expect(
+ MONTH_VIEWS.map((months) => monthWindow("2026-09-04", months)),
+ ).toEqual([
+ { from: "2026-07-01", to: "2026-09-30" },
+ { from: "2026-06-01", to: "2026-09-30" },
+ { from: "2026-04-01", to: "2026-09-30" },
+ { from: "2025-10-01", to: "2026-09-30" },
+ ]);
+ expect(monthWindow("2024-02-29", 3)).toEqual({
+ from: "2023-12-01",
+ to: "2024-02-29",
+ });
+});
+
+test("switching month views preserves dated values, complete coverage and future states", () => {
+ const days = demoCalendar(7, 8);
+ expect(
+ MONTH_VIEWS.map(
+ (months) => visibleCalendarDays(days, months, "2026-09-04").length,
+ ),
+ ).toEqual([92, 122, 183, 365]);
+ for (const months of MONTH_VIEWS) {
+ const visible = visibleCalendarDays(days, months, "2026-09-04");
+ expect(visible.find((day) => day.date === "2026-09-04")).toEqual(
+ days.find((day) => day.date === "2026-09-04"),
+ );
+ expect(visible.filter((day) => day.state === "future")).toHaveLength(26);
+ expect(new Set(visible.map((day) => day.date)).size).toBe(visible.length);
+ }
+ expect(visibleCalendarDays([], 3, "2026-09-04")).toEqual([]);
+});
+
+test("live calendars preserve server colors and historical units", () => {
+ const day = { date: "2026-09-03", value: 3, target: 8, state: "due" as const, color: "#abcdef", unit: "pages" };
+ expect(progressShade(day, "#111111")).toBe("#abcdef");
+ expect(describeDay(day, "glasses")).toBe("3 of 8 pages · Incomplete");
+});
diff --git a/src/components/design-system/calendar-model.ts b/src/components/design-system/calendar-model.ts
new file mode 100644
index 0000000..bfaeafc
--- /dev/null
+++ b/src/components/design-system/calendar-model.ts
@@ -0,0 +1,139 @@
+export const PROGRESS_SHADES = [
+ "#eeeeee",
+ "#d8d8d8",
+ "#bfbfbf",
+ "#a5a5a5",
+ "#8a8a8a",
+ "#707070",
+ "#555555",
+ "#363636",
+ "#111111",
+] as const;
+export type CalendarDay = {
+ color?: string;
+ unit?: string;
+ date: string;
+ value: number;
+ target: number;
+ state: "due" | "not-due" | "future";
+};
+
+export const MONTH_VIEWS = [3, 4, 6, 12] as const;
+export type MonthView = (typeof MONTH_VIEWS)[number];
+
+/** Whole calendar months, including the month containing the anchor date. */
+export function monthWindow(anchor: string, months: MonthView) {
+ const date = new Date(`${anchor}T12:00:00Z`);
+ const first = new Date(
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - months + 1, 1),
+ );
+ const last = new Date(
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
+ );
+ return {
+ from: first.toISOString().slice(0, 10),
+ to: last.toISOString().slice(0, 10),
+ };
+}
+
+export function visibleCalendarDays(
+ days: CalendarDay[],
+ months: MonthView,
+ anchor: string,
+) {
+ const range = monthWindow(anchor, months);
+ return days.filter((day) => day.date >= range.from && day.date <= range.to);
+}
+
+export const HABIT_COLORS = {
+ water: "#426582",
+ reading: "#977344",
+ tasks: "#79618d",
+ movement: "#58765b",
+} as const;
+
+export function progressShade(day: CalendarDay, color = "#111111") {
+ if (day.color) return day.color;
+ if (day.state === "not-due") return "#ffffff";
+ if (day.state === "future") return PROGRESS_SHADES[0];
+ if (day.target <= 0 || day.value <= 0) return PROGRESS_SHADES[0];
+ const step = Math.max(
+ 1,
+ Math.min(8, Math.round((day.value / day.target) * 8)),
+ );
+ if (color === "#111111" || !/^#[0-9a-f]{6}$/i.test(color))
+ return PROGRESS_SHADES[step]!;
+ const weight = step / 8;
+ return `#${[1, 3, 5]
+ .map((offset) =>
+ Math.round(
+ 238 + (parseInt(color.slice(offset, offset + 2), 16) - 238) * weight,
+ )
+ .toString(16)
+ .padStart(2, "0"),
+ )
+ .join("")}`;
+}
+
+export function describeDay(day: CalendarDay, unit: string) {
+ if (day.state === "future") return "Upcoming · Logging is not available yet";
+ if (day.state === "not-due")
+ return "Nothing scheduled · Not included in the score";
+ return `${day.value} of ${day.target} ${day.unit ?? unit} · ${day.value >= day.target ? "Complete" : "Incomplete"}`;
+}
+
+/** Only show shades that the chart's completion targets can produce. */
+export function legendShades(days: CalendarDay[], color = "#111111") {
+ const levels = new Set();
+ for (const target of new Set(days.map((day) => day.target))) {
+ if (!Number.isFinite(target) || target <= 0) continue;
+ for (let value = 1; value <= Math.min(target, 8); value++) {
+ levels.add(
+ target > 8 ? value : Math.max(1, Math.round((value / target) * 8)),
+ );
+ }
+ }
+ return [
+ PROGRESS_SHADES[0],
+ ...[...levels]
+ .sort((a, b) => a - b)
+ .map((value) =>
+ progressShade({ date: "", value, target: 8, state: "due" }, color),
+ ),
+ ];
+}
+
+export function combinedProgress(
+ habits: { value: number; target: number; due: boolean }[],
+) {
+ const due = habits.filter((habit) => habit.due && habit.target > 0);
+ return {
+ completed: due.filter((habit) => habit.value >= habit.target).length,
+ due: due.length,
+ };
+}
+
+/** A full 12 calendar months of stable demo history, including future dates. */
+export function demoCalendar(value: number, target: number): CalendarDay[] {
+ return Array.from({ length: 365 }, (_, index) => {
+ const timestamp = Date.UTC(2025, 9, 1 + index);
+ // Keep the previously displayed dates' fixture values unchanged.
+ const fixtureIndex = (timestamp - Date.UTC(2026, 2, 8)) / 86_400_000;
+ const rawValue = fixtureIndex * 17 + Math.floor(fixtureIndex / 7) * 3;
+ const date = new Date(timestamp).toISOString().slice(0, 10);
+ return {
+ date,
+ target,
+ value:
+ date === "2026-09-04"
+ ? value
+ : ((rawValue % (target + 1)) + target + 1) % (target + 1),
+ state:
+ date > "2026-09-04"
+ ? "future"
+ : fixtureIndex % 19 === 0
+ ? "not-due"
+ : "due",
+ };
+ });
+}
diff --git a/src/components/design-system/editing-model.test.ts b/src/components/design-system/editing-model.test.ts
new file mode 100644
index 0000000..c28a49a
--- /dev/null
+++ b/src/components/design-system/editing-model.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, test } from "bun:test";
+import {
+ EDITOR_HABITS,
+ backfillError,
+ historicalDays,
+ validateEditor,
+ validateTasks,
+} from "./editing-model";
+
+describe("design-system editors", () => {
+ test("all initial configurations are valid", () => {
+ for (const habit of EDITOR_HABITS)
+ expect(validateEditor(habit.config)).toBeNull();
+ });
+ test("requires a name, valid target, and nonempty weekday schedule", () => {
+ const config = EDITOR_HABITS[0]!.config;
+ expect(validateEditor({ ...config, name: " " })).not.toBeNull();
+ if (config.method !== "count") throw new Error("Expected count fixture");
+ for (const target of [0, 1.5, NaN, 10001])
+ expect(validateEditor({ ...config, target })).not.toBeNull();
+ expect(
+ validateEditor({ ...config, schedule: { type: "weekdays", days: [] } }),
+ ).not.toBeNull();
+ });
+ test("validates task names and independent recurrence", () => {
+ expect(
+ validateTasks([{ id: "1", name: "", schedule: { type: "daily" } }]),
+ ).not.toBeNull();
+ expect(
+ validateTasks([
+ {
+ id: "1",
+ name: "Read",
+ schedule: { type: "interval", every: 0, anchor: "2026-09-04" },
+ },
+ ]),
+ ).not.toBeNull();
+ expect(validateTasks([])).toBeNull();
+ });
+ test("backfills reject today, future, nonexistent and unscheduled dates", () => {
+ for (const date of [
+ "2026-09-04",
+ "2026-09-05",
+ "2026-02-30",
+ "2025-09-30",
+ "",
+ ])
+ expect(backfillError("water", date, 1)).not.toBeNull();
+ const notDue = historicalDays("water").find(
+ (day) => day.state === "not-due",
+ )!;
+ expect(backfillError("water", notDue.date, 1)).not.toBeNull();
+ });
+ test("corrections allow zero and over-target counts but reject invalid totals", () => {
+ for (const value of [0, 8, 12, 1_000_000_000])
+ expect(backfillError("water", "2026-09-03", value)).toBeNull();
+ for (const value of [-1, 1.5, NaN, 1_000_000_001])
+ expect(backfillError("water", "2026-09-03", value)).not.toBeNull();
+ expect(backfillError("reading", "2026-09-03", 2)).not.toBeNull();
+ expect(backfillError("tasks", "2026-09-03", 4)).not.toBeNull();
+ });
+ test("editing a current target or task list does not rewrite historical requirements", () => {
+ const copy = structuredClone(EDITOR_HABITS);
+ const water = copy[0]!.config;
+ const tasks = copy[2]!.config;
+ if (water.method === "count") water.target = 12;
+ if (tasks.method === "tasks") tasks.tasks.pop();
+ expect(historicalDays("water")[0]!.target).toBe(8);
+ expect(historicalDays("tasks")[0]!.target).toBe(3);
+ });
+});
diff --git a/src/components/design-system/editing-model.ts b/src/components/design-system/editing-model.ts
new file mode 100644
index 0000000..5a0d14b
--- /dev/null
+++ b/src/components/design-system/editing-model.ts
@@ -0,0 +1,110 @@
+import {
+ dateSchema,
+ habitInput,
+ taskInput,
+ type HabitConfig,
+} from "../../habits/contracts";
+import { demoCalendar, HABIT_COLORS } from "./calendar-model";
+
+export const EDITOR_TODAY = "2026-09-04";
+export const EDITOR_START = "2025-10-01";
+export const EDITOR_HABITS: {
+ id: string;
+ color: string;
+ config: HabitConfig;
+}[] = [
+ {
+ id: "water",
+ color: HABIT_COLORS.water,
+ config: {
+ name: "Drink water",
+ method: "count",
+ target: 8,
+ unit: "glasses",
+ carryPartialProgress: false,
+ schedule: { type: "daily" },
+ archived: false,
+ },
+ },
+ {
+ id: "reading",
+ color: HABIT_COLORS.reading,
+ config: {
+ name: "Read a little",
+ method: "manual",
+ schedule: { type: "daily" },
+ archived: false,
+ },
+ },
+ {
+ id: "tasks",
+ color: HABIT_COLORS.tasks,
+ config: {
+ name: "Evening reset",
+ method: "tasks",
+ schedule: { type: "daily" },
+ archived: false,
+ tasks: ["Clear desk", "Plan tomorrow", "Stretch"].map((name, index) => ({
+ id: `task-${index}`,
+ name,
+ schedule: { type: "daily" },
+ })),
+ },
+ },
+];
+
+export function validateEditor(config: HabitConfig): string | null {
+ const { archived: _, ...input } = config;
+ const result = habitInput.safeParse(
+ config.method === "tasks"
+ ? { ...input, tasks: config.tasks.map(({ id: _, ...task }) => task) }
+ : input,
+ );
+ return result.success
+ ? null
+ : (result.error.issues[0]?.message ?? "Check the habit settings.");
+}
+
+export function validateTasks(
+ tasks: Extract["tasks"],
+): string | null {
+ if (tasks.length > 100) return "Use no more than 100 tasks.";
+ for (const { id: _, ...task } of tasks) {
+ const result = taskInput.safeParse(task);
+ if (!result.success)
+ return result.error.issues[0]?.message ?? "Check each task.";
+ }
+ return null;
+}
+
+/** Frozen historical requirements deliberately do not read today's edited config. */
+export function historicalDays(id: string) {
+ const config = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
+ return demoCalendar(
+ 0,
+ config.method === "count"
+ ? config.target
+ : config.method === "tasks"
+ ? config.tasks.length
+ : 1,
+ );
+}
+
+export function backfillError(
+ id: string,
+ date: string,
+ value: number,
+): string | null {
+ if (!dateSchema.safeParse(date).success) return "Choose a valid date.";
+ if (date >= EDITOR_TODAY)
+ return "Choose a past date. Today is edited in the daily view.";
+ if (date < EDITOR_START) return "This habit did not exist on that date.";
+ const day = historicalDays(id).find((day) => day.date === date);
+ if (!day || day.state !== "due")
+ return "Nothing was scheduled on this date. There is no progress to correct.";
+ if (!Number.isInteger(value) || value < 0 || value > 1_000_000_000)
+ return "Enter a whole number from 0 to 1,000,000,000.";
+ if (id !== "water" && value > day.target)
+ return "Progress cannot exceed the scheduled items.";
+ return null;
+}
diff --git a/src/components/design-system/primitives.tsx b/src/components/design-system/primitives.tsx
new file mode 100644
index 0000000..4ba1c8e
--- /dev/null
+++ b/src/components/design-system/primitives.tsx
@@ -0,0 +1,124 @@
+import type {
+ ButtonHTMLAttributes,
+ InputHTMLAttributes,
+ ReactNode,
+} from "react";
+
+export function Button({
+ variant = "primary",
+ className = "",
+ type = "button",
+ ...props
+}: ButtonHTMLAttributes & {
+ variant?: "primary" | "secondary" | "text";
+}) {
+ return (
+
+ );
+}
+
+export function SectionHeading({
+ number,
+ title,
+ children,
+}: {
+ number: string;
+ title: string;
+ children?: ReactNode;
+}) {
+ return (
+
+ {number}
+ {title}
+ {children && {children}
}
+
+ );
+}
+
+export function Checkbox({
+ label,
+ className = "",
+ ...props
+}: Omit, "type"> & { label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+export function Counter({
+ label,
+ value,
+ target,
+ onChange,
+ disabled = false,
+}: {
+ label: string;
+ value: number;
+ target: number;
+ onChange: (value: number) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ onChange(Math.max(0, value - 1))}
+ >
+ −
+
+
+ {value}
+ / {target}
+
+ = target}
+ onClick={() => onChange(Math.min(target, value + 1))}
+ >
+ +
+
+
+ );
+}
+
+export function HabitRow({
+ name,
+ description,
+ complete,
+ children,
+ tasks,
+}: {
+ name: string;
+ description: string;
+ complete: boolean;
+ children: ReactNode;
+ tasks?: ReactNode;
+}) {
+ return (
+
+
+
+
{name}
+
{description}
+
+
+ {children}
+
+ {complete ? "Complete" : "In progress"}
+
+
+
+ {tasks &&
{tasks}
}
+
+ );
+}
diff --git a/src/habits/contracts.ts b/src/habits/contracts.ts
index 93e0919..6461b19 100644
--- a/src/habits/contracts.ts
+++ b/src/habits/contracts.ts
@@ -16,7 +16,7 @@ const schedule = scheduleSchema.default({ type: 'daily' });
export const taskInput = z.object({ name, schedule }).strict();
// Creation defaults must not become writes when PATCH omits a property.
export const taskPatch = z.object({ name: name.optional(), schedule: scheduleSchema.optional() }).strict().refine(v => Object.keys(v).length > 0);
-const common = { name, schedule };
+const common = { name, schedule, color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color').optional() };
export const habitInput = z.discriminatedUnion('method', [
z.object({ ...common, method: z.literal('count'), carryPartialProgress: z.boolean().default(false), target: z.number().int().min(1).max(10000), unit: z.string().trim().min(1).max(80).default('steps') }).strict(),
z.object({ ...common, method: z.literal('manual') }).strict(),
@@ -27,7 +27,7 @@ export type HabitConfig = { name: string; schedule: Schedule; archived: boolean
{ method: 'manual' } |
{ method: 'tasks'; tasks: { id: string; name: string; schedule: Schedule }[] }
);
-export const habitPatch = z.object({ carryPartialProgress: z.boolean().optional(), name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
+export const habitPatch = z.object({ color: common.color, carryPartialProgress: z.boolean().optional(), name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
export const progressInput = z.union([z.object({ count: z.number().int().min(0).max(1_000_000_000) }).strict(), z.object({ done: z.boolean() }).strict()]);
export const doneInput = z.object({ done: z.boolean() }).strict();
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color');
diff --git a/src/habits/resilience.test.ts b/src/habits/resilience.test.ts
index 67bf27b..ba25ec2 100644
--- a/src/habits/resilience.test.ts
+++ b/src/habits/resilience.test.ts
@@ -8,6 +8,36 @@ test('failed revision writes roll back the habit atomically and do not expose da
expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Internal server error' });
expect((await f.json('/habits')).habits).toHaveLength(0);
});
+
+test('creation saves color atomically and never puts presentation into requirements', async () => {
+ for (const method of ['manual', 'count', 'tasks']) {
+ const h = await f.json('/habits', 'POST', { name: method, method, color: '#79618d', ...(method === 'count' ? { target: 8 } : {}) }, 201);
+ expect((await f.json(`/habits/${h.id}/calendar-settings`)).mainColor).toBe('#79618d');
+ expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).requirements.color).toBeUndefined();
+ }
+ expect((await f.request('/habits', 'POST', { name: 'Invalid', method: 'manual', color: 'red' })).status).toBe(422);
+ expect((await f.json('/habits')).habits).toHaveLength(3);
+ f.sqlite.exec("CREATE TRIGGER reject_color BEFORE INSERT ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
+ const response = await f.request('/habits', 'POST', { name: 'Rollback color', method: 'manual', color: '#426582' });
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({ error: 'Internal server error' });
+ expect((await f.json('/habits')).habits).toHaveLength(3);
+});
+test('edits save color with requirements atomically, preserving history and other settings', async () => {
+ const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, color: '#426582' }, 201);
+ await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#426582', emptyColor: '#fafafa', shadeCount: 4 });
+ f.setTime('2026-09-05T12:00Z');
+ await f.json(`/habits/${h.id}`, 'PATCH', { target: 10, color: '#79618d' });
+ expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).target).toBe(8);
+ expect((await f.json(`/habits/${h.id}/calendar-settings`))).toMatchObject({ mainColor: '#79618d', emptyColor: '#fafafa', shadeCount: 4 });
+ expect((await f.json(`/habits/${h.id}`)).color).toBeUndefined();
+ expect((await f.request(`/habits/${h.id}`, 'PATCH', { color: 'red' })).status).toBe(422);
+ f.sqlite.exec("CREATE TRIGGER reject_color_update BEFORE UPDATE ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
+ expect((await f.request(`/habits/${h.id}`, 'PATCH', { name: 'Should roll back', target: 12, color: '#58765b' })).status).toBe(500);
+ expect((await f.json(`/habits/${h.id}`))).toMatchObject({ name: 'Water', target: 10 });
+ expect((await f.json(`/habits/${h.id}/calendar-settings`)).mainColor).toBe('#79618d');
+});
+
test('same-day method switches reset inherited progress even when returning to count', async () => {
const h = await f.json('/habits', 'POST', { name: 'Carry', method: 'count', target: 8, carryPartialProgress: true }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 }); f.setTime('2026-09-05T12:00Z');
diff --git a/src/habits/service.ts b/src/habits/service.ts
index 09d74b7..91ef649 100644
--- a/src/habits/service.ts
+++ b/src/habits/service.ts
@@ -55,12 +55,14 @@ export class HabitService {
}
create(input: ReturnType) {
const id = crypto.randomUUID();
- const config: HabitConfig = input.method === 'tasks'
- ? { ...input, archived: false, tasks: input.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
- : { ...input, archived: false };
+ const { color, ...requirements } = input;
+ const config: HabitConfig = requirements.method === 'tasks'
+ ? { ...requirements, archived: false, tasks: requirements.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
+ : { ...requirements, archived: false };
this.db.transaction(() => {
this.db.insert(habits).values({ id, userId: this.user.id, createdDate: this.today, createdAt: this.now }).run();
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
+ if (color) this.saveSettings(id, calendarSettingsSchema.parse({ mainColor: color }));
this.materialize(this.owned(id));
});
return this.current(id);
@@ -80,7 +82,13 @@ export class HabitService {
const config: HabitConfig = parsed.data.method === 'tasks'
? { ...parsed.data, tasks: old.method === 'tasks' ? old.tasks : [], archived: (patch.archived ?? old.archived) as boolean }
: { ...parsed.data, archived: (patch.archived ?? old.archived) as boolean };
- return this.revise(id, config);
+ return this.db.transaction(() => {
+ const updated = this.revise(id, config);
+ if (patch.color !== undefined) {
+ this.saveSettings(id, calendarSettingsSchema.parse({ ...this.settings(id), mainColor: patch.color }));
+ }
+ return updated;
+ });
}
revise(id: string, config: HabitConfig) {
this.invalidate(id);
diff --git a/src/index.html b/src/index.html
index 9659fa0..7a0075b 100644
--- a/src/index.html
+++ b/src/index.html
@@ -4,6 +4,7 @@
Minabot
+
diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts
new file mode 100644
index 0000000..ed3d50d
--- /dev/null
+++ b/src/lib/dashboard.ts
@@ -0,0 +1,48 @@
+import type { HabitService } from "../habits/service";
+import type { Schedule } from "../habits/contracts";
+
+export type TodayHabit = ReturnType;
+export type TodayResponse = {
+ date: string;
+ timezone: string;
+ habits: TodayHabit[];
+ due: number;
+ completed: number;
+};
+
+export async function habitRequest(
+ path: string,
+ options?: RequestInit,
+): Promise {
+ const response = await fetch(`/api${path}`, options);
+ if (!response.ok) {
+ if (response.status === 401)
+ throw new Error("Your session has expired. Sign in again to continue.");
+ const body = await response.json().catch(() => null);
+ throw new Error(
+ body?.error || "Could not reach your workspace. Please try again.",
+ );
+ }
+ return response.status === 204
+ ? (undefined as T)
+ : (response.json() as Promise);
+}
+
+export function scheduleLabel(schedule?: Schedule) {
+ if (!schedule || schedule.type === "daily") return "Every day";
+ if (schedule.type === "weekdays")
+ return schedule.days
+ .map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day])
+ .join(", ");
+ if (schedule.type === "interval") return `Every ${schedule.every} days`;
+ return `Every ${schedule.every === 1 ? "week" : `${schedule.every} weeks`} · ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][schedule.weekday]}`;
+}
+
+export function formatTrackingDate(date: string) {
+ return new Date(`${date}T12:00:00Z`).toLocaleDateString("en", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ timeZone: "UTC",
+ });
+}
diff --git a/src/pages/DesignSystem.tsx b/src/pages/DesignSystem.tsx
new file mode 100644
index 0000000..3b9b574
--- /dev/null
+++ b/src/pages/DesignSystem.tsx
@@ -0,0 +1,603 @@
+import { useState } from "react";
+import { Link } from "react-router";
+import {
+ Button,
+ Checkbox,
+ Counter,
+ SectionHeading,
+} from "../components/design-system/primitives";
+import { CalendarHeatmap } from "../components/design-system/CalendarHeatmap";
+import { HabitChart } from "../components/design-system/HabitChart";
+import { EditingWorkbench } from "../components/design-system/EditingWorkbench";
+import type { HabitColors } from "../components/design-system/HabitColorPicker";
+import {
+ combinedProgress,
+ demoCalendar,
+ PROGRESS_SHADES,
+ HABIT_COLORS,
+} from "../components/design-system/calendar-model";
+import todayImage from "../assets/design/today-v2.png";
+import habitImage from "../assets/design/habit-detail.png";
+import "../../styles/design-system.css";
+
+type View = "Today" | "Habit detail" | "Combined chart";
+
+export function DesignSystem() {
+ const [habitColors, setHabitColors] = useState({ ...HABIT_COLORS });
+ const [view, setView] = useState("Today");
+ const [water, setWater] = useState(7);
+ const [read, setRead] = useState(true);
+ const [tasks, setTasks] = useState([true, true, false]);
+ const [movement, setMovement] = useState(10);
+ const [included, setIncluded] = useState([true, true, true, true]);
+ const [exampleChecked, setExampleChecked] = useState(false);
+ const [exampleCount, setExampleCount] = useState(3);
+ const [habitName, setHabitName] = useState("");
+ const [savedName, setSavedName] = useState("");
+ const taskCount = tasks.filter(Boolean).length;
+ const habits = [
+ { value: water, target: 8, due: true },
+ { value: Number(read), target: 1, due: true },
+ { value: taskCount, target: 3, due: true },
+ { value: movement, target: 20, due: true },
+ ];
+ const total = combinedProgress(habits);
+ const combined = combinedProgress(
+ habits.filter((_, index) => included[index]),
+ );
+ const isIndividual = view === "Habit detail";
+ const chartDays = demoCalendar(
+ isIndividual ? water : combined.completed,
+ isIndividual ? 8 : combined.due,
+ ).map((day) =>
+ !isIndividual && combined.due === 0 && day.state !== "future"
+ ? { ...day, state: "not-due" as const }
+ : day,
+ );
+ function resetDemo() {
+ setWater(7);
+ setRead(true);
+ setTasks([true, true, false]);
+ setMovement(10);
+ setIncluded([true, true, true, true]);
+ }
+
+ return (
+
+
+ Skip to content
+
+
+
+
+
+
MINABOT — INTERFACE LANGUAGE
+
+ Less interface.
+
+ More intention.
+
+
+ A quiet foundation for everyday progress.
+ A quiet canvas. A color for every habit.
+
+
+
+
+
+
+
+
+ 01 / IN PRACTICE
+
+ A little, every day.
+
+
+
+ ◦ Interactive demo · not saved
+
+
+
+
+ {(["Today", "Habit detail", "Combined chart"] as const).map(
+ (item) => (
+ setView(item)}
+ >
+ {item}
+
+ ),
+ )}
+
+
+ Reset demo ↺
+
+
+
+
+
FRIDAY, SEPTEMBER 4, 2026
+
+ {view === "Today"
+ ? "Your habits, at a glance."
+ : isIndividual
+ ? "Drink water"
+ : "Your rhythm, together."}
+
+
+
+ {view === "Today"
+ ? `${total.completed} of ${total.due} complete`
+ : isIndividual
+ ? "Daily · 8 glasses"
+ : combined.due
+ ? `${combined.completed} of ${combined.due} habits complete`
+ : "No habits selected"}
+
+
+ {view === "Today" ? (
+
+
+
+
+
+ setRead(event.target.checked)}
+ />
+
+
(
+
+ setTasks((current) =>
+ current.map((done, i) =>
+ i === index ? event.target.checked : done,
+ ),
+ )
+ }
+ />
+ ),
+ )}
+ />
+
+
+
+
+ Each habit has its own rhythm. Expand tasks to log your day;
+ only fully completed habits count in a combined chart.
+
+
+ ) : (
+
+ {isIndividual ? (
+
+
+ {water}
+ / 8
+ glasses
+
+
+
+ ) : (
+
+ {[
+ "Drink water",
+ "Read a little",
+ "Evening reset",
+ "Move a little",
+ ].map((label, index) => (
+
+ setIncluded((current) =>
+ current.map((checked, i) =>
+ i === index ? event.target.checked : checked,
+ ),
+ )
+ }
+ />
+ ))}
+
+ )}
+
+
+ {isIndividual
+ ? "Partial progress is visible here. Only 8 of 8 glasses counts as complete in a combined chart."
+ : "Equal weight. Only fully completed, due habits count. Nothing scheduled means no score, not a missed day."}
+
+
+ )}
+
+
+
+
+
+
+ A small vocabulary.
+
+ Used with intention.
+
+
+
+
Typography
+ Instrument Serif + system sans
+
+
+
+ Small steps.
+
+ Lasting rhythm.
+
+
Instrument Serif · Regular & italic · Display
+
+
+
Room to breathe. A clear next step.
+
System sans · 14–16px · Body & controls
+
+
+
Monochrome
+ The interface stays neutral
+
+
+ {[
+ { name: "Ink", value: "#111111" },
+ { name: "Secondary", value: "#666666" },
+ { name: "Rule", value: "#DEDEDE" },
+ { name: "Paper", value: "#FFFFFF" },
+ ].map((color) => (
+
+
+
+ {color.name}
+ {color.value}
+
+
+ ))}
+
+
+
Habit colors
+ Identity, not decoration
+
+
+ {Object.entries(HABIT_COLORS).map(([name, color]) => (
+
+
+ {name}
+
+ ))}
+
+
+
Space & structure
+ 4px base
+
+
+ {[4, 8, 16, 24, 32, 48].map((space) => (
+
+
+ {space}
+
+ ))}
+
+
+ Square corners. One-pixel rules. No cards, shadows, or decorative
+ surfaces.
+
+
+
+
+
+
+ Purpose-built primitives.
+
+ Try them for yourself.
+
+
+
+
Actions
+ Button
+
+
+ {
+ document.getElementById("habit-name")?.focus();
+ }}
+ >
+ Add a habit +
+
+ {
+ setExampleCount(3);
+ setExampleChecked(false);
+ setHabitName("");
+ setSavedName("");
+ }}
+ >
+ Reset examples
+
+ {
+ setView("Habit detail");
+ document.getElementById("playground")?.scrollIntoView();
+ }}
+ >
+ View habit ↗
+
+ Unavailable
+
+
+
+
A simple yes
+ Checkbox
+
+
setExampleChecked(event.target.checked)}
+ />
+
+
+
+
A little more
+ Counter · bounded 0–8
+
+
+
+
{
+ event.preventDefault();
+ if (habitName.trim()) setSavedName(habitName.trim());
+ }}
+ >
+
+ Give it a name
+ Text input
+
+
+ {
+ setHabitName(event.target.value);
+ setSavedName("");
+ }}
+ aria-describedby="name-hint"
+ />
+
+ Try it ↗
+
+
+
+ {savedName
+ ? `“${savedName}” — preview accepted. No habit was created.`
+ : "Keep it short and personal. This is a form preview, not a new habit."}
+
+
+
+
+
+
+
+ Progress is a spectrum.
+
+ Completion is a clear rule.
+
+
+
+
One glass at a time
+ CalendarHeatmap
+
+
+ {PROGRESS_SHADES.map((color, index) => (
+
+
+ {index}/8
+
+ ))}
+
+
+ Due · no progress
+ Partial progress
+ Complete
+
+
+
+
+
+
Nothing scheduled
+
A neutral dot, not a missed day. Excluded from the score.
+
+
+
+
+
+
Upcoming
+
Same fill as zero. Labeled Upcoming on inspection.
+
+
+
+
+ Calendar cells expose exact values and status on selection, hover,
+ and keyboard focus. Shade is never the only signal.
+
+
+
+
+
+
+
+ 06 / VISUAL DIRECTION
+
From idea to interface.
+
+
+ Two imagegen studies, grounded in the PRD.
+
+
+
+
+
+
+
+
+ 01 — Today
+
+ Daily progress ↗
+
+
+
+
+
+
+
+
+ 02 — Habit detail
+
+ A closer look ↗
+
+
+
+
+
+ Visual references, not authoritative calendar data. The interactive
+ components above implement exact counts and distinct date states.
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 028daa2..1fecb90 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,3 +1,744 @@
-export function Home() {
- return Home ;
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type FormEvent,
+} from "react";
+import { Link } from "react-router";
+import { DeleteHabit } from "../components/DeleteHabit";
+import { useAuth } from "../components/AuthProvider";
+import {
+ Button,
+ Checkbox,
+ Counter,
+} from "../components/design-system/primitives";
+import { CreateHabit, type HabitStarter } from "../components/CreateHabit";
+import { HabitHistory } from "../components/HabitHistory";
+import {
+ formatTrackingDate,
+ habitRequest,
+ scheduleLabel,
+ type TodayHabit,
+ type TodayResponse,
+} from "../lib/dashboard";
+import "../../styles/design-system.css";
+import "../../styles/home.css";
+
+const starters: (HabitStarter & { description: string; symbol: string })[] = [
+ {
+ name: "Read a little",
+ method: "manual",
+ description: "A few pages. A moment for yourself.",
+ symbol: "↗",
+ color: "#977344",
+ },
+ {
+ name: "Drink water",
+ method: "count",
+ target: 8,
+ unit: "glasses",
+ description: "One glass at a time, throughout the day.",
+ symbol: "+",
+ color: "#426582",
+ },
+ {
+ name: "Evening reset",
+ method: "tasks",
+ tasks: "Clear desk\nPlan tomorrow\nStretch",
+ description: "A small routine to close the day.",
+ symbol: "☾",
+ color: "#79618d",
+ },
+];
+
+function CountEntry({
+ habit,
+ disabled,
+ onSave,
+}: {
+ habit: TodayHabit;
+ disabled: boolean;
+ onSave: (value: number) => void;
+}) {
+ const [value, setValue] = useState(String(habit.value));
+ useEffect(() => setValue(String(habit.value)), [habit.value]);
+ function submit(event: FormEvent) {
+ event.preventDefault();
+ if (
+ value.trim() &&
+ Number.isInteger(Number(value)) &&
+ Number(value) >= 0 &&
+ Number(value) <= 1_000_000_000
+ )
+ onSave(Number(value));
+ }
+ return (
+
+ Set a total
+
+ Total {habit.unit}
+ setValue(event.target.value)}
+ />
+
+ Save total
+
+
+
+ );
+}
+
+export function Home() {
+ const { user, signOut, busy: authBusy, error: authError, signIn } = useAuth();
+ const [today, setToday] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [notice, setNotice] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [filter, setFilter] = useState<"all" | "remaining">("all");
+ const [starter, setStarter] = useState(null);
+ const [editing, setEditing] = useState<{
+ habit: TodayHabit;
+ color: string;
+ } | null>(null);
+ const [deleting, setDeleting] = useState(null);
+ const [revision, setRevision] = useState(0);
+ const [failedAvatar, setFailedAvatar] = useState(null);
+ const requestId = useRef(0);
+ const mutation = useRef(false);
+ const mounted = useRef(true);
+
+ const refresh = useCallback(async () => {
+ const id = ++requestId.current;
+ try {
+ const result = await habitRequest("/today");
+ if (mounted.current && id === requestId.current) {
+ setToday(result);
+ setError("");
+ setRevision((value) => value + 1);
+ }
+ } catch (error) {
+ if (mounted.current && id === requestId.current)
+ setError(
+ error instanceof Error
+ ? error.message
+ : "Could not load your habits.",
+ );
+ } finally {
+ if (mounted.current && id === requestId.current) setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ mounted.current = true;
+ void refresh();
+ const refreshVisible = () => {
+ if (!mutation.current && document.visibilityState !== "hidden")
+ void refresh();
+ };
+ const timer = window.setInterval(refreshVisible, 60_000);
+ window.addEventListener("focus", refreshVisible);
+ document.addEventListener("visibilitychange", refreshVisible);
+ return () => {
+ mounted.current = false;
+ ++requestId.current;
+ window.clearInterval(timer);
+ window.removeEventListener("focus", refreshVisible);
+ document.removeEventListener("visibilitychange", refreshVisible);
+ };
+ }, [refresh]);
+
+ async function log(
+ habit: TodayHabit,
+ value: { count: number } | { done: boolean },
+ taskId?: string,
+ ) {
+ if (mutation.current || !today) return;
+ mutation.current = true;
+ ++requestId.current;
+ setBusy(true);
+ setError("");
+ setNotice("");
+ try {
+ const updated = await habitRequest(
+ `/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(value),
+ },
+ );
+ if (!mounted.current) return;
+ setToday((current) => {
+ if (!current || current.date !== updated.date) return current;
+ const habits = current.habits.map((item) =>
+ item.habitId === updated.habitId ? updated : item,
+ );
+ return {
+ ...current,
+ habits,
+ due: habits.filter((item) => item.due).length,
+ completed: habits.filter((item) => item.complete).length,
+ };
+ });
+ setNotice(`${habit.name} updated. Your progress is saved.`);
+ await refresh();
+ } catch (error) {
+ if (mounted.current)
+ setError(
+ error instanceof Error
+ ? error.message
+ : "Could not save progress. Please try again.",
+ );
+ } finally {
+ mutation.current = false;
+ if (mounted.current) setBusy(false);
+ }
+ }
+
+ if (!user) return null;
+ const empty = today?.habits.length === 0;
+ const due = today?.habits.filter((habit) => habit.due) ?? [];
+ const offDay = today?.habits.filter((habit) => !habit.due) ?? [];
+ const shown =
+ filter === "remaining" ? due.filter((habit) => !habit.complete) : due;
+ const remaining = today ? today.due - today.completed : 0;
+ const name = user.displayName || user.username;
+
+ return (
+
+
+ Skip to content
+
+
+
+
+
+
YOUR SPACE / YOUR OWN PACE
+
+ {empty ? "Welcome home," : "A little today,"}
+
+ {name}.
+
+
+ {empty
+ ? "This is your space to build a rhythm. Let’s start with one small thing."
+ : "Pick up where you are. Make room for what matters."}
+
+
+
+
+ +
+
+ {today ? (
+ <>
+
+ {formatTrackingDate(today.date)}
+
+
{today.timezone.replaceAll("_", " ")}
+ >
+ ) : (
+
Your daily home base.
+ )}
+
A new day. Your own pace.
+
+
+ {authError && (
+
+ {authError}
+
+ )}
+ {error && (
+
+
+ {error}
+ {today && " The view below may be out of date."}
+
+ {error.includes("session has expired") ? (
+
+ Sign in again
+
+ ) : (
+
{
+ setLoading(!today);
+ void refresh();
+ }}
+ >
+ Try again
+
+ )}
+
+ )}
+
+ {notice}
+
+ {loading && (
+
+ Getting your day ready…
+
+ )}
+ {today && (
+ <>
+
+
+
+ {empty ? "A FRESH START" : "TODAY’S PROGRESS"}
+
+
+ {today.completed}
+ / {today.due}
+
+
habits complete
+
+
+
+
+ {empty ? "ONE SMALL STEP" : "STILL TO COME"}
+
+
+ {empty ? "01" : String(remaining).padStart(2, "0")}
+
+
+ {empty
+ ? "is all it takes to begin"
+ : remaining === 1
+ ? "habit left for today"
+ : "habits left for today"}
+
+
+
+
+ {empty
+ ? "NO PERFECT START REQUIRED"
+ : today.due === 0
+ ? "ROOM TO REST"
+ : remaining === 0
+ ? "ENOUGH FOR TODAY"
+ : "A GENTLE REMINDER"}
+
+
+ {empty ? (
+ <>
+ Small is a good start.
+ >
+ ) : today.due === 0 ? (
+ <>
+ A day off counts, too.
+ >
+ ) : remaining === 0 ? (
+ <>
+ You showed up. Enjoy that.
+ >
+ ) : (
+ <>
+ A little is still something.
+ >
+ )}
+
+
+ {empty
+ ? "Choose something easy enough to come back to tomorrow."
+ : today.due === 0
+ ? "Nothing is scheduled today. Your habits will be here when it’s time."
+ : remaining === 0
+ ? "Everything scheduled for today is complete. No extra credit needed."
+ : "Check in as you go. Every bit of progress belongs here."}
+
+
+
+
+
+
+ 01 / YOUR DAILY CHECK-IN
+
+ {empty ? (
+ <>
+ Begin with a little.
+ >
+ ) : (
+ <>
+ Make today your own.
+ >
+ )}
+
+
+
setStarter({ name: "", method: "manual" })}
+ >
+ {empty ? "Create your first habit" : "New habit"}
+ +
+
+
+ {empty ? (
+
+
No habits yet. No catching up to do.
+
+ Create your own, or use an idea below as a starting point.
+ You can make it yours before saving.
+
+
+ {starters.map((item) => (
+
setStarter(item)}
+ >
+
+ {item.symbol}
+
+ {item.name}
+ {item.description}
+
+ Make it mine ↗
+
+
+ ))}
+
+
+ ) : (
+ <>
+
+
+ setFilter("all")}
+ >
+ All today {today.due}
+
+ setFilter("remaining")}
+ >
+ Remaining {remaining}
+
+
+
Saved as you go
+
+ {shown.length === 0 && (
+
+
+ {today.due === 0
+ ? "A little breathing room."
+ : "You’re all caught up."}
+
+
+ {today.due === 0
+ ? "No habits are scheduled for today. A day off isn’t a missed day."
+ : "All of today’s habits are complete. Take a moment for yourself."}
+
+ {filter === "remaining" && today.due > 0 && (
+
setFilter("all")}>
+ View completed habits →
+
+ )}
+
+ )}
+
+ {shown.map((habit) => (
+
+
+
+
{habit.name}
+
+ {habit.method === "manual"
+ ? "SIMPLE CHECK-IN"
+ : habit.method === "count"
+ ? "COUNT TARGET"
+ : "TASK ROUTINE"}{" "}
+ · {scheduleLabel(habit.requirements?.schedule)}
+ {habit.complete && " · Complete"}
+ {habit.carriedFrom && (
+
+ {" "}
+ · Includes progress from {habit.carriedFrom}
+
+ )}
+
+
+
+ {habit.method === "manual" ? (
+
+ void log(habit, {
+ done: event.target.checked,
+ })
+ }
+ />
+ ) : habit.method === "count" ? (
+ void log(habit, { count })}
+ />
+ ) : (
+
+ {habit.value}
+ / {habit.target}
+
+ )}
+
+ ↗
+
+
+
+ {habit.method === "tasks" && (
+
+
+ Tasks for today{" "}
+
+ {habit.value} / {habit.target}
+
+
+
+ {habit.tasks.map((task) => (
+
+ void log(
+ habit,
+ { done: event.target.checked },
+ task.taskId,
+ )
+ }
+ />
+ ))}
+
+
+ )}
+ {habit.method === "count" && (
+ void log(habit, { count })}
+ />
+ )}
+
+ ))}
+
+ {offDay.length > 0 && (
+
+
+ Not scheduled today {offDay.length}
+
+
+ These habits aren’t included in today’s progress.
+
+ {offDay.map((habit) => (
+
+
+ {habit.name}
+
+ {scheduleLabel(habit.requirements?.schedule)}
+ {habit.method === "tasks" &&
+ " · No tasks due today"}
+
+
+
+ View history ↗
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {!empty && (
+
+
+
+ 02 / THE BIGGER PICTURE
+
+ Find your rhythm.
+
+
+
+
+ {today.habits.map((habit) => (
+ setEditing({ habit, color })}
+ onDelete={() => setDeleting(habit)}
+ />
+ ))}
+
+
+ Your real progress, one day at a time. Select a date to look
+ closer. Days off stay out of your score.
+
+
+ )}
+ >
+ )}
+
+
+ {starter && today && (
+
setStarter(null)}
+ onCreated={(name) => {
+ setStarter(null);
+ setNotice(`${name} is ready. Your rhythm starts here.`);
+ setFilter("all");
+ void refresh();
+ }}
+ />
+ )}
+ {editing?.habit.requirements && today && (
+ setEditing(null)}
+ onCreated={(name) => {
+ setEditing(null);
+ setNotice(`${name} updated. Your earlier history is kept.`);
+ void refresh();
+ }}
+ />
+ )}
+ {deleting && (
+ setDeleting(null)}
+ onDeleted={() => {
+ setNotice(
+ `${deleting.name} removed from your dashboard. Recorded history is kept.`,
+ );
+ // Reflect a successful deletion even if the following refresh fails.
+ setToday((current) => {
+ if (!current) return current;
+ const habits = current.habits.filter(
+ (habit) => habit.habitId !== deleting.habitId,
+ );
+ return {
+ ...current,
+ habits,
+ due: habits.filter((habit) => habit.due).length,
+ completed: habits.filter((habit) => habit.complete).length,
+ };
+ });
+ setDeleting(null);
+ void refresh();
+ }}
+ />
+ )}
+
+ );
}
diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx
new file mode 100644
index 0000000..5817ec2
--- /dev/null
+++ b/src/pages/Landing.tsx
@@ -0,0 +1,105 @@
+import { Link } from "react-router";
+import { useAuth } from "../components/AuthProvider";
+import { Button } from "../components/design-system/primitives";
+import { LandingDemo } from "../components/LandingDemo";
+import "../../styles/design-system.css";
+import "../../styles/landing.css";
+
+export function Landing() {
+ const { signIn, error } = useAuth();
+
+ return (
+
+
Skip to content
+
+
+ {error && {error}
}
+
+
+
YOUR HABITS. YOUR OWN PACE.
+
A little today.A rhythm for life.
+
A quiet place to track your habits, notice your progress, and keep showing up for the things that matter.
+
+
Your Discord account. One less password.
+
+
+
+
+
+
+
+
+
+ 02 / HOW IT WORKS
+
A habit, not a project.
+
+
+
+
+ 01 — MAKE IT YOURS
+ Start with something small.
+ A glass of water. A few pages. A calmer evening. Choose a simple checkbox, a count target, or a short list of tasks, with a schedule that fits your days.
+
+
+ 02 — SHOW UP
+ Log a little along the way.
+ Check it off, add to your count, or finish a task. Each habit keeps its own progress, and daily boundaries follow your saved timezone.
+
+
+ 03 — SEE YOUR RHYTHM
+ Let the days tell the story.
+ A color for every habit. A square for every day. Look closer at one habit or bring them together in a combined calendar. A day off isn’t a missed day.
+
+
+ Explore the demo above, then sign in to create your own habits and keep your progress in one place.
+
+
+
+
+ 03 / CONNECTED WITH DISCORD
+
One familiar account.A little space for you.
+
+
+
Start with the Discord account you already use. Minabot connects your Discord profile to your own account here, without another password to remember.
+
+
Your profile, connected. Sign-in uses your Discord identity, not access to your messages or email.
+
No server setup. You don’t need to add a bot to a server to sign in. Habit tracking lives here on the web.
+
+
Discord integration currently covers sign-in. Bot commands and reminders aren’t available yet.
+
+
+
+
+
+
NO PERFECT START REQUIRED
+
Begin with a little.
+
+
+
Sign in with Discord ↗
+
Or explore the demo. No account needed.
+
+
+
+
+
+ );
+}
diff --git a/styles/design-system.css b/styles/design-system.css
new file mode 100644
index 0000000..ab09808
--- /dev/null
+++ b/styles/design-system.css
@@ -0,0 +1,1284 @@
+@font-face {
+ font-family: "Instrument Serif";
+ src: url("../src/assets/fonts/InstrumentSerif-Regular.ttf") format("truetype");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Instrument Serif";
+ src: url("../src/assets/fonts/InstrumentSerif-Italic.ttf") format("truetype");
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
+
+.ds-root {
+ --ds-ink: #111;
+ --ds-secondary: #666;
+ --ds-rule: #dedede;
+ --ds-paper: #fff;
+ --ds-serif: "Instrument Serif", Georgia, serif;
+ position: relative;
+ margin: -8px;
+ min-height: 100vh;
+ color: var(--ds-ink);
+ background: var(--ds-paper);
+ font:
+ 14px/1.5 -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+.ds-root *,
+.ds-root *::before,
+.ds-root *::after {
+ box-sizing: border-box;
+}
+.ds-root :where(h1, h2, h3, h4, p, figure) {
+ margin: 0;
+}
+.ds-root :where(h1, h2) {
+ font-family: var(--ds-serif);
+ font-weight: 400;
+ line-height: 1.05;
+}
+.ds-root :where(h3, h4) {
+ font-weight: 400;
+}
+.ds-root a {
+ color: inherit;
+ text-decoration: none;
+}
+.ds-root a:hover {
+ text-decoration: underline;
+ text-underline-offset: 5px;
+}
+.ds-root button,
+.ds-root input {
+ font: inherit;
+ border-radius: 0;
+}
+.ds-root button {
+ cursor: pointer;
+}
+.ds-root button:disabled {
+ cursor: not-allowed;
+ opacity: 0.38;
+}
+.ds-root :focus-visible {
+ outline: 2px solid var(--ds-ink);
+ outline-offset: 5px;
+}
+.ds-root section {
+ scroll-margin-top: 28px;
+}
+.ds-muted,
+.ds-code,
+.ds-footnote {
+ color: var(--ds-secondary);
+}
+.ds-code {
+ font:
+ 11px/1.6 ui-monospace,
+ SFMono-Regular,
+ Consolas,
+ monospace;
+}
+.ds-eyebrow {
+ font-size: 10px;
+ letter-spacing: 0.13em;
+ font-weight: 500;
+}
+.ds-header {
+ max-width: 1280px;
+ margin: auto;
+ padding: 28px 48px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ border-bottom: 1px solid var(--ds-rule);
+}
+.ds-wordmark {
+ font-family: var(--ds-serif);
+ font-size: 35px;
+ line-height: 1;
+ letter-spacing: -1.2px;
+}
+.ds-header nav {
+ display: flex;
+ gap: 30px;
+ font-size: 12px;
+}
+.ds-edition {
+ color: var(--ds-secondary);
+ font-size: 9px;
+ letter-spacing: 0.12em;
+}
+.ds-main {
+ max-width: 1184px;
+ padding: 0 48px;
+ margin: auto;
+}
+.ds-intro {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 32px;
+ padding: 85px 0 72px;
+}
+.ds-intro h1 {
+ font-size: clamp(64px, 6.6vw, 90px);
+ letter-spacing: -2.4px;
+ margin: 23px 0 26px;
+}
+.ds-intro h1 em {
+ font-weight: 400;
+}
+.ds-intro-copy {
+ color: var(--ds-secondary);
+ line-height: 1.7;
+}
+.ds-intro-note {
+ width: 220px;
+ padding-bottom: 4px;
+ font-size: 12px;
+}
+.ds-tiny-cross {
+ display: block;
+ font-size: 27px;
+ font-weight: 200;
+ margin-bottom: 20px;
+}
+.ds-intro-note p {
+ color: var(--ds-secondary);
+ line-height: 1.8;
+ margin-bottom: 29px;
+}
+.ds-intro-note a {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 9px;
+ border-bottom: 1px solid var(--ds-ink);
+}
+.ds-section {
+ padding: 42px 0 56px;
+ border-top: 1px solid var(--ds-ink);
+}
+.ds-section-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 20px;
+ margin-bottom: 32px;
+}
+.ds-section-top h2 {
+ font-size: 42px;
+ margin-top: 15px;
+ letter-spacing: -0.7px;
+}
+.ds-section-top > p {
+ font-size: 12px;
+}
+.ds-demo-note {
+ color: var(--ds-secondary);
+ font-size: 11px;
+ white-space: nowrap;
+}
+.ds-demo-note > span {
+ font-size: 20px;
+ vertical-align: -1px;
+ padding-right: 4px;
+}
+.ds-preview-toolbar {
+ display: flex;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--ds-rule);
+}
+.ds-view-switch {
+ display: flex;
+ gap: 28px;
+}
+.ds-view-switch button {
+ background: transparent;
+ color: var(--ds-secondary);
+ border: 0;
+ border-bottom: 2px solid transparent;
+ padding: 13px 0;
+ font-size: 12px;
+}
+.ds-view-switch button[aria-pressed="true"] {
+ border-bottom-color: var(--ds-ink);
+ color: var(--ds-ink);
+}
+.ds-preview-toolbar > .ds-button {
+ font-size: 11px;
+}
+.ds-preview-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 20px;
+ padding: 32px 0 22px;
+}
+.ds-preview-heading .ds-eyebrow {
+ color: var(--ds-secondary);
+ font-size: 9px;
+}
+.ds-preview-heading h3 {
+ font-family: var(--ds-serif);
+ font-size: 30px;
+ margin-top: 8px;
+}
+.ds-preview-heading > p {
+ color: var(--ds-secondary);
+ font-size: 12px;
+ padding-bottom: 4px;
+}
+.ds-habit-row {
+ border-top: 1px solid var(--ds-rule);
+ padding: 19px 0;
+}
+.ds-habit-main {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+}
+.ds-habit-main h4 {
+ font-family: var(--ds-serif);
+ font-size: 27px;
+}
+.ds-habit-main p {
+ color: var(--ds-secondary);
+ font-size: 11px;
+ margin-top: 3px;
+}
+.ds-habit-control {
+ display: flex;
+ align-items: center;
+ gap: 25px;
+}
+.ds-status {
+ color: var(--ds-secondary);
+ min-width: 67px;
+ font-size: 10px;
+ text-align: right;
+}
+.ds-button {
+ min-height: 42px;
+ border: 1px solid var(--ds-ink);
+ padding: 10px 19px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 22px;
+ font-size: 12px !important;
+ background: var(--ds-ink);
+ color: var(--ds-paper);
+}
+.ds-button:hover:not(:disabled) {
+ background: #333;
+}
+.ds-button--secondary {
+ background: transparent;
+ color: var(--ds-ink);
+}
+.ds-button--text {
+ background: transparent;
+ color: var(--ds-ink);
+ border-color: transparent;
+ padding-left: 0;
+ padding-right: 0;
+}
+.ds-button--secondary:hover:not(:disabled),
+.ds-button--text:hover:not(:disabled) {
+ background: #f3f3f3;
+}
+.ds-counter {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.ds-counter .ds-button {
+ width: 36px;
+ min-height: 40px;
+ font-size: 23px !important;
+ font-weight: 300;
+ padding: 0;
+}
+.ds-counter output {
+ min-width: 41px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+.ds-checkbox {
+ display: inline-flex;
+ align-items: center;
+ gap: 11px;
+ min-height: 36px;
+ cursor: pointer;
+ font-size: 12px;
+}
+.ds-checkbox input {
+ appearance: none;
+ margin: 0;
+ width: 17px;
+ height: 17px;
+ flex: 0 0 17px;
+ border: 1px solid #777;
+ background: #fff;
+ display: grid;
+ place-content: center;
+ cursor: pointer;
+}
+.ds-checkbox input:checked {
+ background: var(--ds-ink);
+ border-color: var(--ds-ink);
+}
+.ds-checkbox input:checked::after {
+ content: "";
+ width: 8px;
+ height: 4px;
+ border-left: 1px solid #fff;
+ border-bottom: 1px solid #fff;
+ transform: rotate(-45deg) translateY(-1px);
+}
+.ds-checkbox input:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.ds-checkbox--icon {
+ padding: 10px;
+}
+.ds-checkbox--icon span {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+.ds-task-list {
+ display: flex;
+ gap: 28px;
+ margin-top: 13px;
+}
+.ds-task-count {
+ min-width: 149px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+.ds-footnote {
+ font-size: 11px;
+ line-height: 1.7;
+ margin-top: 18px !important;
+}
+.ds-habits > .ds-footnote {
+ border-top: 1px solid var(--ds-rule);
+ padding-top: 17px;
+ margin-top: 0 !important;
+}
+.ds-split-section {
+ display: grid;
+ grid-template-columns: 260px minmax(0, 1fr);
+ column-gap: 60px;
+ padding-top: 40px;
+}
+.ds-section-heading h2 {
+ font-size: 37px;
+ margin-top: 17px;
+ letter-spacing: -0.5px;
+}
+.ds-section-heading p {
+ color: var(--ds-secondary);
+ font-size: 12px;
+ line-height: 1.8;
+ margin-top: 15px;
+}
+.ds-spec-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 23px;
+}
+.ds-spec-heading h3,
+.ds-spec-heading label {
+ font-size: 13px;
+}
+.ds-spec-heading--spaced {
+ margin-top: 36px;
+}
+.ds-type-display {
+ display: block;
+ font-family: var(--ds-serif);
+ font-size: 55px;
+ line-height: 1.1;
+ letter-spacing: -0.6px;
+}
+.ds-type-specimen p,
+.ds-body-specimen p {
+ font-size: 10px;
+ color: var(--ds-secondary);
+ margin-top: 16px;
+}
+.ds-body-specimen {
+ border-bottom: 1px solid var(--ds-rule);
+ padding: 26px 0;
+}
+.ds-body-specimen > span {
+ font-size: 16px;
+}
+.ds-body-specimen p {
+ margin-top: 6px;
+}
+.ds-palette {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 18px;
+}
+.ds-swatch {
+ height: 64px;
+}
+.ds-palette > div:last-child .ds-swatch {
+ border: 1px solid var(--ds-rule);
+}
+.ds-palette p {
+ font-size: 11px;
+ margin-top: 10px;
+}
+.ds-palette .ds-code {
+ display: block;
+ font-size: 9px;
+ margin-top: 3px;
+}
+.ds-spacing {
+ display: flex;
+ align-items: flex-end;
+ gap: 30px;
+}
+.ds-spacing > div {
+ display: grid;
+ gap: 10px;
+}
+.ds-spacing span {
+ height: 4px;
+ background: var(--ds-ink);
+}
+.ds-spacing code {
+ font-size: 10px;
+ color: var(--ds-secondary);
+}
+.ds-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 16px;
+ padding-bottom: 30px;
+}
+.ds-component-row {
+ border-top: 1px solid var(--ds-rule);
+ padding: 24px 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+}
+.ds-component-row h3 {
+ font-size: 14px;
+ margin-bottom: 3px;
+}
+.ds-example-form {
+ border-top: 1px solid var(--ds-rule);
+ padding-top: 26px;
+}
+.ds-input-row {
+ display: flex;
+ gap: 14px;
+}
+.ds-input-row input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ border-bottom: 1px solid #888;
+ padding: 10px 0;
+ background: var(--ds-paper);
+ color: var(--ds-ink);
+ outline-offset: 4px;
+}
+.ds-input-row input::placeholder {
+ color: var(--ds-secondary);
+}
+.ds-shade-scale {
+ display: grid;
+ grid-template-columns: repeat(9, 1fr);
+ gap: 14px;
+}
+.ds-shade-scale > div {
+ display: grid;
+ gap: 10px;
+}
+.ds-shade-scale span {
+ aspect-ratio: 1;
+ max-height: 40px;
+}
+.ds-shade-scale small {
+ color: var(--ds-secondary);
+ font-size: 10px;
+}
+.ds-scale-caption {
+ display: flex;
+ justify-content: space-between;
+ font-size: 10px;
+ color: var(--ds-secondary);
+ margin-top: 16px;
+}
+.ds-neutral-states {
+ margin-top: 25px;
+ border-top: 1px solid var(--ds-rule);
+ padding-top: 25px;
+ display: flex;
+ gap: 36px;
+}
+.ds-neutral-states > div {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+}
+.ds-neutral-states h4 {
+ font-size: 12px;
+}
+.ds-neutral-states p {
+ font-size: 10px;
+ color: var(--ds-secondary);
+ margin-top: 3px;
+}
+.ds-state-square {
+ flex: 0 0 22px;
+ width: 22px;
+ height: 22px;
+}
+.ds-state-square--future {
+ background: #eeeeee;
+}
+.ds-reference-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 28px;
+}
+.ds-reference-grid a {
+ display: block;
+}
+.ds-reference-grid img {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+.ds-reference-grid figcaption {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 11px;
+ border-top: 1px solid var(--ds-rule);
+ padding-top: 15px;
+ margin-top: 20px;
+}
+.ds-footer {
+ border-top: 1px solid var(--ds-rule);
+ padding: 28px 0 40px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 20px;
+}
+.ds-footer .ds-wordmark {
+ font-size: 26px;
+}
+.ds-footer > span:nth-child(2) {
+ font-family: var(--ds-serif);
+ font-style: italic;
+ font-size: 20px;
+ color: var(--ds-secondary);
+}
+.ds-footer a {
+ font-size: 11px;
+}
+.ds-detail-count {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-top: 1px solid var(--ds-rule);
+ padding: 20px 0 30px;
+}
+.ds-big-value {
+ font-family: var(--ds-serif);
+ font-size: 64px;
+ line-height: 1;
+}
+.ds-big-value small {
+ font-size: 24px;
+ margin-left: 20px;
+}
+.ds-chart-selection {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 26px;
+ padding: 4px 0 22px;
+}
+.ds-calendar-scroll {
+ overflow-x: auto;
+ padding: 5px;
+ margin: -5px;
+}
+.ds-calendar-range-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px 16px;
+ margin-bottom: 18px;
+}
+.ds-calendar-range-label {
+ font-size: 10px;
+ color: var(--ds-secondary);
+}
+.ds-month-views {
+ display: flex;
+ gap: 4px;
+}
+.ds-month-views button {
+ min-height: 32px;
+ min-width: 32px;
+ padding: 5px 7px;
+ color: var(--ds-secondary);
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid transparent;
+ font-size: 11px;
+}
+.ds-month-views button:hover {
+ color: var(--ds-ink);
+ background: #f3f3f3;
+}
+.ds-month-views button[aria-pressed="true"] {
+ color: var(--ds-ink);
+ border-bottom-color: var(--ds-ink);
+}
+.ds-calendar-inner {
+ min-width: 600px;
+}
+.ds-calendar-months {
+ display: grid;
+ grid-template-columns: repeat(26, 1fr);
+ margin-left: 38px;
+ font-size: 10px;
+ color: var(--ds-secondary);
+ height: 28px;
+}
+.ds-calendar-body {
+ display: flex;
+ gap: 10px;
+}
+.ds-calendar-weekdays {
+ display: grid;
+ grid-template-rows: repeat(7, 1fr);
+ width: 28px;
+ flex-shrink: 0;
+ font-size: 9px;
+ color: var(--ds-secondary);
+ align-items: center;
+}
+.ds-calendar-weekdays span:nth-child(1) {
+ grid-row: 2;
+}
+.ds-calendar-weekdays span:nth-child(2) {
+ grid-row: 4;
+}
+.ds-calendar-weekdays span:nth-child(3) {
+ grid-row: 6;
+}
+.ds-calendar-grid {
+ display: grid;
+ grid-template-rows: repeat(7, 1fr);
+ grid-auto-flow: column;
+ gap: 6px;
+ flex: 1;
+}
+.ds-day {
+ padding: 0;
+ aspect-ratio: 1;
+ border: 1px solid transparent;
+ min-width: 0;
+}
+/* Keep a full-size date target, but reserve filled squares for tracked days. */
+.ds-day--not-due,
+.ds-legend-swatch--not-due,
+.ds-state-square--not-due {
+ position: relative;
+ background: transparent;
+}
+.ds-day--not-due::after,
+.ds-legend-swatch--not-due::after,
+.ds-state-square--not-due::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: #888;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+}
+.ds-day--not-due:hover {
+ box-shadow: inset 0 0 0 1px var(--ds-rule);
+}
+.ds-day[aria-pressed="true"] {
+ outline: 1px solid var(--ds-ink);
+ outline-offset: 2px;
+}
+.ds-day:focus-visible {
+ outline: 2px solid var(--ds-ink);
+ outline-offset: 2px;
+}
+.ds-calendar-caption {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ font-size: 10px;
+ margin: 17px 0 23px;
+}
+.ds-calendar-legend {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px 20px;
+ margin-top: 18px;
+ color: var(--ds-secondary);
+ font-size: 10px;
+}
+.ds-legend-scale,
+.ds-legend-neutral,
+.ds-legend-swatches {
+ display: inline-flex;
+ align-items: center;
+}
+.ds-legend-scale,
+.ds-legend-neutral {
+ gap: 8px;
+}
+.ds-legend-swatches {
+ gap: 3px;
+}
+.ds-legend-swatch {
+ display: block;
+ width: 11px;
+ height: 11px;
+ flex: 0 0 11px;
+}
+.ds-date-inspector {
+ border-top: 1px solid var(--ds-rule);
+ border-bottom: 1px solid var(--ds-rule);
+ padding: 16px 0;
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ font-size: 12px;
+}
+.ds-skip-link {
+ position: absolute;
+ top: 0;
+ left: 24px;
+ padding: 10px;
+ background: white;
+ transform: translateY(-110%);
+ z-index: 2;
+}
+.ds-skip-link:focus {
+ transform: translateY(0);
+}
+@media (max-width: 900px) {
+ .ds-header {
+ padding-inline: 32px;
+ }
+ .ds-main {
+ padding-inline: 32px;
+ }
+ .ds-split-section {
+ grid-template-columns: 210px minmax(0, 1fr);
+ gap: 30px;
+ }
+ .ds-section-heading h2 {
+ font-size: 32px;
+ }
+ .ds-neutral-states {
+ flex-direction: column;
+ gap: 20px;
+ }
+ .ds-spacing {
+ gap: 22px;
+ }
+ .ds-spec-heading {
+ flex-wrap: wrap;
+ gap: 5px;
+ }
+ .ds-intro-note {
+ width: 185px;
+ }
+ .ds-edition {
+ display: none;
+ }
+}
+@media (max-width: 640px) {
+ .ds-header {
+ padding: 23px 22px;
+ gap: 18px;
+ }
+ .ds-wordmark {
+ font-size: 30px;
+ }
+ .ds-header nav {
+ gap: 16px;
+ font-size: 10px;
+ }
+ .ds-main {
+ padding-inline: 22px;
+ }
+ .ds-intro {
+ padding: 50px 0 44px;
+ }
+ .ds-intro h1 {
+ font-size: 63px;
+ letter-spacing: -1.4px;
+ }
+ .ds-intro-copy {
+ font-size: 12px;
+ }
+ .ds-intro-note {
+ display: none;
+ }
+ .ds-section {
+ padding: 30px 0 36px;
+ }
+ .ds-section-top {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 24px;
+ }
+ .ds-section-top h2 {
+ font-size: 37px;
+ }
+ .ds-split-section {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 30px;
+ }
+ .ds-section-heading h2 {
+ font-size: 37px;
+ }
+ .ds-section-heading p br {
+ display: none;
+ }
+ .ds-view-switch {
+ gap: 18px;
+ }
+ .ds-view-switch button {
+ font-size: 11px;
+ }
+ .ds-preview-toolbar > .ds-button {
+ font-size: 10px !important;
+ gap: 5px;
+ }
+ .ds-preview-heading {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ padding-top: 25px;
+ }
+ .ds-preview-heading h3 {
+ font-size: 28px;
+ }
+ .ds-habit-main h4 {
+ font-size: 25px;
+ }
+ .ds-habit-main p {
+ font-size: 10px;
+ }
+ .ds-habit-control {
+ flex-direction: column;
+ gap: 0;
+ align-items: flex-end;
+ }
+ .ds-status {
+ font-size: 9px;
+ }
+ .ds-counter {
+ gap: 2px;
+ }
+ .ds-counter .ds-button {
+ width: 32px;
+ }
+ .ds-task-count {
+ min-width: 106px;
+ text-align: right;
+ min-height: 36px;
+ }
+ .ds-task-list {
+ flex-wrap: wrap;
+ gap: 3px 20px;
+ }
+ .ds-checkbox {
+ font-size: 11px;
+ }
+ .ds-palette {
+ gap: 13px;
+ }
+ .ds-swatch {
+ height: 58px;
+ }
+ .ds-type-display {
+ font-size: 50px;
+ }
+ .ds-shade-scale {
+ gap: 8px;
+ }
+ .ds-scale-caption {
+ font-size: 9px;
+ }
+ .ds-reference-grid {
+ grid-template-columns: 1fr;
+ gap: 35px;
+ }
+ .ds-footer > span:nth-child(2) {
+ display: none;
+ }
+ .ds-chart-selection {
+ gap: 10px 18px;
+ }
+ .ds-calendar-caption,
+ .ds-date-inspector {
+ flex-direction: column;
+ gap: 8px;
+ }
+ .ds-big-value {
+ font-size: 53px;
+ }
+ .ds-big-value small {
+ font-size: 20px;
+ margin-left: 10px;
+ }
+ .ds-actions {
+ gap: 12px;
+ }
+ .ds-input-row {
+ gap: 10px;
+ }
+ .ds-input-row .ds-button {
+ padding-inline: 14px;
+ }
+ .ds-root {
+ font-size: 13px;
+ }
+}
+@media (prefers-reduced-motion: no-preference) {
+ .ds-root :where(button, a, input) {
+ transition:
+ background-color 120ms ease,
+ color 120ms ease;
+ }
+}
+
+/* Habit charts share an open two-column grid, not card containers. */
+.ds-habit-chart-grid {
+ font-size: 14px;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 38px 44px;
+ align-items: start;
+}
+.ds-habit-chart {
+ min-width: 0;
+ border-top: 1px solid var(--ds-rule);
+ padding-top: 20px;
+}
+.ds-habit-chart:has(.ds-calendar[data-months="12"]) {
+ grid-column: 1 / -1;
+}
+.ds-habit-chart-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+}
+.ds-habit-chart-heading > div {
+ min-width: 0;
+}
+.ds-habit-chart-heading h4 {
+ overflow-wrap: anywhere;
+ font-family: var(--ds-serif);
+ font-size: 29px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.ds-habit-chart-heading h4 > span {
+ width: 7px;
+ height: 7px;
+ flex-shrink: 0;
+}
+.ds-habit-chart-heading p {
+ color: var(--ds-secondary);
+ font-size: 10px;
+ margin-top: 4px;
+}
+.ds-habit-chart-heading .ds-counter {
+ gap: 4px;
+}
+.ds-habit-chart-heading .ds-counter .ds-button {
+ width: 28px;
+}
+.ds-habit-chart-progress {
+ display: flex;
+ justify-content: space-between;
+ color: var(--ds-secondary);
+ font-size: 11px;
+ margin: 23px 0 20px !important;
+}
+.ds-habit-chart-grid-note {
+ grid-column: 1 / -1;
+ margin-top: -12px !important;
+}
+.ds-calendar--compact .ds-calendar-inner {
+ min-width: 380px;
+}
+.ds-calendar--compact .ds-calendar-grid {
+ gap: 5px;
+}
+.ds-calendar--compact .ds-date-inspector {
+ flex-direction: column;
+ gap: 5px;
+ font-size: 10px;
+ border-bottom: 0;
+ padding-bottom: 0;
+}
+.ds-calendar--compact .ds-calendar-caption {
+ font-size: 9px;
+ margin-bottom: 15px;
+}
+.ds-task-accordion {
+ margin-top: 18px;
+ border-top: 1px solid var(--ds-rule);
+ border-bottom: 1px solid var(--ds-rule);
+}
+.ds-task-accordion summary {
+ cursor: pointer;
+ list-style: none;
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ min-height: 46px;
+ font-size: 12px;
+}
+.ds-task-accordion summary::-webkit-details-marker {
+ display: none;
+}
+.ds-task-accordion summary > span {
+ margin-left: auto;
+ color: var(--ds-secondary);
+ font-size: 11px;
+}
+.ds-task-accordion summary::after {
+ content: "+";
+ font-size: 19px;
+ width: 16px;
+ text-align: center;
+}
+.ds-task-accordion[open] summary::after {
+ content: "−";
+}
+.ds-task-accordion-content {
+ display: grid;
+ padding: 0 0 14px;
+}
+.ds-habit-color-key {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+}
+.ds-habit-color-key > span {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ font-size: 11px;
+ text-transform: capitalize;
+}
+.ds-habit-color-key i {
+ width: 10px;
+ height: 10px;
+}
+@media (max-width: 760px) {
+ .ds-habit-chart-grid {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 32px;
+ }
+ .ds-calendar--compact .ds-calendar-inner {
+ max-width: 520px;
+ }
+ .ds-habit-chart-heading .ds-counter {
+ gap: 8px;
+ }
+}
+
+/* A full year gets a full row; fit every week instead of clipping or scrolling. */
+.ds-calendar[data-months="12"] .ds-calendar-inner {
+ width: 100%;
+ max-width: none;
+}
+.ds-calendar[data-months="12"] .ds-calendar-grid {
+ min-width: 0;
+ gap: clamp(2px, 0.4vw, 6px);
+}
+
+/* Small-text scale: 12px minimum, 14px for supporting copy. */
+.ds-edition,
+.ds-preview-heading .ds-eyebrow,
+.ds-palette .ds-code,
+.ds-calendar-weekdays,
+.ds-calendar--compact .ds-calendar-caption {
+ font-size: 12px;
+}
+.ds-code,
+.ds-demo-note,
+.ds-habit-main p,
+.ds-footnote,
+.ds-palette p,
+.ds-reference-grid figcaption,
+.ds-footer a,
+.ds-month-views button,
+.ds-habit-chart-progress,
+.ds-task-accordion summary > span,
+.ds-habit-color-key > span,
+.ds-eyebrow,
+.ds-status,
+.ds-type-specimen p,
+.ds-body-specimen p,
+.ds-spacing code,
+.ds-shade-scale small,
+.ds-scale-caption,
+.ds-neutral-states p,
+.ds-calendar-range-label,
+.ds-calendar-months,
+.ds-calendar-caption,
+.ds-calendar-legend,
+.ds-habit-chart-heading p,
+.ds-calendar--compact .ds-date-inspector {
+ font-size: 14px;
+}
+.ds-legend-scale { flex-wrap: wrap; }
+
+/* Editing patterns use the same open layout, rules, and neutral controls. */
+.ds-color-picker { border: 0; border-top: 1px solid var(--ds-rule); padding: 20px 0 0; margin: 26px 0; min-width: 0; }
+.ds-color-picker legend { padding: 0 12px 0 0; font-size: 14px; }
+.ds-color-picker > .ds-footnote { margin-top: 0 !important; }
+.ds-color-palettes { display: flex; flex-wrap: wrap; gap: 20px 28px; margin: 20px 0; }
+.ds-color-palette > span { font-size: 12px; color: var(--ds-secondary); }
+.ds-color-swatches { display: flex; gap: 4px; margin-top: 8px; }
+.ds-color-swatches button { width: 38px; min-height: 44px; padding: 4px; border: 1px solid transparent; background: transparent; }
+.ds-color-swatches button[aria-pressed="true"] { border-color: var(--ds-ink); }
+.ds-color-swatches button > span { display: grid; place-content: center; height: 28px; width: 28px; color: white; font-size: 16px; }
+.ds-custom-color { display: flex; gap: 16px; align-items: start; }
+.ds-custom-color .ds-field { margin-bottom: 0; }
+.ds-custom-color .ds-field:last-child { width: 180px; }
+.ds-custom-color input[type="color"] { width: 80px; height: 44px; padding: 5px; cursor: pointer; }
+.ds-color-live-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 22px; font-size: 12px; }
+.ds-color-preview-shades { display: flex; gap: 4px; }
+.ds-color-preview-shades > span { width: 18px; height: 18px; border: 1px solid #dedede; }
+.ds-edit-layout {
+ display: grid;
+ grid-template-columns: 280px minmax(0, 1fr);
+ gap: 72px;
+ padding-top: 36px;
+}
+.ds-edit-context, .ds-edit-content { min-width: 0; }
+.ds-edit-context h3 {
+ font-family: var(--ds-serif);
+ font-size: 36px;
+ line-height: 1.12;
+ margin: 16px 0;
+}
+.ds-edit-context > p { color: var(--ds-secondary); line-height: 1.8; }
+.ds-edit-context > .ds-field { margin-top: 30px; }
+.ds-edit-meta { display: flex; align-items: center; gap: 10px; margin-top: 24px; overflow-wrap: anywhere; }
+.ds-habit-dot { width: 8px; height: 8px; flex: 0 0 8px; }
+.ds-field { display: grid; min-width: 0; gap: 8px; margin-bottom: 22px; }
+.ds-field label, .ds-weekday-field legend { font-size: 14px; }
+.ds-field :is(input, select) {
+ width: 100%;
+ min-width: 0;
+ min-height: 44px;
+ border: 1px solid #999;
+ padding: 10px 12px;
+ font: inherit;
+ border-radius: 0;
+ background: var(--ds-paper);
+ color: var(--ds-ink);
+}
+.ds-field input[readonly] { border-color: var(--ds-rule); color: var(--ds-secondary); }
+.ds-field small { font-size: 12px; color: var(--ds-secondary); line-height: 1.7; }
+.ds-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
+.ds-form-section { border-top: 1px solid var(--ds-rule); padding-top: 24px; margin-top: 24px; }
+.ds-edit-content .ds-checkbox { font-size: 14px; }
+.ds-weekday-field, .ds-task-editor-fieldset { border: 0; padding: 0; margin: 0 0 20px; min-width: 0; }
+.ds-weekdays { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
+.ds-weekdays button { min-height: 44px; min-width: 44px; padding: 8px; border: 1px solid var(--ds-rule); background: white; color: var(--ds-secondary); }
+.ds-weekdays button[aria-pressed="true"] { color: white; background: var(--ds-ink); border-color: var(--ds-ink); }
+.ds-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); font-size: 14px; overflow-wrap: anywhere; }
+.ds-form-feedback[role="alert"] { border-left: 2px solid var(--ds-ink); padding-left: 12px; color: var(--ds-ink); }
+.ds-save-notice { margin-bottom: 0 !important; }
+.ds-edit-task { border-top: 1px solid var(--ds-rule); padding: 16px 0 10px; }
+.ds-edit-task .ds-spec-heading { margin-bottom: 16px; }
+.ds-empty-state { padding: 24px 0; color: var(--ds-secondary); }
+.ds-history-requirement { display: flex; justify-content: space-between; gap: 16px; border-block: 1px solid var(--ds-rule); padding: 18px 0; margin-bottom: 24px; }
+.ds-history-requirement > span { color: var(--ds-secondary); }
+.ds-history-requirement strong { font-weight: 500; }
+.ds-backfill-tasks { display: grid; gap: 8px; }
+.ds-correction-summary { display: flex; justify-content: space-between; gap: 16px; margin-top: 22px !important; font-variant-numeric: tabular-nums; }
+.ds-correction-summary span { color: var(--ds-secondary); }
+.ds-backfill-calendar { border-top: 1px solid var(--ds-rule); margin-top: 32px; padding-top: 24px; }
+.ds-correction-log { margin-top: 28px; border-top: 1px solid var(--ds-rule); padding-top: 24px; }
+.ds-correction-log ol { list-style: none; margin: 0; padding: 0; }
+.ds-correction-log li { display: grid; grid-template-columns: 130px minmax(0, 1fr) auto; gap: 16px; border-bottom: 1px solid var(--ds-rule); padding: 16px 0; font-size: 14px; overflow-wrap: anywhere; }
+.ds-correction-log time { color: var(--ds-secondary); }
+#editing .ds-preview-toolbar { flex-wrap: wrap; gap: 0 16px; }
+#editing .ds-view-switch { flex-wrap: wrap; gap: 0 24px; }
+@media (max-width: 900px) {
+ .ds-edit-layout { grid-template-columns: 220px minmax(0, 1fr); gap: 32px; }
+}
+@media (max-width: 640px) {
+ .ds-edit-layout { grid-template-columns: minmax(0, 1fr); gap: 30px; }
+ .ds-edit-context h3 { font-size: 32px; }
+ .ds-form-grid { grid-template-columns: minmax(0, 1fr); gap: 0; }
+ .ds-history-requirement { flex-wrap: wrap; }
+ .ds-correction-log li { grid-template-columns: minmax(0, 1fr); gap: 5px; }
+ #editing .ds-view-switch { gap: 0 18px; }
+}
+@media (max-width: 640px) {
+ .ds-header nav,
+ .ds-preview-toolbar > .ds-button,
+ .ds-view-switch button,
+ .ds-checkbox,
+ .ds-habit-main p,
+ .ds-status,
+ .ds-scale-caption {
+ font-size: 14px !important;
+ }
+ .ds-header,
+ .ds-preview-toolbar { flex-wrap: wrap; }
+}
diff --git a/styles/home.css b/styles/home.css
new file mode 100644
index 0000000..61b6b3a
--- /dev/null
+++ b/styles/home.css
@@ -0,0 +1,603 @@
+.home-root {
+ font-size: 15px;
+}
+.home-root .ds-eyebrow {
+ font-size: 10px;
+}
+.home-root .ds-button {
+ font-size: 13px !important;
+}
+.home-header nav {
+ font-size: 13px;
+}
+.home-header nav [aria-current] {
+ border-bottom: 1px solid var(--ds-ink);
+ padding-bottom: 5px;
+}
+.home-account {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+.home-avatar {
+ display: grid;
+ place-items: center;
+ background: #f1f0ed;
+ width: 30px;
+ height: 30px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ font-family: var(--ds-serif);
+ font-size: 19px;
+}
+.home-avatar:is(img) {
+ object-fit: cover;
+}
+.home-account-name {
+ max-width: 140px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+}
+.home-account .ds-button {
+ margin-left: 12px;
+ white-space: nowrap;
+}
+.home-welcome {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 40px;
+ padding: 64px 0 48px;
+}
+.home-welcome > div:first-child {
+ min-width: 0;
+}
+.home-welcome h1 {
+ font-size: clamp(52px, 6vw, 78px);
+ letter-spacing: -1.7px;
+ margin: 18px 0 22px;
+ overflow-wrap: anywhere;
+}
+.home-welcome .ds-intro-copy {
+ max-width: 480px;
+}
+.home-date {
+ flex: 0 0 235px;
+ font-size: 13px;
+ padding-bottom: 5px;
+}
+.home-date .ds-tiny-cross {
+ margin-bottom: 14px;
+}
+.home-date p {
+ color: var(--ds-secondary);
+ margin: 5px 0 17px;
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+.home-date .ds-footnote {
+ font-size: 12px;
+}
+.home-overview {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1.7fr;
+ border-top: 1px solid var(--ds-rule);
+ padding: 28px 0 34px;
+ gap: 32px;
+}
+.home-overview > div + div {
+ border-left: 1px solid var(--ds-rule);
+ padding-left: 32px;
+}
+.home-overview p:not(.home-stat) {
+ color: var(--ds-secondary);
+ font-size: 12px;
+}
+.home-stat {
+ font-family: var(--ds-serif);
+ font-size: 54px;
+ line-height: 1;
+ margin: 18px 0 10px !important;
+}
+.home-stat span {
+ color: #777;
+ font-size: 32px;
+}
+.home-overview progress {
+ display: block;
+ height: 3px;
+ margin-top: 18px;
+ width: 100%;
+ max-width: 185px;
+ border: 0;
+ background: #eee;
+ accent-color: #111;
+}
+.home-overview progress::-webkit-progress-bar {
+ background: #eee;
+}
+.home-overview progress::-webkit-progress-value {
+ background: #111;
+}
+.home-overview-note h2 {
+ font-size: 32px;
+ margin: 19px 0 13px;
+}
+.home-overview-note p {
+ max-width: 280px;
+ line-height: 1.8;
+}
+.home-today {
+ padding-top: 34px;
+}
+.home-empty > p:first-child {
+ font-size: 17px;
+ margin-bottom: 9px;
+}
+.home-empty > .ds-muted {
+ max-width: 610px;
+ font-size: 14px;
+ line-height: 1.8;
+}
+.home-starters {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 28px;
+ margin-top: 32px;
+}
+.home-starters > button {
+ background: transparent;
+ border: 0;
+ border-top: 1px solid var(--ds-rule);
+ border-bottom: 1px solid var(--ds-rule);
+ text-align: left;
+ color: var(--ds-ink);
+ padding: 22px 4px 18px;
+ font: inherit;
+}
+.home-starters > button:hover {
+ background: #fafaf8;
+}
+.home-starter-symbol {
+ font-family: var(--ds-serif);
+ font-size: 27px;
+ display: block;
+ margin-bottom: 20px;
+}
+.home-starters h3 {
+ font-family: var(--ds-serif);
+ font-size: 28px;
+ margin-bottom: 9px;
+}
+.home-starters p {
+ color: var(--ds-secondary);
+ font-size: 13px;
+ min-height: 40px;
+}
+.home-starter-action {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 24px;
+ font-size: 12px;
+}
+.home-root .ds-view-switch button {
+ font-size: 13px;
+}
+.home-root .ds-view-switch button span {
+ font-size: 11px;
+ color: var(--ds-secondary);
+ margin-left: 8px;
+}
+.home-autosave {
+ align-self: center;
+ color: var(--ds-secondary);
+ font-size: 11px;
+}
+.home-habit {
+ padding: 12px 0;
+ border-bottom: 1px solid var(--ds-rule);
+}
+.home-habit-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+}
+.home-habit-heading > div:first-child {
+ min-width: 0;
+}
+.home-habit .ds-eyebrow {
+ color: var(--ds-secondary);
+ font-size: 9px;
+}
+.home-habit h3 {
+ font-family: var(--ds-serif);
+ font-size: 24px;
+ line-height: 1.15;
+ margin: 0 0 4px;
+ overflow-wrap: anywhere;
+}
+.home-habit-status {
+ font-size: 11px;
+ color: var(--ds-secondary);
+}
+.home-habit--complete .home-habit-status {
+ color: #386641;
+}
+.home-check-in {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-shrink: 0;
+}
+.home-check-in .ds-checkbox span {
+ max-width: 250px;
+ overflow-wrap: anywhere;
+}
+.home-task-count {
+ font-family: var(--ds-serif);
+ font-size: 26px;
+}
+.home-task-count > span {
+ color: var(--ds-secondary);
+ font-size: 20px;
+}
+.home-history-link {
+ display: grid;
+ place-items: center;
+ width: 32px;
+ min-height: 44px;
+ color: var(--ds-secondary);
+}
+.home-task-details {
+ margin-top: 6px;
+ font-size: 12px;
+}
+.home-task-details summary {
+ cursor: pointer;
+ padding: 4px 0;
+}
+.home-task-details summary span {
+ color: var(--ds-secondary);
+ margin-left: 8px;
+}
+.home-tasks {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 24px;
+ margin-top: 8px;
+}
+.home-tasks .ds-checkbox {
+ min-height: 36px;
+}
+.home-tasks .ds-checkbox span {
+ overflow-wrap: anywhere;
+}
+.home-count-entry {
+ font-size: 11px;
+ color: var(--ds-secondary);
+ margin-top: 6px;
+}
+.home-count-entry summary,
+.home-off-day summary {
+ cursor: pointer;
+}
+.home-count-entry form {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 0;
+}
+.home-count-entry input {
+ width: 120px;
+ padding: 10px;
+ border: 1px solid var(--ds-rule);
+}
+.home-off-day {
+ margin-top: 26px;
+}
+.home-off-day summary {
+ font-size: 14px;
+}
+.home-off-day summary > span {
+ margin-left: 8px;
+ color: var(--ds-secondary);
+ font-size: 12px;
+}
+.home-off-day > p {
+ margin-top: 16px;
+}
+.home-off-day > div {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 24px;
+ padding: 16px 0;
+ border-bottom: 1px solid var(--ds-rule);
+}
+.home-off-day strong {
+ font-weight: 400;
+ overflow-wrap: anywhere;
+}
+.home-off-day small {
+ display: block;
+ color: var(--ds-secondary);
+ margin-top: 4px;
+}
+.home-off-day a {
+ flex-shrink: 0;
+ font-size: 12px;
+}
+.home-rhythm > .ds-footnote {
+ margin-top: 24px;
+ font-size: 12px;
+}
+.home-state {
+ padding: 36px 0;
+ color: var(--ds-secondary);
+}
+.home-state h3 {
+ font-family: var(--ds-serif);
+ font-size: 30px;
+ color: var(--ds-ink);
+ margin-bottom: 10px;
+}
+.home-state .ds-button {
+ margin-top: 16px;
+}
+.home-error {
+ border-left: 2px solid #963e33;
+ padding: 12px 16px;
+ background: #fcf6f3;
+ margin-bottom: 20px !important;
+ color: #772d25;
+}
+.home-error .ds-button {
+ margin-top: 12px;
+}
+.home-notice {
+ font-size: 12px;
+ color: #386641;
+}
+.home-notice:not(:empty) {
+ padding: 0 0 20px;
+}
+.home-footer {
+ margin-top: 12px;
+}
+.home-habit-actions {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-shrink: 0;
+}
+.home-habit-actions .ds-button {
+ min-height: 36px;
+ padding: 0;
+ color: var(--ds-secondary);
+}
+.home-habit-actions .ds-button:hover {
+ color: var(--ds-ink);
+}
+.home-create {
+ color: var(--ds-ink);
+ background: var(--ds-paper);
+ border: 1px solid var(--ds-rule);
+ padding: 28px 32px;
+ width: min(560px, calc(100% - 32px));
+ max-height: calc(100dvh - 48px);
+ overflow-y: auto;
+}
+.home-create::backdrop {
+ background: rgb(0 0 0 / 35%);
+}
+.home-delete {
+ max-width: 520px;
+}
+.home-delete h2 {
+ margin-top: 20px;
+}
+.home-delete strong {
+ color: var(--ds-ink);
+ overflow-wrap: anywhere;
+}
+.home-create-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+.home-create-heading .ds-button {
+ font-size: 26px !important;
+ min-width: 36px;
+}
+.home-create h2 {
+ font-size: 46px;
+ margin: 8px 0 14px;
+}
+.home-create > .ds-muted {
+ font-size: 13px;
+}
+.home-form-fields {
+ border: 0;
+ margin: 24px 0;
+ padding: 0;
+ display: grid;
+ gap: 22px;
+ min-width: 0;
+}
+.home-form-fields .ds-field {
+ min-width: 0;
+}
+.home-form-fields textarea {
+ resize: vertical;
+ width: 100%;
+ border: 1px solid var(--ds-rule);
+ padding: 10px 12px;
+ font: inherit;
+}
+.home-form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ border-top: 1px solid var(--ds-rule);
+ padding-top: 22px;
+}
+
+@media (max-width: 900px) {
+ .home-header {
+ flex-wrap: wrap;
+ }
+ .home-header nav {
+ order: 3;
+ flex-basis: 100%;
+ }
+ .home-welcome {
+ gap: 24px;
+ }
+ .home-date {
+ flex-basis: 190px;
+ }
+ .home-overview {
+ gap: 20px;
+ }
+ .home-overview > div + div {
+ padding-left: 20px;
+ }
+ .home-starters {
+ gap: 18px;
+ }
+}
+@media (max-width: 680px) {
+ .home-header {
+ padding: 22px 24px;
+ gap: 20px;
+ }
+ .home-header nav {
+ justify-content: space-between;
+ gap: 20px;
+ }
+ .home-account-name {
+ display: none;
+ }
+ .home-account {
+ gap: 8px;
+ }
+ .home-account .ds-button {
+ margin-left: 0;
+ }
+ .home-root .ds-main {
+ padding: 0 24px;
+ }
+ .home-welcome {
+ display: block;
+ padding: 30px 0 24px;
+ }
+ .home-welcome h1 {
+ font-size: clamp(46px, 10vw, 60px);
+ margin: 14px 0;
+ }
+ .home-date {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 10px;
+ margin-top: 18px;
+ padding: 0;
+ font-size: 12px;
+ }
+ .home-date .ds-tiny-cross,
+ .home-date > span:last-child {
+ display: none;
+ }
+ .home-date p {
+ margin: 0;
+ }
+ .home-overview {
+ grid-template-columns: 1fr 1fr;
+ gap: 18px;
+ padding: 20px 0;
+ }
+ .home-overview > .home-overview-note {
+ grid-column: 1 / -1;
+ border-left: 0;
+ border-top: 1px solid var(--ds-rule);
+ padding: 16px 0 0;
+ }
+ .home-overview-note .ds-eyebrow {
+ display: none;
+ }
+ .home-overview-note h2 {
+ font-size: 28px;
+ margin: 0 0 8px;
+ }
+ .home-overview-note p {
+ max-width: none;
+ }
+ .home-root .ds-section-top {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 24px;
+ }
+ .home-root .ds-section-top h2 {
+ font-size: 38px;
+ }
+ .home-starters {
+ grid-template-columns: 1fr;
+ gap: 0;
+ }
+ .home-starters > button {
+ padding: 22px 0;
+ border-bottom: 0;
+ }
+ .home-starters > button:last-child {
+ border-bottom: 1px solid var(--ds-rule);
+ }
+ .home-starter-symbol {
+ float: right;
+ margin: 0;
+ }
+ .home-starters p {
+ min-height: 0;
+ }
+ .home-starter-action {
+ margin-top: 18px;
+ }
+ .home-autosave {
+ display: none;
+ }
+ .home-habit-heading {
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ }
+ .home-habit h3 {
+ font-size: 23px;
+ }
+ .home-check-in {
+ margin-left: auto;
+ gap: 8px;
+ }
+ .home-check-in .ds-checkbox span {
+ max-width: none;
+ }
+ .home-tasks {
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 12px;
+ }
+ .home-create {
+ padding: 20px;
+ }
+ .home-create .ds-form-grid {
+ grid-template-columns: 1fr;
+ }
+ .home-create .ds-weekdays {
+ flex-wrap: wrap;
+ }
+ .home-footer {
+ flex-wrap: wrap;
+ }
+}
diff --git a/styles/landing.css b/styles/landing.css
new file mode 100644
index 0000000..f3b51f5
--- /dev/null
+++ b/styles/landing.css
@@ -0,0 +1,64 @@
+.landing-root { font-size: 16px; }
+.landing-root .ds-eyebrow { font-size: 11px; }
+.landing-root .ds-button { font-size: 14px !important; }
+.landing-header nav { font-size: 14px; }
+.landing-header > .ds-button { gap: 24px; }
+.landing-hero { padding: 88px 0 80px; }
+.landing-hero h1 { font-size: clamp(64px, 7.5vw, 100px); }
+.landing-hero .ds-intro-copy { max-width: 580px; font-size: 16px; }
+.landing-hero-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 28px; margin-top: 30px; }
+.landing-text-link { display: inline-flex; align-items: center; gap: 20px; padding: 10px 0; font-size: 14px; }
+.landing-signin-note { margin-top: 14px !important; color: var(--ds-secondary); font-size: 12px; }
+.landing-hero-note { flex: 0 0 200px; padding-bottom: 43px; font-size: 14px; }
+.landing-root .ds-demo-note { font-size: 12px; white-space: normal; }
+.landing-root .ds-footnote { font-size: 13px; }
+.landing-root .ds-preview-heading .ds-eyebrow { font-size: 11px; }
+.landing-root .ds-preview-heading > p { font-size: 13px; }
+.landing-root .ds-view-switch button { font-size: 14px; }
+.landing-root .ds-counter .ds-button { font-size: 23px !important; }
+.landing-demo-hint { max-width: 740px; }
+.landing-steps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 44px; list-style: none; padding: 0; margin: 44px 0 24px; }
+.landing-steps li { border-top: 1px solid var(--ds-rule); padding-top: 22px; }
+.landing-steps .ds-eyebrow { color: var(--ds-secondary); }
+.landing-steps h3 { font-family: var(--ds-serif); font-size: 29px; line-height: 1.15; margin: 22px 0 14px; }
+.landing-steps p, .landing-discord-copy > p { color: var(--ds-secondary); line-height: 1.8; }
+.landing-discord { display: grid; grid-template-columns: 1fr 1fr; gap: 64px; padding-top: 56px; padding-bottom: 64px; }
+.landing-discord h2 { font-size: clamp(38px, 4.5vw, 56px); margin-top: 24px; }
+.landing-discord-copy { padding-top: 4px; }
+.landing-discord-details { margin: 30px 0 0; }
+.landing-discord-details > div { border-top: 1px solid var(--ds-rule); padding: 18px 0; }
+.landing-discord-details dt { margin-bottom: 6px; font-size: 14px; }
+.landing-discord-details dd { margin: 0; color: var(--ds-secondary); font-size: 14px; line-height: 1.7; }
+.landing-closing { display: flex; align-items: center; justify-content: space-between; gap: 32px; padding: 56px 0 64px; }
+.landing-closing h2 { font-size: clamp(44px, 5.5vw, 64px); margin-top: 22px; }
+.landing-closing > div:last-child { text-align: right; }
+.landing-footer { font-size: 13px; }
+.landing-auth-error { border-bottom: 1px solid var(--ds-rule); padding: 20px 0; font-size: 14px; }
+.landing-account-state { max-width: 600px; margin: auto; padding: 96px 24px; }
+.landing-account-state p { margin: 32px 0 20px; color: var(--ds-secondary); }
+
+@media (max-width: 900px) {
+ .landing-hero-note { flex-basis: 160px; }
+ .landing-steps { gap: 24px; }
+ .landing-discord { gap: 32px; }
+}
+@media (max-width: 680px) {
+ .landing-header { flex-wrap: wrap; gap: 20px; padding: 22px 24px; }
+ .landing-header nav { order: 3; flex-basis: 100%; justify-content: space-between; gap: 16px; }
+ .landing-root .ds-main { padding: 0 24px; }
+ .landing-hero { padding: 52px 0; }
+ .landing-hero h1 { font-size: clamp(54px, 12vw, 78px); letter-spacing: -1.5px; }
+ .landing-hero-note { display: none; }
+ .landing-desktop-break { display: none; }
+ .landing-root .ds-section-top { align-items: flex-start; }
+ .landing-root .ds-section-top h2 { font-size: 38px; }
+ .landing-root .ds-preview-toolbar { flex-wrap: wrap; gap: 8px 20px; }
+ .landing-root .ds-view-switch { gap: 20px; }
+ .landing-root .ds-preview-heading { align-items: flex-start; flex-direction: column; gap: 10px; }
+ .landing-steps { grid-template-columns: 1fr; gap: 28px; margin-top: 32px; }
+ .landing-steps h3 { margin-top: 14px; }
+ .landing-discord { grid-template-columns: 1fr; gap: 28px; padding: 40px 0; }
+ .landing-closing { align-items: flex-start; flex-direction: column; padding: 40px 0; }
+ .landing-closing > div:last-child { text-align: left; }
+ .landing-footer { flex-wrap: wrap; gap: 20px; }
+}