From bfa54851c24735541a6a023d9076ab13bf6ed34c Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 4 Sep 2026 12:37:56 +0200 Subject: [PATCH] feat: restructure styles and components for improved design system - Added typography and design system styles to globals.css. - Removed deprecated home.css and landing.css files. - Introduced DiscordSignInButton component for Discord authentication. - Created Card and CardGrid components for structured content display. - Implemented ContainerShowcase to demonstrate card usage and layout. - Added DesignSystemTabs for navigation between design system sections. - Established typography.css for consistent text styling across components. - Added tests for typography styles to ensure compliance with design standards. --- README.md | 56 +- src/App.test.tsx | 654 ++--------- src/App.tsx | 56 +- src/components/AuthControls.tsx | 10 +- src/components/DiscordSignInButton.tsx | 13 + src/components/LandingDemo.tsx | 67 -- .../design-system/CalendarHeatmap.tsx | 2 +- src/components/design-system/Card.tsx | 44 + .../design-system/ContainerShowcase.tsx | 64 + .../design-system/DesignSystemTabs.tsx | 63 + .../design-system/EditingWorkbench.tsx | 2 +- src/components/design-system/primitives.tsx | 17 +- src/pages/About.tsx | 3 - src/pages/DesignSystem.tsx | 1026 +++++++++-------- src/pages/Home.tsx | 744 ------------ src/pages/Landing.tsx | 105 -- src/pages/Settings.tsx | 3 - styles/design-system.css | 423 +++---- styles/globals.css | 2 + styles/home.css | 603 ---------- styles/landing.css | 64 - styles/typography.css | 55 + styles/typography.test.ts | 38 + 23 files changed, 1177 insertions(+), 2937 deletions(-) create mode 100644 src/components/DiscordSignInButton.tsx delete mode 100644 src/components/LandingDemo.tsx create mode 100644 src/components/design-system/Card.tsx create mode 100644 src/components/design-system/ContainerShowcase.tsx create mode 100644 src/components/design-system/DesignSystemTabs.tsx delete mode 100644 src/pages/About.tsx delete mode 100644 src/pages/Home.tsx delete mode 100644 src/pages/Landing.tsx delete mode 100644 src/pages/Settings.tsx delete mode 100644 styles/home.css delete mode 100644 styles/landing.css create mode 100644 styles/typography.css create mode 100644 styles/typography.test.ts diff --git a/README.md b/README.md index daa2be4..d7da6d8 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 has a public landing page and a connected signed-in habit dashboard. +React Router client currently contains only the design system, ready for a fresh page redesign. See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress, combined calendars, historical corrections, daily resets, and optional count carryover. @@ -13,12 +13,17 @@ bun dev Open http://127.0.0.1:3000. Set `PORT` to use a different port. -- Client pages: `/`, `/about`, `/settings`. +- Client page: `/design-system`. Root and all other client URLs redirect there. - Hono endpoint: `GET /api/health`. - Unknown API routes return JSON with a 404 status. ### Design system preview +The library uses seven tabs: Foundations, Components, Layout, Calendars, Playground, +Editing, and References. Foundations is the default. Each tab has a URL hash; +existing section links still work. Arrow keys, Home, and End navigate the tabs. +Switching tabs preserves unsaved demo state; browser back/forward restores the section. + 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, @@ -30,39 +35,24 @@ 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. +The previous Landing, Home, About, and Settings pages have been removed. The +backend API, authentication infrastructure, database, and reusable components remain +available for the redesigned pages. The design system does not load account data +or call the API. -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. +`styles/globals.css` is the only stylesheet +entry point; component styles are in Tailwind's components layer so utilities can +override them predictably. Tailwind Preflight remains omitted. -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. +`styles/typography.css` defines reusable Tailwind classes: `type-display` (72/81), +`type-section` (48/54), `type-title` (32/40), `type-lead` (20/30), `type-body` +(16/24), `type-small` and `type-label` (14/21), and `type-control` (24/30, symbols). +Values are font-size/line-height in pixels at the default 16px root, implemented +in rem. At 640px and below, display steps to 48/54 and section to 36/45; body and +supporting text never shrink. Use these classes directly or with `@apply`, not +bespoke font sizes or viewport-based type. See `/design-system#foundations` for +live specimens. Reuse `SectionHeading`, `Button`, `ButtonLink`, and the shared +`DiscordSignInButton` for headings and calls to action. ```sh bun run typecheck diff --git a/src/App.test.tsx b/src/App.test.tsx index c689811..68a0218 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -10,34 +10,18 @@ import { } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; -import { MemoryRouter } from "react-router"; +import { MemoryRouter, useLocation, useNavigate } 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. +// Keep the design system independent of authentication 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 [ @@ -90,26 +74,28 @@ afterAll(() => { } }); +function LocationProbe() { + const location = useLocation(); + const navigate = useNavigate(); + return <> + {location.pathname} + {location.hash} + + + ; +} + 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( @@ -177,516 +163,114 @@ test("unscheduled dates stay inspectable without a progress-square fill", async ).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, - ); +describe("design-system-only routing", () => { + for (const path of ["/design-system", "/design-system/", "/", "/about", "/settings", "/missing", "/?auth_error=denied"]) { + test(`renders only the public design system at ${path}`, async () => { + await render(path); + expect(container.querySelector("#ds-title")).not.toBeNull(); + expect(container.querySelectorAll("main")).toHaveLength(1); + expect(["/design-system", "/design-system/"]).toContain(container.querySelector('[data-testid="pathname"]')!.textContent!); + expect(container.querySelector(".landing-root")).toBeNull(); + expect(container.querySelector(".home-root")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(container.querySelectorAll('[role="tablist"]')).toHaveLength(1); + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(7); + expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1); + for (const tab of container.querySelectorAll('[role="tab"]')) { + const panel = document.getElementById(tab.getAttribute("aria-controls")!); + expect(panel?.getAttribute("aria-labelledby")).toBe(tab.id); } - } 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"); + test("design system playground remains interactive without API requests", async () => { + await render("/design-system#playground"); + const tabs = Array.from(container.querySelectorAll("button")); + const detail = tabs.find(button => button.textContent?.trim() === "Habit detail")!; + expect(detail).toBeDefined(); + await act(async () => detail.click()); + expect(detail.getAttribute("aria-pressed")).toBe("true"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("design system tabs", () => { + const tab = (id: string) => container.querySelector(`#ds-tab-${id}`)!; + const panel = () => container.querySelector('[role="tabpanel"]:not([hidden])')!; + const clickTab = async (id: string) => { await act(async () => tab(id).click()); }; + const press = async (id: string, key: string) => { + await act(async () => tab(id).dispatchEvent( + new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent, + )); + }; + + test("defaults to foundations and each tab exposes only its own panel", async () => { + await render(); + expect(panel().id).toBe("ds-panel-foundations"); + for (const id of ["components", "containers", "calendar-states", "playground", "editing", "views", "foundations"]) { + await clickTab(id); + expect(panel().id).toBe(`ds-panel-${id}`); + expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1); + expect(tab(id).getAttribute("aria-selected")).toBe("true"); + expect(container.querySelectorAll('[role="tab"][tabindex="0"]')).toHaveLength(1); + expect(container.querySelector('[data-testid="hash"]')?.textContent).toBe(`#${id}`); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("supports arrow keys, Home, End, and wraparound with focus", async () => { + await render(); + await press("foundations", "ArrowLeft"); + expect(document.activeElement).toBe(tab("views")); + expect(panel().id).toBe("ds-panel-views"); + await press("views", "ArrowRight"); + expect(document.activeElement).toBe(tab("foundations")); + await press("foundations", "End"); + expect(document.activeElement).toBe(tab("views")); + await press("views", "Home"); + expect(document.activeElement).toBe(tab("foundations")); + expect(panel().id).toBe("ds-panel-foundations"); + }); + + test("honors deep links and browser history", async () => { + await render("/design-system#editing"); + expect(panel().id).toBe("ds-panel-editing"); + await clickTab("containers"); + const historyButton = (label: string) => Array.from(container.querySelectorAll("button")).find(button => button.textContent === label)!; + await act(async () => historyButton("History back").click()); + expect(panel().id).toBe("ds-panel-editing"); + await act(async () => historyButton("History forward").click()); + expect(panel().id).toBe("ds-panel-containers"); + }); + + test("keeps example state and opens the playground from View habit", async () => { + await render("/design-system#components"); + await act(async () => panel().querySelector('[aria-label="Increase example count"]')!.click()); + await clickTab("editing"); + await clickTab("components"); + expect(panel().querySelector("output")?.textContent).toBe("4 / 8"); + const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!; + await act(async () => viewHabit.click()); + expect(panel().id).toBe("ds-panel-playground"); + expect(panel().querySelector('.ds-view-switch [aria-pressed="true"]')?.textContent).toBe("Habit detail"); + expect(document.activeElement).toBe(tab("playground")); + }); + + test("retains child component state and skip link does not switch tabs", async () => { + await render("/design-system#containers"); + const reading = panel().querySelector('input[type="checkbox"]')!; + await act(async () => reading.click()); + expect(reading.checked).toBe(true); + await clickTab("foundations"); + await clickTab("containers"); + expect(reading.checked).toBe(true); + await act(async () => container.querySelector(".ds-skip-link")!.click()); + expect(panel().id).toBe("ds-panel-containers"); + expect(document.activeElement?.id).toBe("ds-main"); + }); + + test("an unknown section safely falls back to foundations", async () => { + await render("/design-system#unknown"); + expect(panel().id).toBe("ds-panel-foundations"); }); }); diff --git a/src/App.tsx b/src/App.tsx index d6bf752..f6c9c60 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,57 +1,11 @@ -import { Route, Routes, useLocation, useNavigate } from "react-router"; -import { AuthControls } from "./components/AuthControls"; -import { Home } from "./pages/Home"; -import { About } from "./pages/About"; -import { Settings } from "./pages/Settings"; +import { Navigate, Route, Routes } from "react-router"; 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}

