feat: restructure styles and components for improved design system

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

View File

@@ -1,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>
);
}