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.
This commit is contained in:
syntaxbullet
2026-09-04 12:37:56 +02:00
parent 084603bb6e
commit bfa54851c2
23 changed files with 1177 additions and 2937 deletions

View File

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

View File

@@ -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<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
let fetchMock: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;
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 <>
<output data-testid="pathname">{location.pathname}</output>
<output data-testid="hash">{location.hash}</output>
<button onClick={() => navigate(-1)}>History back</button>
<button onClick={() => navigate(1)}>History forward</button>
</>;
}
async function render(path = "/") {
await act(async () =>
root.render(
<MemoryRouter initialEntries={[path]}>
<App />
<LocationProbe />
</MemoryRouter>,
),
);
}
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<HTMLButtonElement>(
'[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<typeof fixture>;
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<HTMLInputElement>(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<HTMLImageElement>("img.home-avatar")!;
expect(avatar.src).toBe(avatarUrl);
expect(avatar.alt).toBe("Demo Users 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<HTMLButtonElement>(".home-starters > button")!
.click(),
);
expect(
container.querySelector<HTMLInputElement>("#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<HTMLElement>(".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<HTMLInputElement>("#habit-target")!.value,
).toBe("8");
expect(
container.querySelector<HTMLSelectElement>("#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<HTMLInputElement>("#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<HTMLInputElement>(`#habit-${manual.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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("Youre 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<HTMLButtonElement>("button"));
const detail = tabs.find(button => button.textContent?.trim() === "Habit detail")!;
expect(detail).toBeDefined();
await act(async () => detail.click());
expect(detail.getAttribute("aria-pressed")).toBe("true");
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("design system tabs", () => {
const tab = (id: string) => container.querySelector<HTMLButtonElement>(`#ds-tab-${id}`)!;
const panel = () => container.querySelector<HTMLElement>('[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<HTMLButtonElement>('[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<HTMLInputElement>('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<HTMLAnchorElement>(".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");
});
});

View File

@@ -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 <DesignSystem />;
return <AuthProvider><AppRoutes /></AuthProvider>;
}
function AppRoutes() {
const navigate = useNavigate();
const { pathname } = useLocation();
const { user, loading, accountError, retry } = useAuth();
if (pathname === "/") {
if (loading || accountError) return (
<div className="ds-root landing-root">
<main className="landing-account-state">
<a className="ds-wordmark" href="/">minabot.</a>
{loading ? <p role="status">Loading your account</p> : <>
<p role="alert">{accountError}</p>
<Button variant="secondary" onClick={retry}>Try again</Button>
</>}
</main>
</div>
);
if (!user) return <Landing />;
return <Home />;
}
return (
<>
<nav aria-label="Main navigation">
<button type="button" disabled={pathname === "/"} onClick={() => navigate("/")}>Home</button>{" "}
<button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "}
<button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button>
{" "}<button type="button" onClick={() => navigate("/design-system")}>Design system</button>
</nav>
<AuthControls />
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<h1>Page not found</h1>} />
</Routes>
</main>
</>
<Routes>
<Route path="/design-system" element={<DesignSystem />} />
<Route path="*" element={<Navigate to="/design-system" replace />} />
</Routes>
);
}

View File

@@ -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 (
<section aria-label="Account">
<section className="ds-account-controls" aria-label="Account">
{loading ? <p role="status">Loading account</p> : user ? (
<p>
Signed in as <strong>{user.displayName}</strong> · {user.timezone}{" "}
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
<Button variant="secondary" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</Button>{" "}
<a href="/api/me">View my profile</a>
</p>
) : !accountError && <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
) : !accountError && <p><DiscordSignInButton onClick={signIn} /></p>}
{error && <p role="alert">{error}</p>}
{accountError && <button type="button" onClick={retry}>Try again</button>}
{accountError && <Button variant="secondary" onClick={retry}>Try again</Button>}
</section>
);
}

View File

@@ -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<typeof Button>) {
return (
<Button {...props}>
<svg className="ds-button-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M20.317 4.37a19.792 19.792 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.211.375-.445.864-.609 1.249a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.618-1.249.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.675 4.37a.07.07 0 0 0-.032.027C.533 9.043-.32 13.575.099 18.057a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.095.252-.194.372-.294a.074.074 0 0 1 .078-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .079.01c.12.1.246.199.373.294a.077.077 0 0 1-.006.127c-.598.352-1.22.65-1.873.893a.076.076 0 0 0-.04.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.673-3.549-13.66a.061.061 0 0 0-.031-.03ZM8.02 15.331c-1.183 0-2.157-1.085-2.157-2.419s.955-2.419 2.157-2.419c1.211 0 2.176 1.095 2.157 2.419 0 1.334-.955 2.419-2.157 2.419Zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419s.955-2.419 2.157-2.419c1.211 0 2.176 1.095 2.157 2.419 0 1.334-.946 2.419-2.157 2.419Z" />
</svg>
{children}
</Button>
);
}

View File

@@ -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 (
<section className="ds-section landing-demo" id="demo" aria-labelledby="demo-title">
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">01 / A LITTLE, EVERY DAY</span>
<h2 id="demo-title">Small steps. <em>Visible progress.</em></h2>
</div>
<span className="ds-demo-note">Interactive demo · nothing is saved</span>
</div>
<div className="ds-preview-toolbar">
<div className="ds-view-switch" role="group" aria-label="Demo view">
{(["Today", "Combined progress"] as const).map((item) => (
<button key={item} type="button" aria-pressed={view === item} onClick={() => setView(item)}>{item}</button>
))}
</div>
<Button variant="text" onClick={() => { setWater(7); setTasks([true, true, false]); setView("Today"); }}>
Reset demo <span aria-hidden="true"></span>
</Button>
</div>
<div className="ds-preview-heading">
<div>
<p className="ds-eyebrow">A SAMPLE DAY / SEPTEMBER 4, 2026</p>
<h3>{view === "Today" ? "Make a little room for yourself." : "See your habits, together."}</h3>
</div>
<p aria-live="polite">{total.completed} of {total.due} habits complete</p>
</div>
{view === "Today" ? (
<div className="ds-habit-chart-grid">
<HabitChart name="Drink water" method="Count target" value={water} target={8} unit="glasses" color={HABIT_COLORS.water}>
<Counter label="glasses of water" value={water} target={8} onChange={setWater} />
</HabitChart>
<HabitChart name="Evening reset" method="Task-based" value={taskCount} target={3} unit="tasks" color={HABIT_COLORS.tasks}
tasks={["Clear desk", "Plan tomorrow", "Stretch"].map((label, index) => (
<Checkbox key={label} label={label} checked={tasks[index]} onChange={(event) => {
const checked = event.target.checked;
setTasks((current) => current.map((done, i) => i === index ? checked : done));
}} />
))}
/>
</div>
) : (
<CalendarHeatmap days={demoCalendar(total.completed, total.due)} label="Combined habit calendar" unit="habits complete" />
)}
<p className="ds-footnote landing-demo-hint">
{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."}
</p>
</section>
);
}

View File

@@ -32,7 +32,7 @@ export function CalendarHeatmap({
historyLabel?: string;
legend?: ReactNode;
}) {
const [months, setMonths] = useState<MonthView>(6);
const [months, setMonths] = useState<MonthView>(12);
const latestDate =
allDays.findLast((day) => day.state !== "future")?.date ??
allDays.at(-1)?.date;

View File

@@ -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<HTMLElement> & {
eyebrow?: string;
heading: ReactNode;
footer?: ReactNode;
variant?: "soft" | "outlined";
span?: "standard" | "wide" | "featured";
}) {
const headingId = useId();
return (
<article
aria-labelledby={headingId}
className={`ds-card ds-card--${variant} ds-card--${span} ${className}`}
{...props}
>
<header className="ds-card-header">
{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}
<h3 id={headingId}>{heading}</h3>
</header>
{children && <div className="ds-card-body">{children}</div>}
{footer && <footer className="ds-card-footer">{footer}</footer>}
</article>
);
}
/** Equal cards by default; bento spans keep the same reading order on mobile. */
export function CardGrid({
layout = "equal",
className = "",
...props
}: HTMLAttributes<HTMLDivElement> & { layout?: "equal" | "bento" }) {
return <div className={`ds-card-grid ds-card-grid--${layout} ${className}`} {...props} />;
}

View File

@@ -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 (
<section className="ds-section ds-container-showcase" id="containers" aria-labelledby="containers-title">
<SectionHeading number="CONTAINERS & LAYOUT" id="containers-title" title={<>A little structure. <em>Room to breathe.</em></>}>
Group related content with a quiet surface. Keep open layouts for long lists and calendars.
</SectionHeading>
<div className="ds-spec-heading ds-spec-heading--spaced">
<h3>Two quiet surfaces</h3>
<span className="ds-code">Card · soft / outlined</span>
</div>
<CardGrid>
<Card eyebrow="SOFT SURFACE" heading="A gentle grouping.">
<p>A neutral fill gathers related content without adding another divider. Use it for summaries, guidance, and a small set of actions.</p>
</Card>
<Card variant="outlined" eyebrow="OUTLINED SURFACE" heading="A clear boundary.">
<p>A fine border defines a standalone group on white. Useful when a form or a focused task needs its own space.</p>
</Card>
</CardGrid>
<div className="ds-spec-heading ds-spec-heading--spaced">
<h3>A day, arranged simply</h3>
<span className="ds-code">CardGrid · bento</span>
</div>
<CardGrid layout="bento" aria-label="Interactive bento layout example">
<Card
span="featured"
eyebrow="TODAY / YOUR OWN PACE"
heading={<>Small things, <em>adding up.</em></>}
footer={<p className="ds-muted type-small">Interactive example · nothing is saved</p>}
>
<div className="ds-card-summary" role="status">
<p className="type-display">{Number(water === 8) + Number(read)}<span className="type-title ds-muted"> / 2</span></p>
<p>habits complete</p>
</div>
<p>Make room for a glass of water and a few pages. A little progress belongs here, too.</p>
</Card>
<Card eyebrow="DAILY / 8 GLASSES" heading="Drink water" variant="outlined">
<Counter label="bento glasses of water" value={water} target={8} onChange={setWater} />
<p className="type-small" role="status">{water === 8 ? "Complete for today" : `${8 - water} glasses to go`}</p>
</Card>
<Card eyebrow="A FEW PAGES" heading="Read a little" variant="outlined">
<Checkbox label="Reading done" checked={read} onChange={(event) => setRead(event.target.checked)} />
<p className="type-small">One page is a place to start.</p>
</Card>
<Card span="wide" eyebrow="A GENTLE REMINDER" heading="Theres no catching up.">
<p>Come back to today. Your next small step is enough.</p>
</Card>
</CardGrid>
<div className="ds-container-guidance">
<p className="ds-footnote">Equal grids suit peer items. Bento gives one summary more room; smaller cards hold short tasks. Both collapse in reading order on small screens. Use one surface per group, with spacing inside and no shadows.</p>
<Button variant="text" onClick={() => { setWater(3); setRead(false); }}>Reset card examples </Button>
</div>
<pre className="ds-code ds-container-code"><code>{'<CardGrid layout="bento">\n <Card heading="Today" span="featured">…</Card>\n <Card heading="Water" variant="outlined">…</Card>\n <Card heading="Reading" variant="outlined">…</Card>\n <Card heading="A reminder" span="wide">…</Card>\n</CardGrid>'}</code></pre>
</section>
);
}

