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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user