feat: restructure styles and components for improved design system

- Added typography and design system styles to globals.css.
- Removed deprecated home.css and landing.css files.
- Introduced DiscordSignInButton component for Discord authentication.
- Created Card and CardGrid components for structured content display.
- Implemented ContainerShowcase to demonstrate card usage and layout.
- Added DesignSystemTabs for navigation between design system sections.
- Established typography.css for consistent text styling across components.
- Added tests for typography styles to ensure compliance with design standards.
This commit is contained in:
syntaxbullet
2026-09-04 12:37:56 +02:00
parent 084603bb6e
commit bfa54851c2
23 changed files with 1177 additions and 2937 deletions

View File

@@ -10,34 +10,18 @@ import {
} from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import { MemoryRouter } from "react-router";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import type { Root } from "react-dom/client";
import { App } from "./App";
import { fixture } from "./habits/test-fixture";
import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap";
// A simulated DOM keeps auth regression checks independent of Discord and the local database.
// Keep the design system independent of authentication and the local database.
const dom = new Window({ url: "http://localhost:3000/" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
let fetchMock: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;
const account = {
id: "test-user",
discordId: "123",
username: "demo",
displayName: "Demo User",
avatarUrl: null,
timezone: "UTC",
};
const emptyToday = {
date: "2026-09-04",
timezone: "UTC",
habits: [],
due: 0,
completed: 0,
};
beforeAll(async () => {
for (const key of [
@@ -90,26 +74,28 @@ afterAll(() => {
}
});
function LocationProbe() {
const location = useLocation();
const navigate = useNavigate();
return <>
<output data-testid="pathname">{location.pathname}</output>
<output data-testid="hash">{location.hash}</output>
<button onClick={() => navigate(-1)}>History back</button>
<button onClick={() => navigate(1)}>History forward</button>
</>;
}
async function render(path = "/") {
await act(async () =>
root.render(
<MemoryRouter initialEntries={[path]}>
<App />
<LocationProbe />
</MemoryRouter>,
),
);
}
async function click(label: string) {
const button = Array.from(container.querySelectorAll("button")).find(
(element) =>
element.textContent?.trim() === label ||
element.getAttribute("aria-label") === label,
);
expect(button).toBeDefined();
await act(async () => button!.click());
}
test("unscheduled dates stay inspectable without a progress-square fill", async () => {
await act(async () =>
root.render(
@@ -177,516 +163,114 @@ test("unscheduled dates stay inspectable without a progress-square fill", async
).toBe("true");
});
describe("home route authentication", () => {
test("signed-out visitors see the landing page with Discord and demo content", async () => {
await render();
expect(container.querySelector("h1")?.textContent).toBe(
"A little today.A rhythm for life.",
);
expect(container.querySelector("#discord")?.textContent).toContain(
"Discord integration currently covers sign-in",
);
expect(container.querySelector("#demo")).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("an unresolved account request never flashes the landing page", async () => {
let resolve!: (response: Response) => void;
fetchMock.mockResolvedValue(Response.json(emptyToday)).mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
await render();
expect(container.querySelector('[role="status"]')?.textContent).toBe(
"Loading your account…",
);
expect(container.querySelector("#landing-title")).toBeNull();
await act(async () => resolve(Response.json(account)));
expect(container.querySelector("h1")?.textContent).toBe(
"Welcome home,Demo User.",
);
expect(container.querySelector("#landing-title")).toBeNull();
});
test("signed-in users keep Home and signing out reveals the landing page", async () => {
fetchMock
.mockResolvedValueOnce(Response.json(account))
.mockResolvedValueOnce(Response.json(emptyToday))
.mockResolvedValueOnce(Response.json({ ok: true }));
await render();
expect(container.querySelector("h1")?.textContent).toBe(
"Welcome home,Demo User.",
);
expect(container.textContent).toContain("Demo User");
await click("Sign out");
expect(fetchMock).toHaveBeenLastCalledWith("/api/auth/logout", {
method: "POST",
});
expect(container.querySelector("#landing-title")).not.toBeNull();
});
test("logout failure keeps the signed-in view and shows an error", async () => {
fetchMock
.mockResolvedValueOnce(Response.json(account))
.mockResolvedValueOnce(Response.json(emptyToday))
.mockResolvedValueOnce(new Response(null, { status: 500 }));
await render();
await click("Sign out");
expect(container.querySelector("h1")?.textContent).toBe(
"Welcome home,Demo User.",
);
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Could not sign out",
);
});
test("an account error is not treated as signed out and can be retried", async () => {
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(new Response(null, { status: 401 }));
await render();
expect(container.querySelector("#landing-title")).toBeNull();
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Could not load your account",
);
await click("Try again");
expect(container.querySelector("#landing-title")).not.toBeNull();
});
test("OAuth cancellation remains visible on the landing page", async () => {
await render("/?auth_error=denied");
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Discord sign-in was cancelled",
);
});
test("all sign-in calls to action use Discord OAuth with the browser timezone", async () => {
const assign = spyOn(dom.location, "assign").mockImplementation(() => {});
try {
await render();
const buttons = Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.startsWith("Sign in"),
);
expect(buttons).toHaveLength(3);
for (const button of buttons) await act(async () => button.click());
expect(assign).toHaveBeenCalledTimes(3);
for (const [destination] of assign.mock.calls) {
const url = new URL(destination, "http://localhost:3000");
expect(url.pathname).toBe("/api/auth/discord");
expect(url.searchParams.get("timezone")).toBe(
Intl.DateTimeFormat().resolvedOptions().timeZone,
);
describe("design-system-only routing", () => {
for (const path of ["/design-system", "/design-system/", "/", "/about", "/settings", "/missing", "/?auth_error=denied"]) {
test(`renders only the public design system at ${path}`, async () => {
await render(path);
expect(container.querySelector("#ds-title")).not.toBeNull();
expect(container.querySelectorAll("main")).toHaveLength(1);
expect(["/design-system", "/design-system/"]).toContain(container.querySelector('[data-testid="pathname"]')!.textContent!);
expect(container.querySelector(".landing-root")).toBeNull();
expect(container.querySelector(".home-root")).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(container.querySelectorAll('[role="tablist"]')).toHaveLength(1);
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(7);
expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1);
for (const tab of container.querySelectorAll('[role="tab"]')) {
const panel = document.getElementById(tab.getAttribute("aria-controls")!);
expect(panel?.getAttribute("aria-labelledby")).toBe(tab.id);
}
} finally {
assign.mockRestore();
}
});
test("other routes keep their shell and can navigate back to the public home", async () => {
await render("/about");
expect(container.querySelector("h1")?.textContent).toBe("About");
await click("Home");
expect(container.querySelector("#landing-title")).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("the design-system route stays public and independent of account loading", async () => {
await render("/design-system/");
expect(container.querySelector("#ds-title")).not.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
test("the demo updates completion, switches views, and resets without API writes", async () => {
await render();
await click("Increase glasses of water");
expect(
container.querySelector(".ds-preview-heading")?.textContent,
).toContain("1 of 2 habits complete");
expect(
container.querySelector<HTMLButtonElement>(
'[aria-label="Increase glasses of water"]',
)?.disabled,
).toBe(true);
await click("Combined progress");
expect(
container.querySelector('[aria-label="Combined habit calendar"]'),
).not.toBeNull();
const reset = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Reset demo"),
)!;
await act(async () => reset.click());
expect(
container.querySelector(".ds-preview-heading")?.textContent,
).toContain("0 of 2 habits complete");
expect(
container.querySelector('[aria-label="Increase glasses of water"]'),
).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("signed-in dashboard with the real habit API", () => {
let api: ReturnType<typeof fixture>;
beforeEach(() => {
api = fixture();
fetchMock.mockImplementation((async (
input: RequestInfo | URL,
options?: RequestInit,
) => {
const path = String(input).replace(/^\/api/, "");
if (path === "/me") return Response.json(account);
return api.request(
path,
options?.method ?? "GET",
options?.body ? JSON.parse(String(options.body)) : undefined,
);
}) as typeof fetch);
});
afterEach(() => api.close());
async function input(selector: string, value: string) {
const element = container.querySelector<HTMLInputElement>(selector)!;
expect(element).not.toBeNull();
await act(async () => {
const prototype =
element.tagName === "SELECT"
? dom.HTMLSelectElement.prototype
: element.tagName === "TEXTAREA"
? dom.HTMLTextAreaElement.prototype
: dom.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(
element,
value,
);
element.dispatchEvent(
new Event(element.tagName === "SELECT" ? "change" : "input", {
bubbles: true,
}),
);
});
}
test("empty accounts get useful starters without invented history", async () => {
await render();
expect(container.textContent).toContain(
"No habits yet. No catching up to do.",
);
expect(container.querySelectorAll(".home-starters > button")).toHaveLength(
3,
);
expect(container.querySelector("#rhythm")).toBeNull();
expect(container.textContent).not.toContain("Demo history");
expect(container.textContent).toContain("Europe/Belgrade");
});
test("uses the Discord avatar and falls back only when it fails", async () => {
const avatarUrl = "https://cdn.discordapp.com/avatars/123/avatar.png";
fetchMock.mockResolvedValueOnce(Response.json({ ...account, avatarUrl }));
await render();
const avatar =
container.querySelector<HTMLImageElement>("img.home-avatar")!;
expect(avatar.src).toBe(avatarUrl);
expect(avatar.alt).toBe("Demo Users Discord avatar");
await act(async () => avatar.dispatchEvent(new Event("error")));
expect(container.querySelector("img.home-avatar")).toBeNull();
expect(container.querySelector(".home-avatar")?.textContent).toBe("D");
});
test("a starter is editable and creates a persistent first habit", async () => {
await render();
await act(async () =>
container
.querySelector<HTMLButtonElement>(".home-starters > button")!
.click(),
);
expect(
container.querySelector<HTMLInputElement>("#habit-name")!.value,
).toBe("Read a little");
await input("#habit-name", "Read five pages");
await click("Dusk #79618d");
await act(async () =>
container
.querySelector("dialog form")!
.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
),
);
expect(container.querySelector("dialog")).toBeNull();
const created = (await api.json("/habits")).habits[0];
expect(created.name).toBe("Read five pages");
expect(
(await api.json(`/habits/${created.id}/calendar-settings`)).mainColor,
).toBe("#79618d");
expect(container.querySelector(".home-habit")?.textContent).toContain(
"Read five pages",
);
expect(container.textContent).toContain("Your recorded progress");
expect(container.textContent).not.toContain("No habits yet");
const chart = container.querySelector(`#history-${created.id}`)!;
expect(chart.classList.contains("ds-habit-chart")).toBe(true);
expect(
chart.querySelector<HTMLElement>(".ds-habit-chart-heading h4 > span")!
.style.backgroundColor,
).toBe("#79618d");
});
test("edit controls prefill settings, cancel safely, and persist validated changes", async () => {
const habit = await api.json(
"/habits",
"POST",
{
name: "Water",
method: "count",
target: 8,
unit: "glasses",
carryPartialProgress: true,
color: "#426582",
schedule: { type: "interval", every: 2, anchor: "2026-09-04" },
},
201,
);
await render();
await click("Edit Water");
expect(
container.querySelector<HTMLInputElement>("#habit-target")!.value,
).toBe("8");
expect(
container.querySelector<HTMLSelectElement>("#habit-method")!.disabled,
).toBe(true);
await input("#habit-name", "Not saved");
await click("Cancel");
expect((await api.json(`/habits/${habit.id}`)).name).toBe("Water");
await click("Edit Water");
await input("#habit-name", "Drink water");
await input("#habit-target", "10");
await click("Dusk #79618d");
const submit = async () =>
act(async () =>
container
.querySelector("dialog form")!
.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
),
);
fetchMock.mockResolvedValueOnce(
Response.json({ error: "Try saving again" }, { status: 503 }),
);
await submit();
expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
"Try saving again",
);
expect(
container.querySelector<HTMLInputElement>("#habit-name")!.value,
).toBe("Drink water");
await submit();
expect(container.querySelector("dialog")).toBeNull();
const updated = await api.json(`/habits/${habit.id}`);
expect(updated.name).toBe("Drink water");
expect(updated.target).toBe(10);
expect(updated.carryPartialProgress).toBe(true);
expect(updated.schedule).toEqual(habit.schedule);
expect(
(await api.json(`/habits/${habit.id}/calendar-settings`)).mainColor,
).toBe("#79618d");
expect(
container.querySelector(`#history-${habit.id} h4`)?.textContent,
).toBe("Drink water");
});
test("editing a task habit preserves task IDs and schedules", async () => {
const habit = await api.json(
"/habits",
"POST",
{
name: "Reset",
method: "tasks",
tasks: [
{ name: "Clear desk", schedule: { type: "weekdays", days: [5] } },
],
},
201,
);
await render();
await click("Edit Reset");
await input("#habit-name", "Evening reset");
await act(async () =>
container
.querySelector("dialog form")!
.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
),
);
expect(container.querySelector("dialog")).toBeNull();
const updated = await api.json(`/habits/${habit.id}`);
expect(updated.name).toBe("Evening reset");
expect(updated.tasks).toEqual(habit.tasks);
});
test("deletion requires confirmation, supports retry, and handles an empty dashboard", async () => {
const habit = await api.json(
"/habits",
"POST",
{ name: "Read", method: "manual" },
201,
);
await render();
await click("Delete Read");
expect(container.querySelector("dialog")?.textContent).toContain(
"not permanently erased",
);
expect((await api.json("/habits")).habits).toHaveLength(1);
await click("Keep habit");
expect(container.querySelector("dialog")).toBeNull();
await click("Delete Read");
fetchMock.mockResolvedValueOnce(
Response.json({ error: "Could not delete" }, { status: 503 }),
);
await click("Delete habit");
expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
"Could not delete",
);
expect(container.querySelector(`#history-${habit.id}`)).not.toBeNull();
await click("Delete habit");
expect(container.querySelector("dialog")).toBeNull();
expect(container.querySelector(`#history-${habit.id}`)).toBeNull();
expect(container.textContent).toContain("No habits yet");
expect((await api.json("/habits")).habits).toHaveLength(0);
expect((await api.json(`/habits/${habit.id}`)).archived).toBe(true);
});
test("all tracking methods save, undo, and exclude unscheduled habits", async () => {
const manual = await api.json(
"/habits",
"POST",
{ name: "Read", method: "manual" },
201,
);
const count = await api.json(
"/habits",
"POST",
{ name: "Water", method: "count", target: 8, unit: "glasses" },
201,
);
const tasks = await api.json(
"/habits",
"POST",
{ name: "Reset", method: "tasks", tasks: [{ name: "Clear desk" }] },
201,
);
await api.json(
"/habits",
"POST",
{
name: "Sunday walk",
method: "manual",
schedule: { type: "weekdays", days: [0] },
},
201,
);
await render();
expect(container.querySelectorAll(".home-habit")).toHaveLength(3);
expect(
container.querySelectorAll(
"#rhythm .ds-habit-chart-grid > .ds-habit-chart",
),
).toHaveLength(4);
expect(container.querySelector("#history-habit")).toBeNull();
expect(
container.querySelectorAll("#rhythm .ds-calendar--compact"),
).toHaveLength(4);
expect(
container.querySelector(".home-task-details")?.hasAttribute("open"),
).toBe(false);
expect(container.querySelector(".home-off-day")?.textContent).toContain(
"Sunday walk",
);
await act(async () =>
container
.querySelector<HTMLInputElement>(`#habit-${manual.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect((await api.json("/today")).completed).toBe(1);
await click("Increase Water (glasses)");
expect((await api.json(`/habits/${count.id}/days/2026-09-04`)).value).toBe(
1,
);
await act(async () =>
container
.querySelector(`#habit-${tasks.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect((await api.json("/today")).completed).toBe(2);
await click("Remaining 1");
expect(container.querySelectorAll(".home-habit")).toHaveLength(1);
await click("All today 3");
await act(async () =>
container
.querySelector(`#habit-${manual.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect((await api.json("/today")).completed).toBe(1);
});
test("all-complete and no-schedule days are different from having no habits", async () => {
const habit = await api.json(
"/habits",
"POST",
{ name: "Read", method: "manual" },
201,
);
await api.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", {
done: true,
});
await render();
expect(container.textContent).toContain(
"Everything scheduled for today is complete",
);
await click("Remaining 0");
expect(container.textContent).toContain("Youre all caught up.");
await api.json(`/habits/${habit.id}`, "PATCH", {
schedule: { type: "weekdays", days: [0] },
});
await act(async () => window.dispatchEvent(new Event("focus")));
expect(container.textContent).toContain("Nothing is scheduled today");
expect(container.textContent).not.toContain("No habits yet");
});
test("failed logging preserves progress and offers a refresh", async () => {
await api.json(
"/habits",
"POST",
{ name: "Water", method: "count", target: 8, unit: "glasses" },
201,
);
await render();
fetchMock.mockResolvedValueOnce(
Response.json({ error: "Could not save progress" }, { status: 503 }),
);
await click("Increase Water (glasses)");
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Could not save progress",
);
expect((await api.json("/today")).habits[0].value).toBe(0);
await click("Try again");
await click("Increase Water (glasses)");
expect((await api.json("/today")).habits[0].value).toBe(1);
});
test("a failed initial load is not shown as an empty account", async () => {
fetchMock
.mockResolvedValueOnce(Response.json(account))
.mockResolvedValueOnce(new Response(null, { status: 503 }));
await render();
expect(container.querySelector('[role="alert"]')).not.toBeNull();
expect(container.textContent).not.toContain("No habits yet");
await click("Try again");
expect(container.textContent).toContain("No habits yet");
test("design system playground remains interactive without API requests", async () => {
await render("/design-system#playground");
const tabs = Array.from(container.querySelectorAll<HTMLButtonElement>("button"));
const detail = tabs.find(button => button.textContent?.trim() === "Habit detail")!;
expect(detail).toBeDefined();
await act(async () => detail.click());
expect(detail.getAttribute("aria-pressed")).toBe("true");
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("design system tabs", () => {
const tab = (id: string) => container.querySelector<HTMLButtonElement>(`#ds-tab-${id}`)!;
const panel = () => container.querySelector<HTMLElement>('[role="tabpanel"]:not([hidden])')!;
const clickTab = async (id: string) => { await act(async () => tab(id).click()); };
const press = async (id: string, key: string) => {
await act(async () => tab(id).dispatchEvent(
new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent,
));
};
test("defaults to foundations and each tab exposes only its own panel", async () => {
await render();
expect(panel().id).toBe("ds-panel-foundations");
for (const id of ["components", "containers", "calendar-states", "playground", "editing", "views", "foundations"]) {
await clickTab(id);
expect(panel().id).toBe(`ds-panel-${id}`);
expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1);
expect(tab(id).getAttribute("aria-selected")).toBe("true");
expect(container.querySelectorAll('[role="tab"][tabindex="0"]')).toHaveLength(1);
expect(container.querySelector('[data-testid="hash"]')?.textContent).toBe(`#${id}`);
}
expect(fetchMock).not.toHaveBeenCalled();
});
test("supports arrow keys, Home, End, and wraparound with focus", async () => {
await render();
await press("foundations", "ArrowLeft");
expect(document.activeElement).toBe(tab("views"));
expect(panel().id).toBe("ds-panel-views");
await press("views", "ArrowRight");
expect(document.activeElement).toBe(tab("foundations"));
await press("foundations", "End");
expect(document.activeElement).toBe(tab("views"));
await press("views", "Home");
expect(document.activeElement).toBe(tab("foundations"));
expect(panel().id).toBe("ds-panel-foundations");
});
test("honors deep links and browser history", async () => {
await render("/design-system#editing");
expect(panel().id).toBe("ds-panel-editing");
await clickTab("containers");
const historyButton = (label: string) => Array.from(container.querySelectorAll("button")).find(button => button.textContent === label)!;
await act(async () => historyButton("History back").click());
expect(panel().id).toBe("ds-panel-editing");
await act(async () => historyButton("History forward").click());
expect(panel().id).toBe("ds-panel-containers");
});
test("keeps example state and opens the playground from View habit", async () => {
await render("/design-system#components");
await act(async () => panel().querySelector<HTMLButtonElement>('[aria-label="Increase example count"]')!.click());
await clickTab("editing");
await clickTab("components");
expect(panel().querySelector("output")?.textContent).toBe("4 / 8");
const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!;
await act(async () => viewHabit.click());
expect(panel().id).toBe("ds-panel-playground");
expect(panel().querySelector('.ds-view-switch [aria-pressed="true"]')?.textContent).toBe("Habit detail");
expect(document.activeElement).toBe(tab("playground"));
});
test("retains child component state and skip link does not switch tabs", async () => {
await render("/design-system#containers");
const reading = panel().querySelector<HTMLInputElement>('input[type="checkbox"]')!;
await act(async () => reading.click());
expect(reading.checked).toBe(true);
await clickTab("foundations");
await clickTab("containers");
expect(reading.checked).toBe(true);
await act(async () => container.querySelector<HTMLAnchorElement>(".ds-skip-link")!.click());
expect(panel().id).toBe("ds-panel-containers");
expect(document.activeElement?.id).toBe("ds-main");
});
test("an unknown section safely falls back to foundations", async () => {
await render("/design-system#unknown");
expect(panel().id).toBe("ds-panel-foundations");
});
});

View File

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

View File

@@ -1,19 +1,21 @@
import { useAuth } from "./AuthProvider";
import { Button } from "./design-system/primitives";
import { DiscordSignInButton } from "./DiscordSignInButton";
export function AuthControls() {
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
return (
<section aria-label="Account">
<section className="ds-account-controls" aria-label="Account">
{loading ? <p role="status">Loading account</p> : user ? (
<p>
Signed in as <strong>{user.displayName}</strong> · {user.timezone}{" "}
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
<Button variant="secondary" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</Button>{" "}
<a href="/api/me">View my profile</a>
</p>
) : !accountError && <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
) : !accountError && <p><DiscordSignInButton onClick={signIn} /></p>}
{error && <p role="alert">{error}</p>}
{accountError && <button type="button" onClick={retry}>Try again</button>}
{accountError && <Button variant="secondary" onClick={retry}>Try again</Button>}
</section>
);
}

View File

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

View File

@@ -1,67 +0,0 @@
import { useState } from "react";
import { Button, Checkbox, Counter } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { combinedProgress, demoCalendar, HABIT_COLORS } from "./design-system/calendar-model";
export function LandingDemo() {
const [water, setWater] = useState(7);
const [tasks, setTasks] = useState([true, true, false]);
const [view, setView] = useState<"Today" | "Combined progress">("Today");
const taskCount = tasks.filter(Boolean).length;
const total = combinedProgress([
{ value: water, target: 8, due: true },
{ value: taskCount, target: 3, due: true },
]);
return (
<section className="ds-section landing-demo" id="demo" aria-labelledby="demo-title">
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">01 / A LITTLE, EVERY DAY</span>
<h2 id="demo-title">Small steps. <em>Visible progress.</em></h2>
</div>
<span className="ds-demo-note">Interactive demo · nothing is saved</span>
</div>
<div className="ds-preview-toolbar">
<div className="ds-view-switch" role="group" aria-label="Demo view">
{(["Today", "Combined progress"] as const).map((item) => (
<button key={item} type="button" aria-pressed={view === item} onClick={() => setView(item)}>{item}</button>
))}
</div>
<Button variant="text" onClick={() => { setWater(7); setTasks([true, true, false]); setView("Today"); }}>
Reset demo <span aria-hidden="true"></span>
</Button>
</div>
<div className="ds-preview-heading">
<div>
<p className="ds-eyebrow">A SAMPLE DAY / SEPTEMBER 4, 2026</p>
<h3>{view === "Today" ? "Make a little room for yourself." : "See your habits, together."}</h3>
</div>
<p aria-live="polite">{total.completed} of {total.due} habits complete</p>
</div>
{view === "Today" ? (
<div className="ds-habit-chart-grid">
<HabitChart name="Drink water" method="Count target" value={water} target={8} unit="glasses" color={HABIT_COLORS.water}>
<Counter label="glasses of water" value={water} target={8} onChange={setWater} />
</HabitChart>
<HabitChart name="Evening reset" method="Task-based" value={taskCount} target={3} unit="tasks" color={HABIT_COLORS.tasks}
tasks={["Clear desk", "Plan tomorrow", "Stretch"].map((label, index) => (
<Checkbox key={label} label={label} checked={tasks[index]} onChange={(event) => {
const checked = event.target.checked;
setTasks((current) => current.map((done, i) => i === index ? checked : done));
}} />
))}
/>
</div>
) : (
<CalendarHeatmap days={demoCalendar(total.completed, total.due)} label="Combined habit calendar" unit="habits complete" />
)}
<p className="ds-footnote landing-demo-hint">
{view === "Today"
? "Try adding a glass of water, or open the evening tasks and tick off a small win. Select any day to take a closer look."
: "Each fully completed habit counts equally. Partial progress appears in its own calendar; unscheduled habits stay out of the combined score."}
</p>
</section>
);
}

View File

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

View File

@@ -0,0 +1,44 @@
import { useId, type HTMLAttributes, type ReactNode } from "react";
/** A flat content group. Actions remain explicit buttons or links inside it. */
export function Card({
eyebrow,
heading,
children,
footer,
variant = "soft",
span = "standard",
className = "",
...props
}: HTMLAttributes<HTMLElement> & {
eyebrow?: string;
heading: ReactNode;
footer?: ReactNode;
variant?: "soft" | "outlined";
span?: "standard" | "wide" | "featured";
}) {
const headingId = useId();
return (
<article
aria-labelledby={headingId}
className={`ds-card ds-card--${variant} ds-card--${span} ${className}`}
{...props}
>
<header className="ds-card-header">
{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}
<h3 id={headingId}>{heading}</h3>
</header>
{children && <div className="ds-card-body">{children}</div>}
{footer && <footer className="ds-card-footer">{footer}</footer>}
</article>
);
}
/** Equal cards by default; bento spans keep the same reading order on mobile. */
export function CardGrid({
layout = "equal",
className = "",
...props
}: HTMLAttributes<HTMLDivElement> & { layout?: "equal" | "bento" }) {
return <div className={`ds-card-grid ds-card-grid--${layout} ${className}`} {...props} />;
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import type {
ButtonHTMLAttributes,
AnchorHTMLAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
@@ -21,19 +22,31 @@ export function Button({
);
}
export function ButtonLink({
variant = "text",
className = "",
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
variant?: "primary" | "secondary" | "text";
}) {
return <a className={`ds-button ds-button--${variant} ${className}`} {...props} />;
}
export function SectionHeading({
number,
title,
children,
id,
}: {
number: string;
title: string;
title: ReactNode;
children?: ReactNode;
id?: string;
}) {
return (
<header className="ds-section-heading">
<span className="ds-eyebrow">{number}</span>
<h2>{title}</h2>
<h2 id={id}>{title}</h2>
{children && <p>{children}</p>}
</header>
);

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,744 +0,0 @@
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import { Link } from "react-router";
import { DeleteHabit } from "../components/DeleteHabit";
import { useAuth } from "../components/AuthProvider";
import {
Button,
Checkbox,
Counter,
} from "../components/design-system/primitives";
import { CreateHabit, type HabitStarter } from "../components/CreateHabit";
import { HabitHistory } from "../components/HabitHistory";
import {
formatTrackingDate,
habitRequest,
scheduleLabel,
type TodayHabit,
type TodayResponse,
} from "../lib/dashboard";
import "../../styles/design-system.css";
import "../../styles/home.css";
const starters: (HabitStarter & { description: string; symbol: string })[] = [
{
name: "Read a little",
method: "manual",
description: "A few pages. A moment for yourself.",
symbol: "↗",
color: "#977344",
},
{
name: "Drink water",
method: "count",
target: 8,
unit: "glasses",
description: "One glass at a time, throughout the day.",
symbol: "+",
color: "#426582",
},
{
name: "Evening reset",
method: "tasks",
tasks: "Clear desk\nPlan tomorrow\nStretch",
description: "A small routine to close the day.",
symbol: "☾",
color: "#79618d",
},
];
function CountEntry({
habit,
disabled,
onSave,
}: {
habit: TodayHabit;
disabled: boolean;
onSave: (value: number) => void;
}) {
const [value, setValue] = useState(String(habit.value));
useEffect(() => setValue(String(habit.value)), [habit.value]);
function submit(event: FormEvent) {
event.preventDefault();
if (
value.trim() &&
Number.isInteger(Number(value)) &&
Number(value) >= 0 &&
Number(value) <= 1_000_000_000
)
onSave(Number(value));
}
return (
<details className="home-count-entry">
<summary>Set a total</summary>
<form onSubmit={submit}>
<label htmlFor={`count-${habit.habitId}`}>Total {habit.unit}</label>
<input
id={`count-${habit.habitId}`}
type="number"
min={0}
max={1_000_000_000}
step={1}
required
value={value}
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
/>
<Button type="submit" variant="secondary" disabled={disabled}>
Save total
</Button>
</form>
</details>
);
}
export function Home() {
const { user, signOut, busy: authBusy, error: authError, signIn } = useAuth();
const [today, setToday] = useState<TodayResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState<"all" | "remaining">("all");
const [starter, setStarter] = useState<HabitStarter | null>(null);
const [editing, setEditing] = useState<{
habit: TodayHabit;
color: string;
} | null>(null);
const [deleting, setDeleting] = useState<TodayHabit | null>(null);
const [revision, setRevision] = useState(0);
const [failedAvatar, setFailedAvatar] = useState<string | null>(null);
const requestId = useRef(0);
const mutation = useRef(false);
const mounted = useRef(true);
const refresh = useCallback(async () => {
const id = ++requestId.current;
try {
const result = await habitRequest<TodayResponse>("/today");
if (mounted.current && id === requestId.current) {
setToday(result);
setError("");
setRevision((value) => value + 1);
}
} catch (error) {
if (mounted.current && id === requestId.current)
setError(
error instanceof Error
? error.message
: "Could not load your habits.",
);
} finally {
if (mounted.current && id === requestId.current) setLoading(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void refresh();
const refreshVisible = () => {
if (!mutation.current && document.visibilityState !== "hidden")
void refresh();
};
const timer = window.setInterval(refreshVisible, 60_000);
window.addEventListener("focus", refreshVisible);
document.addEventListener("visibilitychange", refreshVisible);
return () => {
mounted.current = false;
++requestId.current;
window.clearInterval(timer);
window.removeEventListener("focus", refreshVisible);
document.removeEventListener("visibilitychange", refreshVisible);
};
}, [refresh]);
async function log(
habit: TodayHabit,
value: { count: number } | { done: boolean },
taskId?: string,
) {
if (mutation.current || !today) return;
mutation.current = true;
++requestId.current;
setBusy(true);
setError("");
setNotice("");
try {
const updated = await habitRequest<TodayHabit>(
`/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
},
);
if (!mounted.current) return;
setToday((current) => {
if (!current || current.date !== updated.date) return current;
const habits = current.habits.map((item) =>
item.habitId === updated.habitId ? updated : item,
);
return {
...current,
habits,
due: habits.filter((item) => item.due).length,
completed: habits.filter((item) => item.complete).length,
};
});
setNotice(`${habit.name} updated. Your progress is saved.`);
await refresh();
} catch (error) {
if (mounted.current)
setError(
error instanceof Error
? error.message
: "Could not save progress. Please try again.",
);
} finally {
mutation.current = false;
if (mounted.current) setBusy(false);
}
}
if (!user) return null;
const empty = today?.habits.length === 0;
const due = today?.habits.filter((habit) => habit.due) ?? [];
const offDay = today?.habits.filter((habit) => !habit.due) ?? [];
const shown =
filter === "remaining" ? due.filter((habit) => !habit.complete) : due;
const remaining = today ? today.due - today.completed : 0;
const name = user.displayName || user.username;
return (
<div className="ds-root home-root" id="top">
<a className="ds-skip-link" href="#home-main">
Skip to content
</a>
<header className="ds-header home-header">
<Link className="ds-wordmark" to="/" aria-label="Minabot home">
minabot.
</Link>
<nav aria-label="Main navigation">
<Link to="/" aria-current="page">
Home
</Link>
<a href="#today">Today</a>
<a href={empty ? "#first-habit" : "#rhythm"}>
{empty ? "Get started" : "Your rhythm"}
</a>
</nav>
<div className="home-account">
{user.avatarUrl && failedAvatar !== user.avatarUrl ? (
<img
className="home-avatar"
src={user.avatarUrl}
alt={`${name}s Discord avatar`}
width={30}
height={30}
referrerPolicy="no-referrer"
onError={() => setFailedAvatar(user.avatarUrl)}
/>
) : (
<span className="home-avatar" aria-hidden="true">
{name.slice(0, 1).toUpperCase()}
</span>
)}
<span className="home-account-name" title={name}>
{name}
</span>
<Button
variant="text"
disabled={authBusy || busy || !!starter}
onClick={() => void signOut()}
>
{authBusy ? "Signing out…" : "Sign out"}
</Button>
</div>
</header>
<main className="ds-main" id="home-main">
<section className="home-welcome" aria-labelledby="home-title">
<div>
<p className="ds-eyebrow">YOUR SPACE / YOUR OWN PACE</p>
<h1 id="home-title">
{empty ? "Welcome home," : "A little today,"}
<br />
<em>{name}.</em>
</h1>
<p className="ds-intro-copy">
{empty
? "This is your space to build a rhythm. Lets start with one small thing."
: "Pick up where you are. Make room for what matters."}
</p>
</div>
<div className="home-date">
<span className="ds-tiny-cross" aria-hidden="true">
+
</span>
{today ? (
<>
<time dateTime={today.date}>
{formatTrackingDate(today.date)}
</time>
<p>{today.timezone.replaceAll("_", " ")}</p>
</>
) : (
<p>Your daily home base.</p>
)}
<span className="ds-footnote">A new day. Your own pace.</span>
</div>
</section>
{authError && (
<p className="home-error" role="alert">
{authError}
</p>
)}
{error && (
<div className="home-error" role="alert">
<p>
{error}
{today && " The view below may be out of date."}
</p>
{error.includes("session has expired") ? (
<Button variant="secondary" onClick={signIn}>
Sign in again
</Button>
) : (
<Button
variant="secondary"
disabled={busy}
onClick={() => {
setLoading(!today);
void refresh();
}}
>
Try again
</Button>
)}
</div>
)}
<p className="home-notice" role="status">
{notice}
</p>
{loading && (
<div className="home-state" role="status">
Getting your day ready
</div>
)}
{today && (
<>
<section className="home-overview" aria-label="Today at a glance">
<div>
<span className="ds-eyebrow">
{empty ? "A FRESH START" : "TODAYS PROGRESS"}
</span>
<p className="home-stat">
{today.completed}
<span> / {today.due}</span>
</p>
<p>habits complete</p>
<progress
value={today.completed}
max={today.due || 1}
aria-label="Habits completed today"
/>
</div>
<div>
<span className="ds-eyebrow">
{empty ? "ONE SMALL STEP" : "STILL TO COME"}
</span>
<p className="home-stat">
{empty ? "01" : String(remaining).padStart(2, "0")}
</p>
<p>
{empty
? "is all it takes to begin"
: remaining === 1
? "habit left for today"
: "habits left for today"}
</p>
</div>
<div className="home-overview-note">
<span className="ds-eyebrow">
{empty
? "NO PERFECT START REQUIRED"
: today.due === 0
? "ROOM TO REST"
: remaining === 0
? "ENOUGH FOR TODAY"
: "A GENTLE REMINDER"}
</span>
<h2>
{empty ? (
<>
Small is <em>a good start.</em>
</>
) : today.due === 0 ? (
<>
A day off <em>counts, too.</em>
</>
) : remaining === 0 ? (
<>
You showed up. <em>Enjoy that.</em>
</>
) : (
<>
A little is <em>still something.</em>
</>
)}
</h2>
<p>
{empty
? "Choose something easy enough to come back to tomorrow."
: today.due === 0
? "Nothing is scheduled today. Your habits will be here when its time."
: remaining === 0
? "Everything scheduled for today is complete. No extra credit needed."
: "Check in as you go. Every bit of progress belongs here."}
</p>
</div>
</section>
<section
className="ds-section home-today"
id="today"
aria-labelledby="today-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">01 / YOUR DAILY CHECK-IN</span>
<h2 id="today-title">
{empty ? (
<>
Begin with <em>a little.</em>
</>
) : (
<>
Make today <em>your own.</em>
</>
)}
</h2>
</div>
<Button
disabled={busy || !!error}
onClick={() => setStarter({ name: "", method: "manual" })}
>
{empty ? "Create your first habit" : "New habit"}
<span aria-hidden="true">+</span>
</Button>
</div>
{empty ? (
<div id="first-habit" className="home-empty">
<p>No habits yet. No catching up to do.</p>
<p className="ds-muted">
Create your own, or use an idea below as a starting point.
You can make it yours before saving.
</p>
<div className="home-starters">
{starters.map((item) => (
<button
type="button"
key={item.name}
disabled={!!error}
onClick={() => setStarter(item)}
>
<span
className="home-starter-symbol"
aria-hidden="true"
>
{item.symbol}
</span>
<h3>{item.name}</h3>
<p>{item.description}</p>
<span className="home-starter-action">
Make it mine <span aria-hidden="true"></span>
</span>
</button>
))}
</div>
</div>
) : (
<>
<div className="ds-preview-toolbar">
<div
className="ds-view-switch"
role="group"
aria-label="Todays habits"
>
<button
type="button"
aria-pressed={filter === "all"}
onClick={() => setFilter("all")}
>
All today <span>{today.due}</span>
</button>
<button
type="button"
aria-pressed={filter === "remaining"}
onClick={() => setFilter("remaining")}
>
Remaining <span>{remaining}</span>
</button>
</div>
<span className="home-autosave">Saved as you go</span>
</div>
{shown.length === 0 && (
<div className="home-state">
<h3>
{today.due === 0
? "A little breathing room."
: "Youre all caught up."}
</h3>
<p>
{today.due === 0
? "No habits are scheduled for today. A day off isnt a missed day."
: "All of todays habits are complete. Take a moment for yourself."}
</p>
{filter === "remaining" && today.due > 0 && (
<Button variant="text" onClick={() => setFilter("all")}>
View completed habits
</Button>
)}
</div>
)}
<div className="home-habits">
{shown.map((habit) => (
<article
className={`home-habit${habit.complete ? " home-habit--complete" : ""}`}
key={habit.habitId}
aria-labelledby={`habit-${habit.habitId}`}
>
<div className="home-habit-heading">
<div>
<h3 id={`habit-${habit.habitId}`}>{habit.name}</h3>
<p className="home-habit-status">
{habit.method === "manual"
? "SIMPLE CHECK-IN"
: habit.method === "count"
? "COUNT TARGET"
: "TASK ROUTINE"}{" "}
· {scheduleLabel(habit.requirements?.schedule)}
{habit.complete && " · Complete"}
{habit.carriedFrom && (
<span>
{" "}
· Includes progress from {habit.carriedFrom}
</span>
)}
</p>
</div>
<div className="home-check-in">
{habit.method === "manual" ? (
<Checkbox
label="Done"
aria-label={`Mark ${habit.name} complete`}
checked={habit.complete}
disabled={busy || !!error}
onChange={(event) =>
void log(habit, {
done: event.target.checked,
})
}
/>
) : habit.method === "count" ? (
<Counter
label={`${habit.name} (${habit.unit})`}
value={habit.value}
target={habit.target ?? 1}
disabled={busy || !!error}
onChange={(count) => void log(habit, { count })}
/>
) : (
<span className="home-task-count">
{habit.value}
<span> / {habit.target}</span>
</span>
)}
<a
className="home-history-link"
href={`#history-${habit.habitId}`}
aria-label={`View ${habit.name} history`}
>
</a>
</div>
</div>
{habit.method === "tasks" && (
<details className="home-task-details">
<summary>
Tasks for today{" "}
<span>
{habit.value} / {habit.target}
</span>
</summary>
<div className="home-tasks">
{habit.tasks.map((task) => (
<Checkbox
key={task.taskId}
label={task.name}
checked={task.done}
disabled={busy || !!error}
onChange={(event) =>
void log(
habit,
{ done: event.target.checked },
task.taskId,
)
}
/>
))}
</div>
</details>
)}
{habit.method === "count" && (
<CountEntry
habit={habit}
disabled={busy || !!error}
onSave={(count) => void log(habit, { count })}
/>
)}
</article>
))}
</div>
{offDay.length > 0 && (
<details className="home-off-day">
<summary>
Not scheduled today <span>{offDay.length}</span>
</summary>
<p className="ds-footnote">
These habits arent included in todays progress.
</p>
{offDay.map((habit) => (
<div key={habit.habitId}>
<span>
<strong>{habit.name}</strong>
<small>
{scheduleLabel(habit.requirements?.schedule)}
{habit.method === "tasks" &&
" · No tasks due today"}
</small>
</span>
<a href={`#history-${habit.habitId}`}>
View history
</a>
</div>
))}
</details>
)}
</>
)}
</section>
{!empty && (
<section
className="ds-section home-rhythm"
id="rhythm"
aria-labelledby="rhythm-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">02 / THE BIGGER PICTURE</span>
<h2 id="rhythm-title">
Find <em>your rhythm.</em>
</h2>
</div>
</div>
<div className="ds-habit-chart-grid">
{today.habits.map((habit) => (
<HabitHistory
key={habit.habitId}
habit={habit}
date={today.date}
revision={revision}
disabled={busy || !!error}
onEdit={(color) => setEditing({ habit, color })}
onDelete={() => setDeleting(habit)}
/>
))}
</div>
<p className="ds-footnote">
Your real progress, one day at a time. Select a date to look
closer. Days off stay out of your score.
</p>
</section>
)}
</>
)}
<footer className="ds-footer home-footer">
<Link className="ds-wordmark" to="/">
minabot.
</Link>
<span>A little, every day.</span>
<a href="#top">Back to top </a>
</footer>
</main>
{starter && today && (
<CreateHabit
date={today.date}
starter={starter}
onClose={() => setStarter(null)}
onCreated={(name) => {
setStarter(null);
setNotice(`${name} is ready. Your rhythm starts here.`);
setFilter("all");
void refresh();
}}
/>
)}
{editing?.habit.requirements && today && (
<CreateHabit
date={today.date}
editing={{
id: editing.habit.habitId,
config: editing.habit.requirements,
}}
starter={{
name: editing.habit.requirements.name,
method: editing.habit.requirements.method,
color: editing.color,
...(editing.habit.requirements.method === "count"
? {
target: editing.habit.requirements.target,
unit: editing.habit.requirements.unit,
}
: {}),
}}
onClose={() => setEditing(null)}
onCreated={(name) => {
setEditing(null);
setNotice(`${name} updated. Your earlier history is kept.`);
void refresh();
}}
/>
)}
{deleting && (
<DeleteHabit
habit={deleting}
onClose={() => setDeleting(null)}
onDeleted={() => {
setNotice(
`${deleting.name} removed from your dashboard. Recorded history is kept.`,
);
// Reflect a successful deletion even if the following refresh fails.
setToday((current) => {
if (!current) return current;
const habits = current.habits.filter(
(habit) => habit.habitId !== deleting.habitId,
);
return {
...current,
habits,
due: habits.filter((habit) => habit.due).length,
completed: habits.filter((habit) => habit.complete).length,
};
});
setDeleting(null);
void refresh();
}}
/>
)}
</div>
);
}

View File

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

View File

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