View File

@@ -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<Array<HTMLButtonElement | null>>([]);
const selected = tabs.findIndex((tab) => hash === `#${tab.id}`);
const active = selected < 0 ? 0 : selected;
function select(index: number) {
navigate({ hash: `#${tabs[index]!.id}` });
}
return (
<div className="ds-explorer">
<div className="ds-tab-bar" role="tablist" aria-label="Design system sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(element) => { buttons.current[index] = element; }}
type="button"
role="tab"
id={`ds-tab-${tab.id}`}
aria-controls={`ds-panel-${tab.id}`}
aria-selected={active === index}
tabIndex={active === index ? 0 : -1}
onClick={() => select(index)}
onKeyDown={(event) => {
let next = index;
if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
else if (event.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = tabs.length - 1;
else return;
event.preventDefault();
select(next);
buttons.current[next]?.focus();
}}
>
{tab.label}
</button>
))}
</div>
{/* Keep panels mounted so editors and examples retain their local state. */}
{tabs.map((tab, index) => (
<div
key={tab.id}
id={`ds-panel-${tab.id}`}
role="tabpanel"
aria-labelledby={`ds-tab-${tab.id}`}
tabIndex={0}
hidden={active !== index}
className="ds-tab-panel"
>
{tab.content}
</div>
))}
</div>
);
}

View File