- - } -
-
- ); - if (!user) return ; - return ; - } - return ( - <> - - -
- - } /> - } /> - } /> - Page not found} /> - -
- + + } /> + } /> + ); } diff --git a/src/components/AuthControls.tsx b/src/components/AuthControls.tsx index 4e8c77a..f881fd2 100644 --- a/src/components/AuthControls.tsx +++ b/src/components/AuthControls.tsx @@ -1,19 +1,21 @@ import { useAuth } from "./AuthProvider"; +import { Button } from "./design-system/primitives"; +import { DiscordSignInButton } from "./DiscordSignInButton"; export function AuthControls() { const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth(); return ( -
+
{loading ?

Loading account…

: user ? (

Signed in as {user.displayName} · {user.timezone}{" "} - {" "} + {" "} View my profile

- ) : !accountError &&

} + ) : !accountError &&

} {error &&

{error}

} - {accountError && } + {accountError && }
); } diff --git a/src/components/DiscordSignInButton.tsx b/src/components/DiscordSignInButton.tsx new file mode 100644 index 0000000..f787474 --- /dev/null +++ b/src/components/DiscordSignInButton.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from "react"; +import { Button } from "./design-system/primitives"; + +export function DiscordSignInButton({ children = "Sign in with Discord", ...props }: ComponentProps) { + return ( + + ); +} diff --git a/src/components/LandingDemo.tsx b/src/components/LandingDemo.tsx deleted file mode 100644 index a5b26d0..0000000 --- a/src/components/LandingDemo.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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) => ( - - ))} -
- -
-
-
-

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 index 594339a..32cb8f8 100644 --- a/src/components/design-system/CalendarHeatmap.tsx +++ b/src/components/design-system/CalendarHeatmap.tsx @@ -32,7 +32,7 @@ export function CalendarHeatmap({ historyLabel?: string; legend?: ReactNode; }) { - const [months, setMonths] = useState(6); + const [months, setMonths] = useState(12); const latestDate = allDays.findLast((day) => day.state !== "future")?.date ?? allDays.at(-1)?.date; diff --git a/src/components/design-system/Card.tsx b/src/components/design-system/Card.tsx new file mode 100644 index 0000000..1128059 --- /dev/null +++ b/src/components/design-system/Card.tsx @@ -0,0 +1,44 @@ +import { useId, type HTMLAttributes, type ReactNode } from "react"; + +/** A flat content group. Actions remain explicit buttons or links inside it. */ +export function Card({ + eyebrow, + heading, + children, + footer, + variant = "soft", + span = "standard", + className = "", + ...props +}: HTMLAttributes & { + eyebrow?: string; + heading: ReactNode; + footer?: ReactNode; + variant?: "soft" | "outlined"; + span?: "standard" | "wide" | "featured"; +}) { + const headingId = useId(); + return ( +
+
+ {eyebrow &&

{eyebrow}

} +

{heading}

+
+ {children &&
{children}
} + {footer &&
{footer}
} +
+ ); +} + +/** Equal cards by default; bento spans keep the same reading order on mobile. */ +export function CardGrid({ + layout = "equal", + className = "", + ...props +}: HTMLAttributes & { layout?: "equal" | "bento" }) { + return
; +} diff --git a/src/components/design-system/ContainerShowcase.tsx b/src/components/design-system/ContainerShowcase.tsx new file mode 100644 index 0000000..d670343 --- /dev/null +++ b/src/components/design-system/ContainerShowcase.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { Card, CardGrid } from "./Card"; +import { Button, Checkbox, Counter, SectionHeading } from "./primitives"; + +export function ContainerShowcase() { + const [water, setWater] = useState(3); + const [read, setRead] = useState(false); + + return ( +
+ A little structure. Room to breathe.}> + Group related content with a quiet surface. Keep open layouts for long lists and calendars. + + +
+

Two quiet surfaces

+ Card · soft / outlined +
+ + +

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

+
+ +

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

+
+
+ +
+

A day, arranged simply

+ CardGrid · bento +
+ + Small things, adding up.} + footer={

Interactive example · nothing is saved

} + > +
+

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

+

habits complete

+
+

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

+
+ + +

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

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

One page is a place to start.

+
+ +

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

+
+
+
+

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

+ +
+
{'\n  \n  \n  \n  \n'}
+
+ ); +} diff --git a/src/components/design-system/DesignSystemTabs.tsx b/src/components/design-system/DesignSystemTabs.tsx new file mode 100644 index 0000000..3300254 --- /dev/null +++ b/src/components/design-system/DesignSystemTabs.tsx @@ -0,0 +1,63 @@ +import { useRef, type ReactNode } from "react"; +import { useLocation, useNavigate } from "react-router"; + +type SystemTab = { id: string; label: string; content: ReactNode }; + +export function DesignSystemTabs({ tabs }: { tabs: SystemTab[] }) { + const { hash } = useLocation(); + const navigate = useNavigate(); + const buttons = useRef>([]); + const selected = tabs.findIndex((tab) => hash === `#${tab.id}`); + const active = selected < 0 ? 0 : selected; + + function select(index: number) { + navigate({ hash: `#${tabs[index]!.id}` }); + } + + return ( +
+
+ {tabs.map((tab, index) => ( + + ))} +
+ {/* Keep panels mounted so editors and examples retain their local state. */} + {tabs.map((tab, index) => ( + + ))} +
+ ); +} diff --git a/src/components/design-system/EditingWorkbench.tsx b/src/components/design-system/EditingWorkbench.tsx index aba9c18..4ea085e 100644 --- a/src/components/design-system/EditingWorkbench.tsx +++ b/src/components/design-system/EditingWorkbench.tsx @@ -361,7 +361,7 @@ export function EditingWorkbench({ >
- 02 / EDITING & HISTORY + EDITING & HISTORY

Room for real life.

diff --git a/src/components/design-system/primitives.tsx b/src/components/design-system/primitives.tsx index 4ba1c8e..37cb102 100644 --- a/src/components/design-system/primitives.tsx +++ b/src/components/design-system/primitives.tsx @@ -1,5 +1,6 @@ import type { ButtonHTMLAttributes, + AnchorHTMLAttributes, InputHTMLAttributes, ReactNode, } from "react"; @@ -21,19 +22,31 @@ export function Button({ ); } +export function ButtonLink({ + variant = "text", + className = "", + ...props +}: AnchorHTMLAttributes & { + variant?: "primary" | "secondary" | "text"; +}) { + return ; +} + export function SectionHeading({ number, title, children, + id, }: { number: string; - title: string; + title: ReactNode; children?: ReactNode; + id?: string; }) { return (
{number} -

{title}

+

{title}

{children &&

{children}

}
); diff --git a/src/pages/About.tsx b/src/pages/About.tsx deleted file mode 100644 index a3648d3..0000000 --- a/src/pages/About.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function About() { - return

About

; -} diff --git a/src/pages/DesignSystem.tsx b/src/pages/DesignSystem.tsx index 3b9b574..49c5275 100644 --- a/src/pages/DesignSystem.tsx +++ b/src/pages/DesignSystem.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link } from "react-router"; +import { Link, useNavigate } from "react-router"; import { Button, Checkbox, @@ -9,6 +9,7 @@ import { import { CalendarHeatmap } from "../components/design-system/CalendarHeatmap"; import { HabitChart } from "../components/design-system/HabitChart"; import { EditingWorkbench } from "../components/design-system/EditingWorkbench"; +import { ContainerShowcase } from "../components/design-system/ContainerShowcase"; import type { HabitColors } from "../components/design-system/HabitColorPicker"; import { combinedProgress, @@ -18,11 +19,13 @@ import { } 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"; + +import { DesignSystemTabs } from "../components/design-system/DesignSystemTabs"; type View = "Today" | "Habit detail" | "Combined chart"; export function DesignSystem() { + const navigate = useNavigate(); const [habitColors, setHabitColors] = useState({ ...HABIT_COLORS }); const [view, setView] = useState("Today"); const [water, setWater] = useState(7); @@ -64,538 +67,539 @@ export function DesignSystem() { return (
- - Skip to content - + { + event.preventDefault(); + document.getElementById("ds-main")?.focus(); + }}>Skip to content
- + minabot - - DESIGN SYSTEM / 01 + Interface library / 01
-
-
+
+

MINABOT — INTERFACE LANGUAGE

-

- Less interface. -
- More intention. -

-

- A quiet foundation for everyday progress. -
A quiet canvas. A color for every habit. -

+

Design system

+

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

-
- -

- Flat by design. -
- Space, not containers. -
- Clarity in every state. -

- - Explore the system - -
-
- -
-
-
- 01 / IN PRACTICE -

- A little, every day. -

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

FRIDAY, SEPTEMBER 4, 2026

-

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

-
-

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

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

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

-
- ) : ( -
- {isIndividual ? ( -
- - {water} - / 8 - glasses - - + Interactive examples · nothing is saved + + + + A small vocabulary. +
+ Used with intention. +
+
+
+

Typography

+ Instrument Serif + system sans
- ) : ( -
+
+ + Small steps. +
+ Lasting rhythm. +
+

Instrument Serif · Regular & italic · 72/81px display, 48/54px on small screens

+
+
+ Room to breathe. A clear next step. +

System sans · 16/24px body & controls · 14/21px supporting text

+
+
    {[ - "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, - ), - ) - } - /> + { name: "Section heading", className: "type-section", spec: "48/54px · small screens 36/45px" }, + { name: "Component title", className: "type-title", spec: "32/40px" }, + { name: "Lead text", className: "type-lead", spec: "20/30px" }, + { name: "Body & actions", className: "type-body", spec: "16/24px" }, + { name: "Supporting text", className: "type-small", spec: "14/21px" }, + { name: "SECTION LABEL", className: "type-label", spec: "14/21px · medium weight" }, + { name: "− / +", className: "type-control", spec: "24/30px · counter symbols" }, + ].map((type) =>
  • + {type.name} + {type.className} · {type.spec} +
  • )} +
+

Fixed rem sizes respect browser text preferences. Use the same type utilities on every page; no fluid type or page-specific size overrides. Ink and secondary text on paper exceed 4.5:1 contrast.

+
+

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} +

+
))}
- )} - -

- {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. +
+

Habit colors

+ Identity, not decoration +
+
+ {Object.entries(HABIT_COLORS).map(([name, color]) => ( + +