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:
56
README.md
56
README.md
@@ -1,7 +1,7 @@
|
||||
# Minabot
|
||||
|
||||
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The existing
|
||||
React Router client has a public landing page and a connected signed-in habit dashboard.
|
||||
React Router client currently contains only the design system, ready for a fresh page redesign.
|
||||
|
||||
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
|
||||
combined calendars, historical corrections, daily resets, and optional count carryover.
|
||||
@@ -13,12 +13,17 @@ bun dev
|
||||
|
||||
Open http://127.0.0.1:3000. Set `PORT` to use a different port.
|
||||
|
||||
- Client pages: `/`, `/about`, `/settings`.
|
||||
- Client page: `/design-system`. Root and all other client URLs redirect there.
|
||||
- Hono endpoint: `GET /api/health`.
|
||||
- Unknown API routes return JSON with a 404 status.
|
||||
|
||||
### Design system preview
|
||||
|
||||
The library uses seven tabs: Foundations, Components, Layout, Calendars, Playground,
|
||||
Editing, and References. Foundations is the default. Each tab has a URL hash;
|
||||
existing section links still work. Arrow keys, Home, and End navigate the tabs.
|
||||
Switching tabs preserves unsaved demo state; browser back/forward restores the section.
|
||||
|
||||
Open `/design-system#editing` for interactive habit settings, task recurrence,
|
||||
and dated progress corrections. Habit colors include Earth, Coast, Dusk, Forest,
|
||||
Citrus, Blossom, Jewel, and Slate palettes plus a native picker and hex input. Editors support save/cancel,
|
||||
@@ -30,39 +35,24 @@ clears the preview. Habit colors preview immediately across the editor and the
|
||||
daily/detail charts; Save keeps the color in the demo and Cancel restores it.
|
||||
Other settings and progress remain independent of the daily tracking playground.
|
||||
|
||||
Signed-out visitors at `/` see the landing page, with interactive habit calendars
|
||||
reused from the design system, a how-it-works introduction, and Discord sign-in.
|
||||
The demo is local, unsaved state and never writes to the habit API. Account loading
|
||||
and retryable errors are resolved before choosing the public or signed-in home.
|
||||
Signing out updates the shared account state and reveals the landing page immediately.
|
||||
Discord integration currently covers identity only, not bot commands or reminders.
|
||||
The previous Landing, Home, About, and Settings pages have been removed. The
|
||||
backend API, authentication infrastructure, database, and reusable components remain
|
||||
available for the redesigned pages. The design system does not load account data
|
||||
or call the API.
|
||||
|
||||
Signed-in users at `/` get a personalized home with their Discord avatar, tracking
|
||||
date/timezone, daily completion summary, manual/count/task check-ins, a remaining
|
||||
filter, and per-habit calendar history in the design system's two-column chart grid.
|
||||
Each habit has its own heading, schedule, color key, and compact calendar. Daily
|
||||
check-ins use compact rows with expandable tasks. New accounts get editable starter ideas and
|
||||
a create-habit dialog supporting all three methods, all schedule types, and the shared
|
||||
palette/custom color picker. The habit and its color are saved atomically. Progress
|
||||
is saved through the habit API; history uses server-provided progress colors and dated units,
|
||||
never the landing page's demo data. Visible dashboards refresh every minute and on
|
||||
returning to the tab. Loading, retry, expired-session, no-schedule, and all-complete
|
||||
states are handled separately. Each habit has Edit and Delete controls: edit its name,
|
||||
count target/unit, schedule, and color, or confirm removal from the dashboard using
|
||||
the existing archive API, which retains recorded history. Editing keeps the tracking
|
||||
method and existing tasks intact. Unscheduled dates use neutral dots; upcoming dates
|
||||
share the zero-progress fill while retaining their distinct inspection labels.
|
||||
Archive management, task-definition editing, historical corrections, and saved
|
||||
combined-chart management remain outside this home view.
|
||||
`styles/globals.css` is the only stylesheet
|
||||
entry point; component styles are in Tailwind's components layer so utilities can
|
||||
override them predictably. Tailwind Preflight remains omitted.
|
||||
|
||||
For isolated browser QA, run `bun scripts/dashboard-preview.ts` and open the printed
|
||||
preview login URL. It serves the real UI and API against an in-memory test database,
|
||||
uses a separate test cookie, and discards its data on exit.
|
||||
It is a local test harness, not a production sign-in flow.
|
||||
|
||||
Secondary pages retain a heading, native navigation buttons, and account controls. Tailwind utilities
|
||||
and shadcn configuration are available; Tailwind Preflight is omitted to preserve
|
||||
native browser styling.
|
||||
`styles/typography.css` defines reusable Tailwind classes: `type-display` (72/81),
|
||||
`type-section` (48/54), `type-title` (32/40), `type-lead` (20/30), `type-body`
|
||||
(16/24), `type-small` and `type-label` (14/21), and `type-control` (24/30, symbols).
|
||||
Values are font-size/line-height in pixels at the default 16px root, implemented
|
||||
in rem. At 640px and below, display steps to 48/54 and section to 36/45; body and
|
||||
supporting text never shrink. Use these classes directly or with `@apply`, not
|
||||
bespoke font sizes or viewport-based type. See `/design-system#foundations` for
|
||||
live specimens. Reuse `SectionHeading`, `Button`, `ButtonLink`, and the shared
|
||||
`DiscordSignInButton` for headings and calls to action.
|
||||
|
||||
```sh
|
||||
bun run typecheck
|
||||
|
||||
654
src/App.test.tsx
654
src/App.test.tsx
@@ -10,34 +10,18 @@ import {
|
||||
} from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { fixture } from "./habits/test-fixture";
|
||||
import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap";
|
||||
|
||||
// A simulated DOM keeps auth regression checks independent of Discord and the local database.
|
||||
// Keep the design system independent of authentication and the local database.
|
||||
const dom = new Window({ url: "http://localhost:3000/" });
|
||||
const originalGlobals = new Map<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 User’s Discord avatar");
|
||||
await act(async () => avatar.dispatchEvent(new Event("error")));
|
||||
expect(container.querySelector("img.home-avatar")).toBeNull();
|
||||
expect(container.querySelector(".home-avatar")?.textContent).toBe("D");
|
||||
});
|
||||
|
||||
test("a starter is editable and creates a persistent first habit", async () => {
|
||||
await render();
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<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("You’re all caught up.");
|
||||
await api.json(`/habits/${habit.id}`, "PATCH", {
|
||||
schedule: { type: "weekdays", days: [0] },
|
||||
});
|
||||
await act(async () => window.dispatchEvent(new Event("focus")));
|
||||
expect(container.textContent).toContain("Nothing is scheduled today");
|
||||
expect(container.textContent).not.toContain("No habits yet");
|
||||
});
|
||||
|
||||
test("failed logging preserves progress and offers a refresh", async () => {
|
||||
await api.json(
|
||||
"/habits",
|
||||
"POST",
|
||||
{ name: "Water", method: "count", target: 8, unit: "glasses" },
|
||||
201,
|
||||
);
|
||||
await render();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
Response.json({ error: "Could not save progress" }, { status: 503 }),
|
||||
);
|
||||
await click("Increase Water (glasses)");
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"Could not save progress",
|
||||
);
|
||||
expect((await api.json("/today")).habits[0].value).toBe(0);
|
||||
await click("Try again");
|
||||
await click("Increase Water (glasses)");
|
||||
expect((await api.json("/today")).habits[0].value).toBe(1);
|
||||
});
|
||||
|
||||
test("a failed initial load is not shown as an empty account", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(Response.json(account))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 }));
|
||||
await render();
|
||||
expect(container.querySelector('[role="alert"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("No habits yet");
|
||||
await click("Try again");
|
||||
expect(container.textContent).toContain("No habits yet");
|
||||
test("design system playground remains interactive without API requests", async () => {
|
||||
await render("/design-system#playground");
|
||||
const tabs = Array.from(container.querySelectorAll<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");
|
||||
});
|
||||
});
|
||||
|
||||
56
src/App.tsx
56
src/App.tsx
@@ -1,57 +1,11 @@
|
||||
import { Route, Routes, useLocation, useNavigate } from "react-router";
|
||||
import { AuthControls } from "./components/AuthControls";
|
||||
import { Home } from "./pages/Home";
|
||||
import { About } from "./pages/About";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
import { DesignSystem } from "./pages/DesignSystem";
|
||||
import { AuthProvider, useAuth } from "./components/AuthProvider";
|
||||
import { Landing } from "./pages/Landing";
|
||||
import { Button } from "./components/design-system/primitives";
|
||||
|
||||
export function App() {
|
||||
const { pathname } = useLocation();
|
||||
if (pathname === "/design-system" || pathname === "/design-system/") return <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/components/DiscordSignInButton.tsx
Normal file
13
src/components/DiscordSignInButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
44
src/components/design-system/Card.tsx
Normal file
44
src/components/design-system/Card.tsx
Normal 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} />;
|
||||
}
|
||||
64
src/components/design-system/ContainerShowcase.tsx
Normal file
64
src/components/design-system/ContainerShowcase.tsx
Normal 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="There’s 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>
|
||||
);
|
||||
}
|
||||
63
src/components/design-system/DesignSystemTabs.tsx
Normal file
63
src/components/design-system/DesignSystemTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function About() {
|
||||
return <h1>About</h1>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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. Let’s 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" : "TODAY’S 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 it’s time."
|
||||
: remaining === 0
|
||||
? "Everything scheduled for today is complete. No extra credit needed."
|
||||
: "Check in as you go. Every bit of progress belongs here."}
|
||||
</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="Today’s 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."
|
||||
: "You’re all caught up."}
|
||||
</h3>
|
||||
<p>
|
||||
{today.due === 0
|
||||
? "No habits are scheduled for today. A day off isn’t a missed day."
|
||||
: "All of today’s habits are complete. Take a moment for yourself."}
|
||||
</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 aren’t included in today’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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 isn’t 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 don’t 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 aren’t 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function Settings() {
|
||||
return <h1>Settings</h1>;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 *));
|
||||
|
||||
|
||||
603
styles/home.css
603
styles/home.css
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
55
styles/typography.css
Normal 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
38
styles/typography.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user