@@ -361,7 +361,7 @@ export function EditingWorkbench({
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">02 / EDITING & HISTORY</span>
<span className="ds-eyebrow">EDITING & HISTORY</span>
<h2 id="editing-title">
Room for <em>real life.</em>
</h2>

View File

@@ -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<HTMLAnchorElement> & {
variant?: "primary" | "secondary" | "text";
}) {
return <a className={`ds-button ds-button--${variant} ${className}`} {...props} />;
}
export function SectionHeading({
number,
title,
children,
id,
}: {
number: string;
title: string;
title: ReactNode;
children?: ReactNode;
id?: string;
}) {
return (
<header className="ds-section-heading">
<span className="ds-eyebrow">{number}</span>
<h2>{title}</h2>
<h2 id={id}>{title}</h2>
{children && <p>{children}</p>}
</header>
);

View File

@@ -1,3 +0,0 @@
export function About() {
return <h1>About</h1>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,744 +0,0 @@
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 (
<details className="home-count-entry">
<summary>Set a total</summary>
<form onSubmit={submit}>
<label htmlFor={`count-${habit.habitId}`}>Total {habit.unit}</label>
<input
id={`count-${habit.habitId}`}
type="number"
min={0}
max={1_000_000_000}
step={1}
required
value={value}
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
/>
<Button type="submit" variant="secondary" disabled={disabled}>
Save total
</Button>
</form>
</details>
);
}
export function Home() {
const { user, signOut, busy: authBusy, error: authError, signIn } = useAuth();
const [today, setToday] = useState<TodayResponse | null>(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<HabitStarter | null>(null);
const [editing, setEditing] = useState<{
habit: TodayHabit;
color: string;
} | null>(null);
const [deleting, setDeleting] = useState<TodayHabit | null>(null);
const [revision, setRevision] = useState(0);
const [failedAvatar, setFailedAvatar] = useState<string | null>(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<TodayResponse>("/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<TodayHabit>(
`/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 (
<div className="ds-root home-root" id="top">
<a className="ds-skip-link" href="#home-main">
Skip to content
</a>
<header className="ds-header home-header">
<Link className="ds-wordmark" to="/" aria-label="Minabot home">
minabot.
</Link>
<nav aria-label="Main navigation">
<Link to="/" aria-current="page">
Home
</Link>
<a href="#today">Today</a>
<a href={empty ? "#first-habit" : "#rhythm"}>
{empty ? "Get started" : "Your rhythm"}
</a>
</nav>
<div className="home-account">
{user.avatarUrl && failedAvatar !== user.avatarUrl ? (
<img
className="home-avatar"
src={user.avatarUrl}
alt={`${name}s Discord avatar`}
width={30}
height={30}
referrerPolicy="no-referrer"
onError={() => setFailedAvatar(user.avatarUrl)}
/>
) : (
<span className="home-avatar" aria-hidden="true">
{name.slice(0, 1).toUpperCase()}
</span>
)}
<span className="home-account-name" title={name}>
{name}
</span>
<Button
variant="text"
disabled={authBusy || busy || !!starter}
onClick={() => void signOut()}
>
{authBusy ? "Signing out…" : "Sign out"}
</Button>
</div>
</header>
<main className="ds-main" id="home-main">
<section className="home-welcome" aria-labelledby="home-title">
<div>
<p className="ds-eyebrow">YOUR SPACE / YOUR OWN PACE</p>
<h1 id="home-title">
{empty ? "Welcome home," : "A little today,"}
<br />
<em>{name}.</em>
</h1>
<p className="ds-intro-copy">
{empty
? "This is your space to build a rhythm. Lets start with one small thing."
: "Pick up where you are. Make room for what matters."}
</p>
</div>
<div className="home-date">
<span className="ds-tiny-cross" aria-hidden="true">
+
</span>
{today ? (
<>
<time dateTime={today.date}>
{formatTrackingDate(today.date)}
</time>
<p>{today.timezone.replaceAll("_", " ")}</p>
</>
) : (
<p>Your daily home base.</p>
)}
<span className="ds-footnote">A new day. Your own pace.</span>
</div>
</section>
{authError && (
<p className="home-error" role="alert">
{authError}
</p>
)}
{error && (
<div className="home-error" role="alert">
<p>
{error}
{today && " The view below may be out of date."}
</p>
{error.includes("session has expired") ? (
<Button variant="secondary" onClick={signIn}>
Sign in again
</Button>
) : (
<Button
variant="secondary"
disabled={busy}
onClick={() => {
setLoading(!today);
void refresh();
}}
>
Try again
</Button>
)}
</div>
)}
<p className="home-notice" role="status">
{notice}
</p>
{loading && (
<div className="home-state" role="status">
Getting your day ready
</div>
)}
{today && (
<>
<section className="home-overview" aria-label="Today at a glance">
<div>
<span className="ds-eyebrow">
{empty ? "A FRESH START" : "TODAYS PROGRESS"}
</span>
<p className="home-stat">
{today.completed}
<span> / {today.due}</span>
</p>
<p>habits complete</p>
<progress
value={today.completed}
max={today.due || 1}
aria-label="Habits completed today"
/>
</div>
<div>
<span className="ds-eyebrow">
{empty ? "ONE SMALL STEP" : "STILL TO COME"}
</span>
<p className="home-stat">
{empty ? "01" : String(remaining).padStart(2, "0")}
</p>
<p>
{empty
? "is all it takes to begin"
: remaining === 1
? "habit left for today"
: "habits left for today"}
</p>
</div>
<div className="home-overview-note">
<span className="ds-eyebrow">
{empty
? "NO PERFECT START REQUIRED"
: today.due === 0
? "ROOM TO REST"
: remaining === 0
? "ENOUGH FOR TODAY"
: "A GENTLE REMINDER"}
</span>
<h2>
{empty ? (
<>
Small is <em>a good start.</em>
</>
) : today.due === 0 ? (
<>
A day off <em>counts, too.</em>
</>
) : remaining === 0 ? (
<>
You showed up. <em>Enjoy that.</em>
</>
) : (
<>
A little is <em>still something.</em>
</>
)}
</h2>
<p>
{empty
? "Choose something easy enough to come back to tomorrow."
: today.due === 0
? "Nothing is scheduled today. Your habits will be here when its time."
: remaining === 0
? "Everything scheduled for today is complete. No extra credit needed."
: "Check in as you go. Every bit of progress belongs here."}
</p>
</div>
</section>
<section
className="ds-section home-today"
id="today"
aria-labelledby="today-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">01 / YOUR DAILY CHECK-IN</span>
<h2 id="today-title">
{empty ? (
<>
Begin with <em>a little.</em>
</>
) : (
<>
Make today <em>your own.</em>
</>
)}
</h2>
</div>
<Button
disabled={busy || !!error}
onClick={() => setStarter({ name: "", method: "manual" })}
>
{empty ? "Create your first habit" : "New habit"}
<span aria-hidden="true">+</span>
</Button>
</div>
{empty ? (
<div id="first-habit" className="home-empty">
<p>No habits yet. No catching up to do.</p>
<p className="ds-muted">
Create your own, or use an idea below as a starting point.
You can make it yours before saving.
</p>
<div className="home-starters">
{starters.map((item) => (
<button
type="button"
key={item.name}
disabled={!!error}
onClick={() => setStarter(item)}
>
<span
className="home-starter-symbol"
aria-hidden="true"
>
{item.symbol}
</span>
<h3>{item.name}</h3>
<p>{item.description}</p>
<span className="home-starter-action">
Make it mine <span aria-hidden="true"></span>
</span>
</button>
))}
</div>
</div>
) : (
<>
<div className="ds-preview-toolbar">
<div
className="ds-view-switch"
role="group"
aria-label="Todays habits"
>
<button
type="button"
aria-pressed={filter === "all"}
onClick={() => setFilter("all")}
>
All today <span>{today.due}</span>
</button>
<button
type="button"
aria-pressed={filter === "remaining"}
onClick={() => setFilter("remaining")}
>
Remaining <span>{remaining}</span>
</button>
</div>
<span className="home-autosave">Saved as you go</span>
</div>
{shown.length === 0 && (
<div className="home-state">
<h3>
{today.due === 0
? "A little breathing room."
: "Youre all caught up."}
</h3>
<p>
{today.due === 0
? "No habits are scheduled for today. A day off isnt a missed day."
: "All of todays habits are complete. Take a moment for yourself."}
</p>
{filter === "remaining" && today.due > 0 && (
<Button variant="text" onClick={() => setFilter("all")}>
View completed habits
</Button>
)}
</div>
)}
<div className="home-habits">
{shown.map((habit) => (
<article
className={`home-habit${habit.complete ? " home-habit--complete" : ""}`}
key={habit.habitId}
aria-labelledby={`habit-${habit.habitId}`}
>
<div className="home-habit-heading">
<div>
<h3 id={`habit-${habit.habitId}`}>{habit.name}</h3>
<p className="home-habit-status">
{habit.method === "manual"
? "SIMPLE CHECK-IN"
: habit.method === "count"
? "COUNT TARGET"
: "TASK ROUTINE"}{" "}
· {scheduleLabel(habit.requirements?.schedule)}
{habit.complete && " · Complete"}
{habit.carriedFrom && (
<span>
{" "}
· Includes progress from {habit.carriedFrom}
</span>
)}
</p>
</div>
<div className="home-check-in">
{habit.method === "manual" ? (
<Checkbox
label="Done"
aria-label={`Mark ${habit.name} complete`}
checked={habit.complete}
disabled={busy || !!error}
onChange={(event) =>
void log(habit, {
done: event.target.checked,
})
}
/>
) : habit.method === "count" ? (
<Counter
label={`${habit.name} (${habit.unit})`}
value={habit.value}
target={habit.target ?? 1}
disabled={busy || !!error}
onChange={(count) => void log(habit, { count })}
/>
) : (
<span className="home-task-count">
{habit.value}
<span> / {habit.target}</span>
</span>
)}
<a
className="home-history-link"
href={`#history-${habit.habitId}`}
aria-label={`View ${habit.name} history`}
>
</a>
</div>
</div>
{habit.method === "tasks" && (
<details className="home-task-details">
<summary>
Tasks for today{" "}
<span>
{habit.value} / {habit.target}
</span>
</summary>
<div className="home-tasks">
{habit.tasks.map((task) => (
<Checkbox
key={task.taskId}
label={task.name}
checked={task.done}
disabled={busy || !!error}
onChange={(event) =>
void log(
habit,
{ done: event.target.checked },
task.taskId,
)
}
/>
))}
</div>
</details>
)}
{habit.method === "count" && (
<CountEntry
habit={habit}
disabled={busy || !!error}
onSave={(count) => void log(habit, { count })}
/>
)}
</article>
))}
</div>
{offDay.length > 0 && (
<details className="home-off-day">
<summary>
Not scheduled today <span>{offDay.length}</span>
</summary>
<p className="ds-footnote">
These habits arent included in todays progress.
</p>
{offDay.map((habit) => (
<div key={habit.habitId}>
<span>
<strong>{habit.name}</strong>
<small>
{scheduleLabel(habit.requirements?.schedule)}
{habit.method === "tasks" &&
" · No tasks due today"}
</small>
</span>
<a href={`#history-${habit.habitId}`}>
View history
</a>
</div>
))}
</details>
)}
</>
)}
</section>
{!empty && (
<section
className="ds-section home-rhythm"
id="rhythm"
aria-labelledby="rhythm-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">02 / THE BIGGER PICTURE</span>
<h2 id="rhythm-title">
Find <em>your rhythm.</em>
</h2>
</div>
</div>
<div className="ds-habit-chart-grid">
{today.habits.map((habit) => (
<HabitHistory
key={habit.habitId}
habit={habit}
date={today.date}
revision={revision}
disabled={busy || !!error}
onEdit={(color) => setEditing({ habit, color })}
onDelete={() => setDeleting(habit)}
/>
))}
</div>
<p className="ds-footnote">
Your real progress, one day at a time. Select a date to look
closer. Days off stay out of your score.
</p>
</section>
)}
</>
)}
<footer className="ds-footer home-footer">
<Link className="ds-wordmark" to="/">
minabot.
</Link>
<span>A little, every day.</span>
<a href="#top">Back to top </a>
</footer>
</main>
{starter && today && (
<CreateHabit
date={today.date}
starter={starter}
onClose={() => setStarter(null)}
onCreated={(name) => {
setStarter(null);
setNotice(`${name} is ready. Your rhythm starts here.`);
setFilter("all");
void refresh();
}}
/>
)}
{editing?.habit.requirements && today && (
<CreateHabit
date={today.date}
editing={{
id: editing.habit.habitId,
config: editing.habit.requirements,
}}
starter={{
name: editing.habit.requirements.name,
method: editing.habit.requirements.method,
color: editing.color,
...(editing.habit.requirements.method === "count"
? {
target: editing.habit.requirements.target,
unit: editing.habit.requirements.unit,
}
: {}),
}}
onClose={() => setEditing(null)}
onCreated={(name) => {
setEditing(null);
setNotice(`${name} updated. Your earlier history is kept.`);
void refresh();
}}
/>
)}
{deleting && (
<DeleteHabit
habit={deleting}
onClose={() => 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();
}}
/>
)}
</div>
);
}

View File

@@ -1,105 +0,0 @@
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 (
<div className="ds-root landing-root" id="top">
<a className="ds-skip-link" href="#landing-main">Skip to content</a>
<header className="ds-header landing-header">
<Link className="ds-wordmark" to="/" aria-label="Minabot home">minabot<span aria-hidden="true">.</span></Link>
<nav aria-label="Main navigation">
<a href="#demo">Try it out</a>
<a href="#how-it-works">How it works</a>
<a href="#discord">Discord</a>
</nav>
<Button variant="secondary" onClick={signIn}>Sign in <span aria-hidden="true"></span></Button>
</header>
<main className="ds-main" id="landing-main">
{error && <p className="landing-auth-error" role="alert">{error}</p>}
<section className="ds-intro landing-hero" aria-labelledby="landing-title">
<div>
<p className="ds-eyebrow">YOUR HABITS. YOUR OWN PACE.</p>
<h1 id="landing-title">A little today.<br /><em>A rhythm for life.</em></h1>
<p className="ds-intro-copy">A quiet place to track your habits, notice your progress,<br className="landing-desktop-break" /> and keep showing up for the things that matter.</p>
<div className="landing-hero-actions">
<Button onClick={signIn}>Sign in with Discord <span aria-hidden="true"></span></Button>
<a className="landing-text-link" href="#demo">Try the demo <span aria-hidden="true"></span></a>
</div>
<p className="landing-signin-note">Your Discord account. One less password.</p>
</div>
<aside className="ds-intro-note landing-hero-note" aria-label="Our approach">
<span className="ds-tiny-cross" aria-hidden="true">+</span>
<p>Not a perfect streak.<br />Not another competition.<br />Just a little more intention.</p>
<a href="#how-it-works">Find your rhythm <span aria-hidden="true"></span></a>
</aside>
</section>
<LandingDemo />
<section className="ds-section landing-how" id="how-it-works" aria-labelledby="how-title">
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">02 / HOW IT WORKS</span>
<h2 id="how-title">A habit, <em>not a project.</em></h2>
</div>
</div>
<ol className="landing-steps">
<li>
<span className="ds-eyebrow" aria-hidden="true">01 MAKE IT YOURS</span>
<h3>Start with something small.</h3>
<p>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.</p>
</li>
<li>
<span className="ds-eyebrow" aria-hidden="true">02 SHOW UP</span>
<h3>Log a little along the way.</h3>
<p>Check it off, add to your count, or finish a task. Each habit keeps its own progress, and daily boundaries follow your saved timezone.</p>
</li>
<li>
<span className="ds-eyebrow" aria-hidden="true">03 SEE YOUR RHYTHM</span>
<h3>Let the days tell the story.</h3>
<p>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 isnt a missed day.</p>
</li>
</ol>
<p className="ds-footnote">Explore the demo above, then sign in to create your own habits and keep your progress in one place.</p>
</section>
<section className="ds-section landing-discord" id="discord" aria-labelledby="discord-title">
<div>
<span className="ds-eyebrow">03 / CONNECTED WITH DISCORD</span>
<h2 id="discord-title">One familiar account.<br /><em>A little space for you.</em></h2>
</div>
<div className="landing-discord-copy">
<p>Start with the Discord account you already use. Minabot connects your Discord profile to your own account here, without another password to remember.</p>
<dl className="landing-discord-details">
<div><dt>Your profile, connected.</dt><dd>Sign-in uses your Discord identity, not access to your messages or email.</dd></div>
<div><dt>No server setup.</dt><dd>You dont need to add a bot to a server to sign in. Habit tracking lives here on the web.</dd></div>
</dl>
<p className="ds-footnote">Discord integration currently covers sign-in. Bot commands and reminders arent available yet.</p>
</div>
</section>
<section className="ds-section landing-closing" aria-labelledby="closing-title">
<div>
<p className="ds-eyebrow">NO PERFECT START REQUIRED</p>
<h2 id="closing-title">Begin with <em>a little.</em></h2>
</div>
<div>
<Button onClick={signIn}>Sign in with Discord <span aria-hidden="true"></span></Button>
<p className="landing-signin-note">Or explore the demo. No account needed.</p>
</div>
</section>
<footer className="ds-footer landing-footer">
<Link className="ds-wordmark" to="/">minabot.</Link>
<span>A little, every day.</span>
<a href="#top">Back to top </a>
</footer>
</main>
</div>
);
}

View File

@@ -1,3 +0,0 @@
export function Settings() {
return <h1>Settings</h1>;
}

View File

@@ -24,8 +24,8 @@
min-height: 100vh;
color: var(--ds-ink);
background: var(--ds-paper);
font:
14px/1.5 -apple-system,
@apply type-body;
font-family: -apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
@@ -39,15 +39,11 @@
.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 {
.ds-root :where(h1) { @apply type-display; }
.ds-root :where(h2) { @apply type-section; }
.ds-root :where(h3, h4) { @apply type-title; }
.ds-root :where(small) { @apply type-small; }
.ds-root :where(a) {
color: inherit;
text-decoration: none;
}
@@ -55,8 +51,7 @@
text-decoration: underline;
text-underline-offset: 5px;
}
.ds-root button,
.ds-root input {
.ds-root :where(button, input, select, textarea) {
font: inherit;
border-radius: 0;
}
@@ -80,16 +75,15 @@
color: var(--ds-secondary);
}
.ds-code {
font:
11px/1.6 ui-monospace,
@apply type-small;
font-family: ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.ds-eyebrow {
font-size: 10px;
letter-spacing: 0.13em;
font-weight: 500;
@apply type-eyebrow;
color: #737373;
}
.ds-header {
max-width: 1280px;
@@ -103,18 +97,17 @@
}
.ds-wordmark {
font-family: var(--ds-serif);
font-size: 35px;
line-height: 1;
@apply type-title;
letter-spacing: -1.2px;
}
.ds-header nav {
display: flex;
gap: 30px;
font-size: 12px;
@apply type-small;
}
.ds-edition {
color: var(--ds-secondary);
font-size: 9px;
@apply type-small;
letter-spacing: 0.12em;
}
.ds-main {
@@ -122,6 +115,66 @@
padding: 0 48px;
margin: auto;
}
.ds-library-label,
.ds-library-note {
@apply type-small;
color: var(--ds-secondary);
}
.ds-library-intro {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 36px 0 28px;
}
.ds-library-intro h1 {
@apply type-section;
font-family: var(--ds-serif);
font-weight: 400;
margin: 10px 0;
}
.ds-library-intro p:not(.ds-eyebrow) { color: var(--ds-secondary); }
.ds-library-note { max-width: 180px; text-align: right; }
.ds-tab-bar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 4px;
padding: 6px;
background: #f5f5f5;
}
.ds-tab-bar [role="tab"] {
@apply type-body;
background: transparent;
color: var(--ds-secondary);
border: 1px solid transparent;
padding: 10px 8px;
}
.ds-tab-bar [role="tab"]:hover { background: #ebebeb; color: var(--ds-ink); }
.ds-tab-bar [aria-selected="true"] {
background: var(--ds-paper);
color: var(--ds-ink);
border-color: var(--ds-rule);
}
.ds-tab-bar [role="tab"]:focus-visible { outline-offset: -3px; }
.ds-tab-panel { min-width: 0; min-height: 420px; }
.ds-tab-panel[hidden] { display: none; }
.ds-tab-panel > .ds-section { border-top: 0; }
@media (max-width: 1000px) {
.ds-tab-bar { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.ds-library-intro { display: block; padding: 28px 0 24px; }
.ds-library-note { display: block; max-width: none; text-align: left; margin-top: 16px; }
.ds-tab-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.ds-header nav { flex-wrap: wrap; }
.ds-header nav [aria-current="page"] { text-decoration: underline; text-underline-offset: 5px; }
.ds-account-controls { padding: 24px 0; border-top: 1px solid var(--ds-rule); }
.ds-account-controls p { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.ds-account-controls [role="alert"] { margin-top: 16px; }
.ds-type-scale { list-style: none; padding: 0; margin: 24px 0; }
.ds-type-scale li { display: grid; gap: 8px; padding: 20px 0; border-top: 1px solid var(--ds-rule); }
.ds-type-scale code { @apply type-small; color: var(--ds-secondary); overflow-wrap: anywhere; }
.ds-intro {
display: flex;
justify-content: space-between;
@@ -130,31 +183,30 @@
padding: 85px 0 72px;
}
.ds-intro h1 {
font-size: clamp(64px, 6.6vw, 90px);
letter-spacing: -2.4px;
@apply type-display;
margin: 23px 0 26px;
}
.ds-intro h1 em {
font-weight: 400;
}
.ds-intro-copy {
@apply type-body;
color: var(--ds-secondary);
line-height: 1.7;
}
.ds-intro-note {
width: 220px;
padding-bottom: 4px;
font-size: 12px;
@apply type-small;
}
.ds-tiny-cross {
display: block;
font-size: 27px;
@apply type-title;
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 {
@@ -166,7 +218,7 @@
}
.ds-section {
padding: 42px 0 56px;
border-top: 1px solid var(--ds-ink);
border-top: 1px solid var(--ds-rule);
}
.ds-section-top {
display: flex;
@@ -176,27 +228,25 @@
margin-bottom: 32px;
}
.ds-section-top h2 {
font-size: 42px;
@apply type-section;
margin-top: 15px;
letter-spacing: -0.7px;
}
.ds-section-top > p {
font-size: 12px;
@apply type-small;
}
.ds-demo-note {
color: var(--ds-secondary);
font-size: 11px;
white-space: nowrap;
@apply type-small;
white-space: normal;
}
.ds-demo-note > span {
font-size: 20px;
@apply type-lead;
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;
@@ -208,15 +258,12 @@
border: 0;
border-bottom: 2px solid transparent;
padding: 13px 0;
font-size: 12px;
@apply type-small;
}
.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;
@@ -224,24 +271,22 @@
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;
@apply type-title;
margin-top: 8px;
}
.ds-preview-heading > p {
color: var(--ds-secondary);
font-size: 12px;
@apply type-small;
padding-bottom: 4px;
}
.ds-habit-row {
border-top: 1px solid var(--ds-rule);
padding: 19px 0;
}
.ds-habit-row + .ds-habit-row {
border-top: 1px solid var(--ds-rule);
}
.ds-habit-main {
display: flex;
align-items: center;
@@ -250,11 +295,11 @@
}
.ds-habit-main h4 {
font-family: var(--ds-serif);
font-size: 27px;
@apply type-title;
}
.ds-habit-main p {
color: var(--ds-secondary);
font-size: 11px;
@apply type-small;
margin-top: 3px;
}
.ds-habit-control {
@@ -265,24 +310,25 @@
.ds-status {
color: var(--ds-secondary);
min-width: 67px;
font-size: 10px;
@apply type-small;
text-align: right;
}
.ds-button {
min-height: 42px;
min-height: 44px;
border: 1px solid var(--ds-ink);
padding: 10px 19px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 22px;
font-size: 12px !important;
gap: 12px;
@apply type-body;
background: var(--ds-ink);
color: var(--ds-paper);
}
.ds-button:hover:not(:disabled) {
background: #333;
}
.ds-button-icon { width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
.ds-button--secondary {
background: transparent;
color: var(--ds-ink);
@@ -306,7 +352,7 @@
.ds-counter .ds-button {
width: 36px;
min-height: 40px;
font-size: 23px !important;
@apply type-control;
font-weight: 300;
padding: 0;
}
@@ -321,7 +367,7 @@
gap: 11px;
min-height: 36px;
cursor: pointer;
font-size: 12px;
@apply type-small;
}
.ds-checkbox input {
appearance: none;
@@ -373,8 +419,8 @@
font-variant-numeric: tabular-nums;
}
.ds-footnote {
font-size: 11px;
line-height: 1.7;
@apply type-small;
margin-top: 18px !important;
}
.ds-habits > .ds-footnote {
@@ -382,6 +428,58 @@
padding-top: 17px;
margin-top: 0 !important;
}
/* Flat surfaces group content; grid spans are only applied by a bento parent. */
.ds-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.ds-card-grid--bento {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.ds-card {
min-width: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 24px;
padding: 28px;
border: 1px solid transparent;
border-radius: 0;
box-shadow: none;
overflow-wrap: anywhere;
}
.ds-card--soft { background: #f5f5f5; }
.ds-card--outlined {
background: var(--ds-paper);
border-color: var(--ds-rule);
}
.ds-card-header h3 { @apply type-title; }
.ds-card-header .ds-eyebrow { margin-bottom: 12px; }
.ds-card-body { display: grid; gap: 20px; }
.ds-card-body > p { color: var(--ds-secondary); }
.ds-card-footer { margin-top: auto; }
.ds-card-summary { padding: 12px 0; }
.ds-card-summary > p + p { color: var(--ds-secondary); margin-top: 8px; }
.ds-card .ds-counter { justify-content: flex-start; flex-wrap: wrap; }
.ds-card-grid--bento > :is(.ds-card--wide, .ds-card--featured) { grid-column: span 2; }
.ds-card-grid--bento > .ds-card--featured { grid-row: span 2; }
.ds-container-showcase > .ds-section-heading { max-width: 720px; }
.ds-container-guidance { display: flex; align-items: start; gap: 32px; margin-top: 24px; }
.ds-container-guidance > p { flex: 1; margin-top: 0 !important; }
.ds-container-guidance > button { flex-shrink: 0; }
.ds-container-code { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 24px; }
@media (max-width: 1000px) {
.ds-card-grid--bento { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ds-card-grid--bento > .ds-card--featured { grid-row: auto; }
}
@media (max-width: 600px) {
.ds-card-grid { grid-template-columns: minmax(0, 1fr); }
.ds-card-grid--bento > :is(.ds-card--wide, .ds-card--featured) { grid-column: auto; }
.ds-card { padding: 24px; }
.ds-container-guidance { flex-direction: column; gap: 12px; }
.ds-container-showcase .ds-spec-heading { align-items: start; flex-direction: column; gap: 8px; }
}
.ds-split-section {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
@@ -389,14 +487,13 @@
padding-top: 40px;
}
.ds-section-heading h2 {
font-size: 37px;
@apply type-section;
margin-top: 17px;
letter-spacing: -0.5px;
}
.ds-section-heading p {
color: var(--ds-secondary);
font-size: 12px;
line-height: 1.8;
@apply type-small;
margin-top: 15px;
}
.ds-spec-heading {
@@ -408,7 +505,7 @@
}
.ds-spec-heading h3,
.ds-spec-heading label {
font-size: 13px;
@apply type-small;
}
.ds-spec-heading--spaced {
margin-top: 36px;
@@ -416,13 +513,11 @@
.ds-type-display {
display: block;
font-family: var(--ds-serif);
font-size: 55px;
line-height: 1.1;
letter-spacing: -0.6px;
@apply type-display;
}
.ds-type-specimen p,
.ds-body-specimen p {
font-size: 10px;
@apply type-small;
color: var(--ds-secondary);
margin-top: 16px;
}
@@ -431,7 +526,7 @@
padding: 26px 0;
}
.ds-body-specimen > span {
font-size: 16px;
@apply type-body;
}
.ds-body-specimen p {
margin-top: 6px;
@@ -448,12 +543,12 @@
border: 1px solid var(--ds-rule);
}
.ds-palette p {
font-size: 11px;
@apply type-small;
margin-top: 10px;
}
.ds-palette .ds-code {
display: block;
font-size: 9px;
@apply type-small;
margin-top: 3px;
}
.ds-spacing {
@@ -470,7 +565,7 @@
background: var(--ds-ink);
}
.ds-spacing code {
font-size: 10px;
@apply type-small;
color: var(--ds-secondary);
}
.ds-actions {
@@ -489,7 +584,7 @@
gap: 20px;
}
.ds-component-row h3 {
font-size: 14px;
@apply type-small;
margin-bottom: 3px;
}
.ds-example-form {
@@ -528,12 +623,12 @@
}
.ds-shade-scale small {
color: var(--ds-secondary);
font-size: 10px;
@apply type-small;
}
.ds-scale-caption {
display: flex;
justify-content: space-between;
font-size: 10px;
@apply type-small;
color: var(--ds-secondary);
margin-top: 16px;
}
@@ -550,10 +645,10 @@
gap: 13px;
}
.ds-neutral-states h4 {
font-size: 12px;
@apply type-small;
}
.ds-neutral-states p {
font-size: 10px;
@apply type-small;
color: var(--ds-secondary);
margin-top: 3px;
}
@@ -582,7 +677,7 @@
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 11px;
@apply type-small;
border-top: 1px solid var(--ds-rule);
padding-top: 15px;
margin-top: 20px;
@@ -596,16 +691,16 @@
gap: 20px;
}
.ds-footer .ds-wordmark {
font-size: 26px;
@apply type-control;
}
.ds-footer > span:nth-child(2) {
font-family: var(--ds-serif);
font-style: italic;
font-size: 20px;
@apply type-lead;
color: var(--ds-secondary);
}
.ds-footer a {
font-size: 11px;
@apply type-small;
}
.ds-detail-count {
display: flex;
@@ -616,11 +711,10 @@
}
.ds-big-value {
font-family: var(--ds-serif);
font-size: 64px;
line-height: 1;
@apply type-display;
}
.ds-big-value small {
font-size: 24px;
@apply type-control;
margin-left: 20px;
}
.ds-chart-selection {
@@ -643,7 +737,7 @@
margin-bottom: 18px;
}
.ds-calendar-range-label {
font-size: 10px;
@apply type-small;
color: var(--ds-secondary);
}
.ds-month-views {
@@ -658,7 +752,7 @@
background: transparent;
border: 0;
border-bottom: 1px solid transparent;
font-size: 11px;
@apply type-small;
}
.ds-month-views button:hover {
color: var(--ds-ink);
@@ -675,7 +769,7 @@
display: grid;
grid-template-columns: repeat(26, 1fr);
margin-left: 38px;
font-size: 10px;
@apply type-small;
color: var(--ds-secondary);
height: 28px;
}
@@ -686,9 +780,9 @@
.ds-calendar-weekdays {
display: grid;
grid-template-rows: repeat(7, 1fr);
width: 28px;
width: 36px;
flex-shrink: 0;
font-size: 9px;
@apply type-small;
color: var(--ds-secondary);
align-items: center;
}
@@ -750,7 +844,7 @@
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 10px;
@apply type-small;
margin: 17px 0 23px;
}
.ds-calendar-legend {
@@ -761,7 +855,7 @@
gap: 12px 20px;
margin-top: 18px;
color: var(--ds-secondary);
font-size: 10px;
@apply type-small;
}
.ds-legend-scale,
.ds-legend-neutral,
@@ -789,7 +883,7 @@
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 12px;
@apply type-small;
}
.ds-skip-link {
position: absolute;
@@ -814,9 +908,6 @@
grid-template-columns: 210px minmax(0, 1fr);
gap: 30px;
}
.ds-section-heading h2 {
font-size: 32px;
}
.ds-neutral-states {
flex-direction: column;
gap: 20px;
@@ -840,12 +931,8 @@
padding: 23px 22px;
gap: 18px;
}
.ds-wordmark {
font-size: 30px;
}
.ds-header nav {
gap: 16px;
font-size: 10px;
}
.ds-main {
padding-inline: 22px;
@@ -853,13 +940,6 @@
.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;
}
@@ -872,27 +952,17 @@
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 {
@@ -901,23 +971,11 @@
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;
}
@@ -933,24 +991,15 @@
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;
@@ -966,11 +1015,7 @@
flex-direction: column;
gap: 8px;
}
.ds-big-value {
font-size: 53px;
}
.ds-big-value small {
font-size: 20px;
margin-left: 10px;
}
.ds-actions {
@@ -982,9 +1027,6 @@
.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) {
@@ -996,7 +1038,7 @@
/* Habit charts share an open two-column grid, not card containers. */
.ds-habit-chart-grid {
font-size: 14px;
@apply type-small;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 38px 44px;
@@ -1004,9 +1046,12 @@
}
.ds-habit-chart {
min-width: 0;
border-top: 1px solid var(--ds-rule);
padding-top: 20px;
}
.ds-habit-chart:nth-child(n + 3),
.ds-habit-chart:has(.ds-calendar[data-months="12"]) + .ds-habit-chart {
border-top: 1px solid var(--ds-rule);
}
.ds-habit-chart:has(.ds-calendar[data-months="12"]) {
grid-column: 1 / -1;
}
@@ -1022,7 +1067,7 @@
.ds-habit-chart-heading h4 {
overflow-wrap: anywhere;
font-family: var(--ds-serif);
font-size: 29px;
@apply type-title;
display: flex;
align-items: center;
gap: 10px;
@@ -1034,7 +1079,7 @@
}
.ds-habit-chart-heading p {
color: var(--ds-secondary);
font-size: 10px;
@apply type-small;
margin-top: 4px;
}
.ds-habit-chart-heading .ds-counter {
@@ -1047,7 +1092,7 @@
display: flex;
justify-content: space-between;
color: var(--ds-secondary);
font-size: 11px;
@apply type-small;
margin: 23px 0 20px !important;
}
.ds-habit-chart-grid-note {
@@ -1063,12 +1108,12 @@
.ds-calendar--compact .ds-date-inspector {
flex-direction: column;
gap: 5px;
font-size: 10px;
@apply type-small;
border-bottom: 0;
padding-bottom: 0;
}
.ds-calendar--compact .ds-calendar-caption {
font-size: 9px;
@apply type-small;
margin-bottom: 15px;
}
.ds-task-accordion {
@@ -1083,7 +1128,7 @@
gap: 16px;
align-items: center;
min-height: 46px;
font-size: 12px;
@apply type-small;
}
.ds-task-accordion summary::-webkit-details-marker {
display: none;
@@ -1091,11 +1136,11 @@
.ds-task-accordion summary > span {
margin-left: auto;
color: var(--ds-secondary);
font-size: 11px;
@apply type-small;
}
.ds-task-accordion summary::after {
content: "+";
font-size: 19px;
@apply type-lead;
width: 16px;
text-align: center;
}
@@ -1115,7 +1160,7 @@
display: flex;
align-items: center;
gap: 9px;
font-size: 11px;
@apply type-small;
text-transform: capitalize;
}
.ds-habit-color-key i {
@@ -1123,6 +1168,9 @@
height: 10px;
}
@media (max-width: 760px) {
.ds-habit-chart + .ds-habit-chart {
border-top: 1px solid var(--ds-rule);
}
.ds-habit-chart-grid {
grid-template-columns: minmax(0, 1fr);
gap: 32px;
@@ -1145,58 +1193,23 @@
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 legend { padding: 0 12px 0 0; @apply type-small; }
.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-palette > span { @apply type-small; color: var(--ds-secondary); }
.ds-color-swatches { display: flex; gap: 4px; margin-top: 8px; }
.ds-color-swatches button { width: 38px; min-height: 44px; padding: 4px; border: 1px solid transparent; background: transparent; }
.ds-color-swatches button[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-color-swatches button > span { display: grid; place-content: center; height: 28px; width: 28px; color: white; @apply type-body; }
.ds-custom-color { display: flex; gap: 16px; align-items: start; }
.ds-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-live-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 22px; @apply type-small; }
.ds-color-preview-shades { display: flex; gap: 4px; }
.ds-color-preview-shades > span { width: 18px; height: 18px; border: 1px solid #dedede; }
.ds-edit-layout {
@@ -1208,16 +1221,15 @@
.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;
@apply type-title;
margin: 16px 0;
}
.ds-edit-context > p { color: var(--ds-secondary); line-height: 1.8; }
.ds-edit-context > p { color: var(--ds-secondary); }
.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 label, .ds-weekday-field legend { @apply type-small; }
.ds-field :is(input, select) {
width: 100%;
min-width: 0;
@@ -1225,21 +1237,22 @@
border: 1px solid #999;
padding: 10px 12px;
font: inherit;
@apply type-body;
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-field small { @apply type-small; color: var(--ds-secondary); }
.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-edit-content .ds-checkbox { @apply type-small; }
.ds-weekday-field, .ds-task-editor-fieldset { border: 0; padding: 0; margin: 0 0 20px; min-width: 0; }
.ds-weekdays { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.ds-weekdays button { min-height: 44px; min-width: 44px; padding: 8px; border: 1px solid var(--ds-rule); background: white; color: var(--ds-secondary); }
.ds-weekdays button[aria-pressed="true"] { color: white; background: var(--ds-ink); border-color: var(--ds-ink); }
.ds-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 { min-height: 24px; margin: 20px 0 12px !important; color: var(--ds-secondary); @apply type-small; overflow-wrap: anywhere; }
.ds-form-feedback[role="alert"] { border-left: 2px solid var(--ds-ink); padding-left: 12px; color: var(--ds-ink); }
.ds-save-notice { margin-bottom: 0 !important; }
.ds-edit-task { border-top: 1px solid var(--ds-rule); padding: 16px 0 10px; }
@@ -1254,7 +1267,7 @@
.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 li { display: grid; grid-template-columns: 130px minmax(0, 1fr) auto; gap: 16px; border-bottom: 1px solid var(--ds-rule); padding: 16px 0; @apply type-small; 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; }
@@ -1263,22 +1276,12 @@
}
@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; }
}

View File

@@ -3,6 +3,8 @@
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@import "tw-animate-css";
@import "./typography.css";
@import "./design-system.css" layer(components);
@custom-variant dark (&:is(.dark *));

View File

@@ -1,603 +0,0 @@
.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;
}
}

View File

@@ -1,64 +0,0 @@
.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; }
}

55
styles/typography.css Normal file
View File

@@ -0,0 +1,55 @@
/* Fixed rem steps respect browser text-size preferences. No viewport-based type.
Use these utilities directly in JSX or @apply them in shared components. */
@theme {
--text-copy: 1rem;
--text-copy--line-height: 1.5;
--text-caption: 0.875rem;
--text-caption--line-height: 1.5;
--text-lead: 1.25rem;
--text-lead--line-height: 1.5;
--text-title: 2rem;
--text-title--line-height: 1.25;
--text-section: 3rem;
--text-section--line-height: 1.125;
--text-display: 4.5rem;
--text-display--line-height: 1.125;
}
@utility type-body { @apply text-copy; }
@utility type-small { @apply text-caption; }
@utility type-label {
@apply text-caption;
font-weight: 500;
letter-spacing: 0.08em;
}
@utility type-eyebrow {
font-size: 0.75rem;
line-height: 1.5;
font-weight: 400;
letter-spacing: 0.08em;
}
@utility type-lead { @apply text-lead; }
@utility type-title {
@apply text-title;
font-family: var(--ds-serif);
font-weight: 400;
letter-spacing: -0.02em;
}
@utility type-section {
@apply text-section;
font-family: var(--ds-serif);
font-weight: 400;
letter-spacing: -0.02em;
@media (max-width: 40rem) { font-size: 2.25rem; line-height: 1.25; }
}
@utility type-display {
@apply text-display;
font-family: var(--ds-serif);
font-weight: 400;
letter-spacing: -0.025em;
@media (max-width: 40rem) { font-size: 3rem; line-height: 1.125; }
}
@utility type-control {
font-size: 1.5rem;
line-height: 1.25;
}

38
styles/typography.test.ts Normal file
View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const css = (name: string) => readFileSync(new URL(name, import.meta.url), "utf8");
describe("shared typography contract", () => {
test("all component and page styles use the fixed shared type utilities", () => {
for (const file of ["design-system.css"]) {
const source = css(file);
expect(source).not.toMatch(/font-size\s*:/);
expect(source).not.toMatch(/font:\s*\d/);
expect(source).toContain("@apply type-");
}
const typography = css("typography.css");
expect(typography).not.toMatch(/clamp\(|\d(?:vw|vh|px)\b/);
expect(typography).toContain("--text-copy: 1rem");
expect(typography).toContain("--text-caption: 0.875rem");
for (const role of ["body", "small", "label", "lead", "title", "section", "display", "control"]) {
expect(typography).toContain(`@utility type-${role}`);
}
});
test("text colors meet WCAG AA normal-text contrast on paper", () => {
const source = css("design-system.css");
const luminance = (token: string) => {
let hex = source.match(new RegExp(`--ds-${token}: #([\\da-f]+);`))![1]!;
if (hex.length === 3) hex = [...hex].map((c) => c + c).join("");
const channels = [0, 2, 4].map((start) => {
const value = parseInt(hex.slice(start, start + 2), 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
};
for (const color of ["ink", "secondary"]) {
expect((luminance("paper") + 0.05) / (luminance(color) + 0.05)).toBeGreaterThanOrEqual(4.5);
}
});
});