feat: add theme provider and control components for light/dark mode

- Implemented ThemeProvider to manage theme state and preferences.
- Added ThemeControl component for users to switch between system, light, and dark themes.
- Integrated localStorage for theme preference persistence.
- Updated document styles based on theme changes.

test: add tests for permanent deletion of habits and related records

- Created tests to ensure permanent deletion of habits removes all dependent records.
- Verified that deletion requires ownership, authentication, and checks for archived habits.
- Added tests to confirm rollback behavior on deletion failures.

test: add tests for habit palette and progress card functionality

- Implemented tests for habit color progression and theme-based color shading.
- Added tests for progress card calculations and calendar display logic.

feat: define theme types and preferences

- Introduced Theme and ThemePreference types for better type safety.
- Created utility functions for resolving theme based on user preference and system settings.
- Defined theme palettes for light and dark modes to ensure adequate contrast.
This commit is contained in:
syntaxbullet
2026-09-05 10:07:28 +02:00
parent 9e9871c08a
commit 00729096bf
47 changed files with 780 additions and 337 deletions

4
PRD.md
View File

@@ -1,11 +1,11 @@
# Minabot — Habit Tracking PRD
# Mina Habits — Habit Tracking PRD
Status: Initial product requirements
Date: 2026-09-04
## Application intent
Minabot helps people record daily habit progress and understand their consistency through GitHub-style calendar heatmaps. Users can inspect each habit independently and combine selected habits into shared charts.
Mina Habits helps people record daily habit progress and understand their consistency through GitHub-style calendar heatmaps. Users can inspect each habit independently and combine selected habits into shared charts.
Individual charts answer, “How much progress did I make on this habit?” Combined charts answer, “How many of the habits due that day did I fully complete?” Partial progress is visible and useful, but does not count as a completed habit in a combined chart.

View File

@@ -1,4 +1,4 @@
# Minabot
# Mina Habits
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router
client includes a homepage for signed-out visitors and a personal habit dashboard.
@@ -67,7 +67,9 @@ its own recurrence. Both habit and task schedules support daily, selected weekda
day intervals, and week intervals with an editable start date and weekday.
Archive asks for confirmation in a modal and preserves earlier history.
**Archived habits** opens the archive modal, with history inspection and restoration
starting today. Task deletion uses the same modal confirmation. Tasks
starting today. **Delete permanently** removes an archived habit and all its history
after confirmation; it also removes the habit from combined charts and deletes
charts left empty. Task deletion uses the same modal confirmation. Tasks
remain manageable on days off, while their checkboxes stay disabled.
Settings, sharing, new habits, and historical check-ins also open in native dialogs.
Dialogs keep the background inactive, contain keyboard focus, support Escape and
@@ -85,16 +87,18 @@ Combined-chart management is not exposed on this homepage.
The dashboard opens with a compact greeting and a Today filter. All habits includes
routines on days off; creating a habit or editing one out of todays schedule keeps
it reachable in All habits. Saved calendars expand through **View history**, with
historical corrections available inside the expanded view. Selecting a square
it reachable in All habits. Every habit shows its calendar immediately, starting with three months and offering
longer ranges. Historical corrections are available by selecting a day. Selecting a square
only changes the inspected day; it preserves the date range and number of weeks,
including on untracked, unscheduled, and future dates. An external editor date
outside a preset window can still bring that date into view.
Each saved habit uses its own action layout: count goals have a prominent counter
and unit inside a tinted panel, simple check-ins use a compact checkbox-and-heading
row, and routines use a bordered checklist with a progress header. Daily schedule labels and
duplicate progress copy are omitted; days off stay explicit.
Each saved habit shares a serif heading, type label, color marker, schedule, and
explicit completion status. A visible graph sits beside a neutral completion panel:
count goals show their target, unit, and editable counter; check-ins show a labeled
checkbox; routines show an open task list and progress. On narrow screens, completion
controls stack above the graph. Spacing, typography, and neutral surfaces use the
shared design-system tokens; habit color marks identity and progress.
Count totals can be typed directly and saved with **Save** or Enter. Escape restores
the recorded total; plus and minus remain available. Counts may exceed the target.

View File

@@ -3,7 +3,7 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"name": "mina-habits",
"dependencies": {
"@nivo/calendar": "^0.99.0",
"bun-plugin-tailwind": "^0.1.2",

View File

@@ -20,6 +20,7 @@ Run `bun dev` to migrate the local database and serve the API. Run `bun run test
| GET | `/habits/:id` | Current habit configuration and revision ID |
| PATCH | `/habits/:id` | Change name, method, method settings, schedule or archived flag |
| DELETE | `/habits/:id` | Archive from today, preserving history; 204 |
| DELETE | `/habits/:id/permanent` | Permanently delete an owned archived habit and all its history; 204. Active habits return 409. Remove it from combined charts and delete charts left empty. |
| GET | `/habits/:id/history` | `{revisions: [...]}` newest first, including superseded same-day revisions |
| GET | `/habits/:id/tasks` | `{tasks: [...]}` for current task method |
| POST | `/habits/:id/tasks` | Add task; 201 |

View File

@@ -1,4 +1,4 @@
# Minabot visual studies
# Mina Habits visual studies
Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are visual references, not data-accurate calendar renders. Two independent generations; no input images. The no-card, monochromatic editorial layouts informed the scoped design-system stylesheet and reusable components. Fonts are bundled locally under src/assets/fonts with their OFL license.
@@ -6,13 +6,13 @@ Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are v
Saved asset: `src/assets/design/today.png`
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Mina Habits, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "Mina Habits" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
## Habit detail
Saved asset: `src/assets/design/habit-detail.png`
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Individual habit detail view. Breadcrumb "Habits / Drink water". Large Instrument Serif heading "Drink water" and small subtitle "Eight glasses. One day at a time." Right aligned text action "Edit habit". Open ruled section with label "Friday, September 4", huge serif "7 / 8" with unit "glasses", minus and plus buttons, text "In progress · 1 glass to go". Next open section "Progress" with GitHub-style 7-row square-cell calendar across JanSep 2026. 8 distinct positive gray steps plus empty due state; not scheduled cells are outlined, future cells faint. Below calendar an inline selected-date inspector "September 4, 2026 · 7 of 8 glasses · Incomplete" and explanatory caption "Partial progress is visible here. This habit counts in a combined chart only at 8 of 8." Bottom thin-rule section "Schedule" with "Every day" and "Changes apply from today. Earlier requirements stay the same." NO cards or boxed panels. Crisp elegant readable interface.
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Mina Habits, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "Mina Habits" and links "Today", "Habits", "Charts". Primary request: Individual habit detail view. Breadcrumb "Habits / Drink water". Large Instrument Serif heading "Drink water" and small subtitle "Eight glasses. One day at a time." Right aligned text action "Edit habit". Open ruled section with label "Friday, September 4", huge serif "7 / 8" with unit "glasses", minus and plus buttons, text "In progress · 1 glass to go". Next open section "Progress" with GitHub-style 7-row square-cell calendar across JanSep 2026. 8 distinct positive gray steps plus empty due state; not scheduled cells are outlined, future cells faint. Below calendar an inline selected-date inspector "September 4, 2026 · 7 of 8 glasses · Incomplete" and explanatory caption "Partial progress is visible here. This habit counts in a combined chart only at 8 of 8." Bottom thin-rule section "Schedule" with "Every day" and "Changes apply from today. Earlier requirements stay the same." NO cards or boxed panels. Crisp elegant readable interface.
## Interactive implementation
@@ -42,4 +42,4 @@ The updated Today preview uses four independent habit charts in a two-column, mu
New saved asset: `src/assets/design/today-v2.png`. Original `today.png` is preserved; the gallery now uses v2. Built-in imagegen prompt:
Use case: ui-mockup. Asset type: high fidelity desktop Minabot habit tracker Today view. Ultra minimalistic flat white website. Instrument Serif regular and italic headings with small system sans labels. Thin horizontal rules, ample whitespace. NO cards, no outer borders around chart groups, no shadows, no rounded panels. Header serif wordmark "minabot", links "Today", "Habits", "Charts". Large title "A little, every day." Date "Friday, September 4". Below show FOUR INDIVIDUAL HABIT CALENDARS in a TWO COLUMN, TWO ROW GRID. Each habit owns its separate GitHub-style 7-row calendar with 13 weeks of square cells, Jun/Jul/Aug/Sep labels. Each calendar has ONE DISTINCT COLOR with intensity shades: Drink water muted blue, Read a little warm ochre, Evening reset muted violet, Move a little muted green. All remaining UI black and white. Top left: Drink water, "7 of 8 glasses", minus/plus logging, blue calendar. Top right: Read a little, "Complete", checked checkbox, ochre calendar. Bottom left: Evening reset, "2 of 3 tasks", violet calendar, accordion labeled "Tasks for today" expanded underneath revealing checked "Clear desk", checked "Plan tomorrow", unchecked "Stretch". Bottom right: Move a little, "10 of 20 minutes", minus/plus logging, green calendar. Separate chart groups with whitespace and top horizontal rules only; absolutely no card containers. Precise editorial elegant layout. At bottom small note "Each chart shows its own progress. Only fully completed habits count in combined charts." Render clean landscape screenshot, no browser frame.
Use case: ui-mockup. Asset type: high fidelity desktop Mina Habits habit tracker Today view. Ultra minimalistic flat white website. Instrument Serif regular and italic headings with small system sans labels. Thin horizontal rules, ample whitespace. NO cards, no outer borders around chart groups, no shadows, no rounded panels. Header serif wordmark "Mina Habits", links "Today", "Habits", "Charts". Large title "A little, every day." Date "Friday, September 4". Below show FOUR INDIVIDUAL HABIT CALENDARS in a TWO COLUMN, TWO ROW GRID. Each habit owns its separate GitHub-style 7-row calendar with 13 weeks of square cells, Jun/Jul/Aug/Sep labels. Each calendar has ONE DISTINCT COLOR with intensity shades: Drink water muted blue, Read a little warm ochre, Evening reset muted violet, Move a little muted green. All remaining UI black and white. Top left: Drink water, "7 of 8 glasses", minus/plus logging, blue calendar. Top right: Read a little, "Complete", checked checkbox, ochre calendar. Bottom left: Evening reset, "2 of 3 tasks", violet calendar, accordion labeled "Tasks for today" expanded underneath revealing checked "Clear desk", checked "Plan tomorrow", unchecked "Stretch". Bottom right: Move a little, "10 of 20 minutes", minus/plus logging, green calendar. Separate chart groups with whitespace and top horizontal rules only; absolutely no card containers. Precise editorial elegant layout. At bottom small note "Each chart shows its own progress. Only fully completed habits count in combined charts." Render clean landscape screenshot, no browser frame.

View File

@@ -0,0 +1,13 @@
# Mina Habits favicon
Generated with the built-in imagegen tool. Master: `mina-habits-icon.png`.
The application uses 32px and 180px PNG derivatives in `src/assets/`.
## Prompt
Use case: logo-brand
Asset type: production square favicon for Mina Habits, a minimal editorial habit tracker.
Primary request: a single bold, elegant uppercase M monogram, warm white on a solid near-black square background. Match a quiet serif wordmark and minimalist black-and-white interface. Strong thick strokes and subtle sturdy serifs, highly readable at 16x16 pixels.
Composition: centered M fills about 75% of square width and height, balanced narrow padding. Flat crisp 2D artwork, square canvas, no mockup.
Text (verbatim): "M"
Constraints: one icon only; no other letters, no caption, no gradients, shadows, texture, borders or decorative objects.

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

View File

@@ -0,0 +1,21 @@
# Discord sharing design
The shared image uses a separate, vertically stacked GitHub-style calendar for each selected habit. Calendar geometry comes from the dashboard's Sunday-first timeline helper; colors and partial progress come directly from the sharing API. Each total describes the entire selected date range. Days off remain dots and do not enter the denominator.
Short ranges pair a large period total with the calendar. Long ranges use the full image width for the uninterrupted timeline. Titles, calendars and legends occupy separate vertical regions, including at the tallest seven-row grid size. Text truncates within explicit bounds instead of colliding with neighboring counts. Preview, download and Discord use the same PNG, preserving the server's 1200px export contract.
The modal puts the image first, shows the Discord destination before sending, exposes quick period choices and keeps privacy details in an expandable section. The footer remains available during scrolling. Its native dialog retains keyboard focus containment, including the privacy disclosure. The thumbnail view uses a 360px image width; actual Discord sizing varies with the client and attachment aspect ratio. Cards containing many stacked calendars will require opening the image to inspect fine daily details.
## Research
- [GitHub: Introducing Contributions](https://github.blog/news-insights/product-news/introducing-contributions/): the calendar itself provides the recognizable visual history.
- [NN/g: Progressive Disclosure](https://www.nngroup.com/articles/progressive-disclosure/): defer optional settings while keeping the primary task clear.
- [Strava: Sharing Your Activities](https://support.strava.com/en-us/articles/15401840-sharing-your-strava-activities): a purpose-built image preview with a focused sharing action.
## Verification
Run `bun run typecheck`, `bun test` and `bun run build`.
For disposable browser testing, run `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` and open http://127.0.0.1:3107/__preview/login. This uses a temporary database and simulated Discord delivery, with no external posts.
Run `bun scripts/share-card-preview.ts` for the visual fixtures at http://127.0.0.1:3108. These cover long names, six calendars, privacy and no scheduled days at 320px, with a 390px iframe for mobile modal checks. Rendering tests cover date geometry, leap days, original shades, days off and whole-period totals.

View File

@@ -1,5 +1,5 @@
{
"name": "minabot",
"name": "mina-habits",
"version": "0.1.0",
"private": true,
"type": "module",

View File

@@ -6,7 +6,8 @@ import { createApi } from "../src/api";
import index from "../src/index.html";
const f = fixture();
const origin = "http://127.0.0.1:3107";
const port = Number(process.env.PREVIEW_PORT ?? 3107);
const origin = `http://127.0.0.1:${port}`;
const previewCookie = "minabot_dashboard_preview_session";
const sharingPreview = process.env.SHARE_PREVIEW === "1";
let sharedImage: Blob | null = null;
@@ -52,7 +53,7 @@ if (sharingPreview) setInterval(async () => {
}, 1000).unref();
const server = Bun.serve({
hostname: "127.0.0.1",
port: 3107,
port,
routes: {
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),

View File

@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sharing visual QA</title></head><body><div id="root"></div><script type="module" src="./share-card-preview.tsx"></script></body></html>

View File

@@ -0,0 +1,5 @@
// Run with: bun scripts/share-card-preview.ts
// Optional mobile iframe: SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts
import page from "./share-card-preview.html";
const server = Bun.serve({ hostname: "127.0.0.1", port: 3108, routes: { "/*": page }, development: true });
console.log(`Sharing visual fixtures: ${server.url}`);

View File

@@ -0,0 +1,30 @@
// Disposable visual fixtures: no user data and no outbound Discord calls.
import { ThemeProvider, ThemeControl, useTheme } from "../src/components/ThemeProvider";
import { createRoot } from "react-dom/client";
import { useEffect, useState } from "react";
import { addDays } from "../src/habits/calendar";
import { renderProgressCard } from "../src/lib/progress-card";
import type { ShareData } from "../src/sharing/contracts";
import "../styles/globals.css";
const names = ["A few pages, every day", "An afternoon walk", "A very long habit name that should truncate gracefully without hitting the count", "Drink enough water", "练习中文 · 日本語", "Make time to unwind"];
const colors = ["#90647e", "#397f76", "#ba793c", "#4b70b4", "#834f9e", "#bd5863"];
function Preview({ count, empty = false, privateCard = false }: { count: number; empty?: boolean; privateCard?: boolean }) {
const { theme } = useTheme();
const [url, setUrl] = useState("");
useEffect(() => {
let disposed = false, objectUrl = "";
const total = count === 6 ? 365 : 30;
const from = addDays("2026-09-04", 1 - total);
const data: ShareData = { from, to: "2026-09-04", timezone: "America/Argentina/Buenos_Aires", habits: names.slice(0, count).map((name, i) => {
const days = Array.from({length: total}, (_, n) => { const due = !empty && (n + i) % 7 !== 0; const ratio = due ? (n % 4 === 0 ? 0 : n % 3 === 0 ? .5 : 1) : null; return { date: addDays(from, n), due, complete: ratio === 1, ratio, color: ratio === 1 ? colors[i]! : ratio ? "#c7b0bf" : "#e4e7e0" }; });
return { name, color: colors[i]!, completed: days.filter(d => d.complete).length, scheduled: days.filter(d => d.due).length, days, legend: { empty: "#e4e7e0", partial: ["#c7b0bf"], complete: colors[i]! } };
}) };
void renderProgressCard(data, { id: "qa", discordId: "qa", username: "alice", displayName: "Alexandra with a very long display name", avatarUrl: null, timezone: data.timezone }, { name: !privateCard, avatar: !privateCard, habitNames: !privateCard, timezone: privateCard }, theme).then(result => {
if (disposed) return; objectUrl = URL.createObjectURL(result.blob); setUrl(objectUrl);
});
return () => { disposed = true; if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [theme]);
return <section style={{ background: "#313338", padding: 20, borderRadius: 8, width: 360 }}><h2 style={{ color: "white", marginBottom: 16 }}>{count} {count === 1 ? "habit" : "habits"}{empty ? " · no schedule" : ""}{privateCard ? " · private" : ""}</h2>{url && <img alt="QA progress card at 320 pixels" src={url} style={{ width: 320, display: "block" }} />}</section>;
}
createRoot(document.getElementById("root")!).render(<ThemeProvider><main className="ds-root" style={{ padding: 24 }}><ThemeControl /><h1>Card readability at 320px</h1><div style={{ display: "flex", flexWrap: "wrap", alignItems: "start", gap: 24, marginTop: 24 }}><Preview count={1}/><Preview count={6}/><Preview count={6} privateCard/><Preview count={1} empty/></div><h2 style={{ margin: "24px 0" }}>390 × 844 mobile composer</h2><iframe title="Mobile dashboard" src="http://127.0.0.1:3107/__preview/login" style={{ width: 390, height: 844, border: "1px solid #aaa" }}/></main></ThemeProvider>);

View File

@@ -184,6 +184,30 @@ describe("homepage", () => {
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
});
test("archive permanent deletion supports cancel, failure, retry, and removal", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Discard me", method: "manual" }, 201);
await f.request(`/habits/${habit.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
await act(async () => buttonNamed("Delete permanently Discard me").click());
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
await act(async () => buttonNamed("Keep habit").click());
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => buttonNamed("Delete permanently Discard me").click());
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
await act(async () => confirm().click());
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("Temporary failure");
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => confirm().click());
expect(container.querySelector("#delete-archived-habit")).toBeNull();
expect(container.textContent).toContain("Discard me permanently deleted.");
expect(container.textContent).toContain("No archived habits.");
expect((await f.request(`/habits/${habit.id}`)).status).toBe(404);
});
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
@@ -191,10 +215,8 @@ describe("homepage", () => {
f.setTime("2026-09-04T12:00:00Z");
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
await render("/");
expect(container.querySelector(".ds-calendar")).toBeNull();
expect(buttonNamed("View history for Reading").getAttribute("aria-expanded")).toBe("false");
await act(async () => buttonNamed("View history for Reading").click());
expect(buttonNamed("Hide history for Reading").getAttribute("aria-expanded")).toBe("true");
expect(container.querySelector(".ds-goal-graph .ds-calendar")?.getAttribute("data-months")).toBe("3");
expect(container.querySelector(".ds-goal-graph [hidden]")).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
await act(async () => buttonNamed("Edit selected check-in").click());
@@ -279,17 +301,18 @@ describe("homepage", () => {
const countGoal = container.querySelector(".ds-goal--count")!;
expect(countGoal.querySelector('.ds-goal-count input[aria-label="Total Water"]')).not.toBeNull();
expect(countGoal.querySelector(".ds-goal-unit")?.textContent).toBe("glasses");
expect(countGoal.textContent).not.toContain("Count target");
expect(countGoal.textContent).toContain("Count target");
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("In progress");
const checkIn = container.querySelector<HTMLInputElement>('.ds-goal--manual input[type="checkbox"]')!;
expect(container.querySelector('.ds-goal--manual h3 label')?.getAttribute("for")).toBe(checkIn.id);
expect(container.querySelector('.ds-goal--manual .ds-goal-check-in > label[for]')?.getAttribute("for")).toBe(checkIn.id);
expect(checkIn.getAttribute("aria-label")).toBe("Reading done");
expect(container.querySelector('.ds-goal--tasks details')).toBeNull();
expect(container.querySelector('.ds-goal--tasks .ds-goal-tasks details')).toBeNull();
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)).toBeNull();
await act(async () => buttonNamed("View history for Water").click());
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
expect(container.textContent).not.toContain("Sunday walk");
await act(async () => buttonNamed("All habits · 4").click());
@@ -469,7 +492,7 @@ describe("homepage", () => {
await selectMethod("tasks");
await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
await act(async () => buttonNamed("Add task +").click());
await act(async () => buttonNamed("Add task").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -560,11 +583,11 @@ test("unscheduled dates stay inspectable without a progress-square fill", async
);
expect(
container.querySelector<HTMLElement>(".ds-day--due")!.style.backgroundColor,
).toBe("#ebedf0");
).toBe("var(--ds-empty)");
expect(
container.querySelector<HTMLElement>(".ds-day--future")!.style
.backgroundColor,
).toBe("#ebedf0");
).toBe("var(--ds-empty)");
expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
await act(async () => restDay.click());
expect(restDay.getAttribute("aria-pressed")).toBe("true");

View File

@@ -3,9 +3,11 @@ import { DesignSystem } from "./pages/DesignSystem";
import { Home } from "./pages/Home";
import { AuthProvider } from "./components/AuthProvider";
import { ThemeProvider } from "./components/ThemeProvider";
export function App() {
return (
<Routes>
<ThemeProvider><Routes>
<Route path="/design-system" element={<DesignSystem />} />
<Route
path="/"
@@ -16,6 +18,6 @@ export function App() {
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Routes></ThemeProvider>
);
}

View File

@@ -29,7 +29,7 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
discordDeliveries: tx.select().from(schema.discordDeliveries).where(eq(schema.discordDeliveries.userId, user.id)).all(),
};
});
c.header("Content-Disposition", 'attachment; filename="minabot-export.json"');
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
return c.json(data);
});
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,3 +1,4 @@
import { ThemeControl } from "./ThemeProvider";
import { ReminderSettings } from "./ReminderSettings";
import { useId, useRef, useState } from "react";
import type { PublicUser } from "../shared/user";
@@ -24,6 +25,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
finally { saving.current = false; setBusy(false); }
}
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
<SettingRow title="Appearance" description={<p>Follow your device or choose a theme for this browser.</p>}><ThemeControl /></SettingRow>
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
<Field label="Timezone">
@@ -35,7 +37,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
</SettingRow>
<ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
<SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="mina-habits-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
</SettingRow>
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy || reminderBusy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :

View File

@@ -1,14 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { Button } from "./design-system/primitives";
import { Archive, ChevronDown, RotateCcw } from "lucide-react";
import { Archive, ChevronDown, RotateCcw, Trash2 } from "lucide-react";
import { FormMessage, WorkspacePanel } from "./design-system/WorkspacePanel";
import { Modal } from "./design-system/Modal";
import { HabitHistory } from "./HabitHistory";
export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }: {
date: string; revision: number; onChanged: () => void; onExpired: () => void; onClose?: () => void;
}) {
const [habits, setHabits] = useState<TodayHabit[]>([]);
const [deleting, setDeleting] = useState<TodayHabit | null>(null);
const [deleteError, setDeleteError] = useState("");
const archiveFocus = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState<string>();
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
@@ -37,11 +41,26 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
setError(message); if (message.includes("session has expired")) expired.current();
} finally { saving.current = false; setBusy(false); }
}
async function permanentlyDelete(habit: TodayHabit) {
if (saving.current) return;
saving.current = true; setBusy(true); setDeleteError(""); setNotice("");
try {
await habitRequest(`/habits/${habit.habitId}/permanent`, { method: "DELETE" });
setHabits(current => current.filter(item => item.habitId !== habit.habitId));
setSelected(current => current === habit.habitId ? undefined : current);
setDeleting(null);
setNotice(`${habit.name} permanently deleted.`);
onChanged();
} catch (error) {
const message = error instanceof Error ? error.message : "Could not delete. Try again.";
setDeleteError(message); if (message.includes("session has expired")) expired.current();
} finally { saving.current = false; setBusy(false); }
}
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever youre ready." onClose={onClose} busy={busy} closeLabel="Close archive">
<div ref={archiveFocus} tabIndex={-1} aria-label="Archive status"><FormMessage>{notice}</FormMessage></div>
<FormMessage error>{error}</FormMessage>
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
{loading ? <p className="ds-supporting-copy" role="status">Loading archive</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}
<FormMessage>{notice}</FormMessage>
<div className="ds-record-list">
{habits.map(habit => <article key={habit.habitId} className="ds-record" aria-labelledby={`archive-title-${habit.habitId}`}>
<header className="ds-record-heading">
@@ -49,10 +68,18 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
<div className="ds-action-row">
<Button variant="text" onClick={() => setSelected(selected === habit.habitId ? undefined : habit.habitId)} aria-expanded={selected === habit.habitId} aria-controls={selected === habit.habitId ? `archive-history-${habit.habitId}` : undefined}>{selected === habit.habitId ? "Hide history" : "View history"}<ChevronDown className="ds-disclosure-icon" size={16} aria-hidden="true" /></Button>
<Button variant="secondary" disabled={busy || loading} aria-label={`Restore ${habit.name}`} onClick={() => void restore(habit)}><RotateCcw size={16} aria-hidden="true" />Restore</Button>
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
</div>
</header>
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
</article>)}
</div>
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>
<FormMessage error>{deleteError}</FormMessage>
<div className="ds-action-row">
<Button variant="secondary" data-cancel-delete disabled={busy} onClick={() => setDeleting(null)}>Keep habit</Button>
<Button disabled={busy} onClick={() => void permanentlyDelete(deleting)}>{busy ? "Deleting…" : "Delete permanently"}</Button>
</div>
</Modal>}
</WorkspacePanel>;
}

View File

@@ -1,5 +1,5 @@
import { Modal } from "./design-system/Modal";
import { ChevronDown, Pencil } from "lucide-react";
import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
@@ -41,7 +41,6 @@ export function HabitHistory({
calendarOnly?: boolean;
}) {
const [selectedDate, setSelectedDate] = useState<string>();
const [historyExpanded, setHistoryExpanded] = useState(false);
const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
@@ -123,6 +122,7 @@ export function HabitHistory({
onSelectDate={setSelectedDate}
selectedDate={selectedDate}
compact
initialMonths={3}
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
unit={habit.unit}
@@ -167,28 +167,7 @@ export function HabitHistory({
target={habit.target ?? 0}
unit={habit.unit}
color={calendar?.settings.mainColor ?? "#196127"}
calendar={
<div className="ds-habit-history">
<Button
variant="text"
className="ds-habit-history-toggle"
aria-expanded={historyExpanded}
aria-controls={`history-panel-${habit.habitId}`}
aria-label={`${historyExpanded ? "Hide" : "View"} history for ${habit.name ?? "habit"}`}
onClick={() => setHistoryExpanded((value) => !value)}
>
<ChevronDown size={16} aria-hidden="true" />
{historyExpanded ? "Hide history" : "View history"}
</Button>
<div
id={`history-panel-${habit.habitId}`}
className="ds-habit-history-panel"
hidden={!historyExpanded}
>
{historyExpanded && chart}
</div>
</div>
}
calendar={chart}
tasks={tasks}
tasksLabel="Tasks"
headingLevel={3}

View File

@@ -1,16 +1,19 @@
import { ThemeControl, useTheme } from "./ThemeProvider";
import { Modal } from "./design-system/Modal";
import { useEffect, useRef, useState } from "react";
import { Download, Send } from "lucide-react";
import { Check, ChevronDown, Download, Hash, ImageIcon, Send, ShieldCheck } from "lucide-react";
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
import { habitRequest, type TodayResponse } from "../lib/dashboard";
import { addDays } from "../habits/calendar";
import { addDays, dayNumber } from "../habits/calendar";
import { renderProgressCard, type CardPrivacy } from "../lib/progress-card";
import type { PublicUser } from "../shared/user";
import type { Delivery, DiscordConnection, ShareData } from "../sharing/contracts";
export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) {
const { theme } = useTheme();
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
const [thumbnail, setThumbnail] = useState(true);
const [range, setRange] = useState("30");
const [from, setFrom] = useState(addDays(today.date, -29));
const [to, setTo] = useState(today.date);
@@ -26,7 +29,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
const [sendError, setSendError] = useState("");
const lock = useRef(false);
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
const key = JSON.stringify({ ids, from, to, privacy, revision, user, theme });
const ready = card?.key === key ? card : null;
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
const busy = sending;
@@ -45,10 +48,11 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
setError(""); setSendError("");
const timer = setTimeout(async () => {
if (!ids.length) { setError("Choose at least one habit for your card."); return; }
if (!from || !to || from > to || to > today.date || dayNumber(to) - dayNumber(from) > 365) { setError("Choose a date range of up to 366 days, ending today or earlier."); return; }
try {
const data = await habitRequest<ShareData>("/sharing/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ habitIds: ids, from, to }), signal: controller.signal });
if (controller.signal.aborted) return;
const image = await renderProgressCard(data, user, privacy);
const image = await renderProgressCard(data, user, privacy, theme);
const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await image.blob.arrayBuffer())), byte => byte.toString(16).padStart(2, "0")).join("");
if (controller.signal.aborted) return;
objectUrl = URL.createObjectURL(image.blob);
@@ -63,41 +67,55 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
async function send() {
if (!ready || !connection?.connected || lock.current || sent) return;
lock.current = true; setSending(true); setSendError("");
const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "minabot-progress.png");
const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "mina-habits-progress.png");
try { const result = await habitRequest<Delivery>("/sharing/discord/send", { method: "POST", body: form }); setDelivery({ ...result, id: ready.deliveryId }); }
catch (e) { setSendError(e instanceof Error ? e.message : "Could not send. Retrying this card will not post it twice."); }
finally { lock.current = false; setSending(false); }
}
return (
<Modal id="share-progress" eyebrow="A LITTLE PROGRESS, WORTH SHARING" title="Your progress, in a picture." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<Modal id="share-progress" eyebrow="SHARE A LITTLE PROGRESS" title="Made for your people." description="Choose what to include. See exactly what youll share." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<div className="ds-share-layout">
<section className="ds-share-preview-column" aria-label="Card preview">
<div className="ds-share-preview-heading"><span><ImageIcon size={16} aria-hidden="true" /> Your card</span>
<div className="ds-share-size" role="group" aria-label="Preview size"><button type="button" aria-pressed={thumbnail} onClick={() => setThumbnail(true)}>Thumbnail</button><button type="button" aria-pressed={!thumbnail} onClick={() => setThumbnail(false)}>Larger</button></div>
</div>
<div className={`ds-share-preview${thumbnail ? " ds-share-preview--thumbnail" : ""}`} aria-busy={!ready && !error}>
{error ? <div className="ds-share-placeholder" role="alert"><ImageIcon size={28} aria-hidden="true" /><p>{error}</p>{ids.length > 0 && <Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button>}</div> : ready ? <img src={ready.url} alt={ready.alt} /> : <div className="ds-share-placeholder" role="status"><ImageIcon size={28} aria-hidden="true" /><p>Creating your card</p></div>}
</div>
<p className="ds-share-caption">{thumbnail ? "A small-size preview, like an image in chat." : "A closer look at your card."} Same image in Discord and your download.</p>
{ready?.avatarMissing && <p role="status" className="ds-share-caption">Your avatar couldnt load. A neutral profile icon is used.</p>}
</section>
<div className="ds-share-controls">
<ThemeControl disabled={busy} />
<fieldset className="ds-share-fieldset" disabled={busy}>
<legend className="type-ui-heading">Choose your habits</legend>
<p className="ds-muted type-small">Up to six per card. Task names are never included.</p>
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
<Field label="Date range">{id => <select id={id} value={range} onChange={e => { const value = e.target.value; setRange(value); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}><option value="7">Past 7 days</option><option value="30">Past 30 days</option><option value="365">Past year</option><option value="custom">Custom dates</option></select>}</Field>
<legend>Time period</legend>
<div className="ds-share-periods" role="group" aria-label="Date range">{[["7", "7 days"], ["30", "30 days"], ["365", "1 year"], ["custom", "Custom"]].map(([value, label]) => <button key={value} type="button" aria-pressed={range === value} onClick={() => { setRange(value!); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}>{label}</button>)}</div>
{range === "custom" && <div className="ds-share-dates"><Field label="From">{id => <input id={id} type="date" value={from} max={to || today.date} min="1970-01-01" onChange={e => setFrom(e.target.value)} />}</Field><Field label="To">{id => <input id={id} type="date" value={to} min={from} max={today.date} onChange={e => setTo(e.target.value)} />}</Field></div>}
</fieldset>
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="type-ui-heading">Show on the card</legend>
<fieldset className="ds-share-fieldset" disabled={busy}>
<legend>Habits <span className="ds-share-count">{ids.length} / 6</span></legend>
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
<p className="ds-share-hint">Choose up to six. Task names stay private.</p>
</fieldset>
<details className="ds-share-privacy"><summary><ShieldCheck size={17} aria-hidden="true" /><span>Privacy & details</span><ChevronDown size={16} aria-hidden="true" /></summary>
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="ds-visually-hidden">Show on the card</legend>
{([["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"]] as const).map(([key, label]) => <Checkbox key={key} label={label} checked={privacy[key]} onChange={e => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)}
</fieldset>
</details>
<div className="ds-share-destination"><span className="ds-share-destination-icon"><Hash size={20} aria-hidden="true" /></span><div><span className="ds-share-hint">Discord destination</span>
{connectionError ? <div role="alert"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry connection</Button></div> : connection?.connected ? <><a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a><p className="ds-share-hint">Posted by the Mina Habits bot.</p></> : <p className="ds-share-hint" role="status">{connection?.message ?? "Loading channel…"}</p>}
</div></div>
</div>
<div className="ds-share-preview-column">
<div className="ds-share-preview" aria-busy={!ready && !error}>
{error ? <div role="alert"><p>{error}</p><Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button></div> : ready ? <img src={ready.url} alt={ready.alt} /> : <p role="status">Creating your card</p>}
</div>
<p className="type-small ds-muted">This exact image will be downloaded or sent. Days off are excluded from the completion rate.</p>
{ready?.avatarMissing && <p role="status" className="type-small ds-muted">Your avatar couldnt load. The card uses a neutral profile icon.</p>}
<footer className="ds-share-footer">
<div className="ds-share-footer-copy" aria-live="polite">
{sendError ? <p role="alert" className="ds-share-error">{sendError}</p> : sent ? <p>{sent.status === "sent" ? <>Shared successfully. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord </a>}</> : <>Delivery unconfirmed. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"}; this attempt wont be resent.</>}</p> : <p>{sending ? "Sending your card to Discord…" : error ? "Check your card settings." : ready ? "Ready when you are." : "Your card is taking shape."}<span>{sending ? "This may take a moment." : "Only the details in your preview are shared."}</span></p>}
</div>
<div className="ds-share-actions">
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`minabot-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download PNG</a> : <Button variant="secondary" disabled>Download PNG</Button>}
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}><Send size={17} aria-hidden="true" />{sending ? "Sending…" : sent?.status === "sent" ? "Sent to Discord" : sent ? "Check Discord" : "Send to Discord"}</Button>
</div>
{connectionError ? <div role="alert" className="ds-share-feedback"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry Discord</Button></div> : connection?.connected ? <p className="type-small ds-muted">The bot will post this card to <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a>.</p> : <p className="type-small ds-muted" role="status">{connection?.message ?? "Loading the Discord sharing channel…"}</p>}
{sendError && <p role="alert">{sendError}</p>}
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord </a>}</> : <>Discord didnt confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt wont be resent.</>}</p>}
</div>
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`mina-habits-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download</a> : <Button variant="secondary" disabled><Download size={17} aria-hidden="true" />Download</Button>}
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}>{sent?.status === "sent" ? <Check size={17} aria-hidden="true" /> : <Send size={17} aria-hidden="true" />}{sending ? "Sending…" : sent?.status === "sent" ? "Shared" : sent ? "Check Discord" : "Send to Discord"}</Button>
</div>
</footer>
</Modal>
);
}

View File

@@ -0,0 +1,47 @@
import { Monitor, Sun, Moon } from "lucide-react";
import { createContext, useContext, useEffect, useLayoutEffect, useId, useState, type ReactNode } from "react";
import { resolveTheme, THEME_STORAGE_KEY, themePreference, type Theme, type ThemePreference } from "../lib/theme";
const ThemeContext = createContext<{ theme: Theme; preference: ThemePreference; setPreference: (value: ThemePreference) => void }>({ theme: "light", preference: "system", setPreference: () => {} });
export const useTheme = () => useContext(ThemeContext);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [preference, setValue] = useState<ThemePreference>(() => {
try { return themePreference(localStorage.getItem(THEME_STORAGE_KEY)); } catch { return "system"; }
});
const [systemDark, setSystemDark] = useState(() => typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches);
const theme = resolveTheme(preference, systemDark);
useLayoutEffect(() => {
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
}, [theme]);
useEffect(() => {
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
const change = () => setSystemDark(media?.matches ?? false);
const storage = (event: StorageEvent) => { if (event.key === THEME_STORAGE_KEY || event.key === null) setValue(themePreference(event.newValue)); };
change(); media?.addEventListener("change", change); window.addEventListener("storage", storage);
return () => { media?.removeEventListener("change", change); window.removeEventListener("storage", storage); };
}, []);
const setPreference = (value: ThemePreference) => {
setValue(value);
try { localStorage.setItem(THEME_STORAGE_KEY, value); } catch { /* Theme still works when browser storage is unavailable. */ }
};
return <ThemeContext value={{ theme, preference, setPreference }}>{children}</ThemeContext>;
}
export function ThemeControl({ disabled = false }: { disabled?: boolean }) {
const { preference, setPreference } = useTheme();
const id = useId();
return (
<div className="ds-theme-control" role="group" aria-label="Theme">
{([
{ value: "system", label: "System", Icon: Monitor },
{ value: "light", label: "Light", Icon: Sun },
{ value: "dark", label: "Dark", Icon: Moon },
] as const).map(({ value, label, Icon }) => (
<label className="ds-theme-option" key={value}>
<input type="radio" name={`theme-${id}`} value={value} checked={preference === value} disabled={disabled} onChange={() => setPreference(value)} />
<span><Icon size={16} strokeWidth={1.75} aria-hidden="true" />{label}</span>
</label>
))}
</div>
);
}

View File

@@ -1,12 +1,13 @@
import { useTheme } from "../ThemeProvider";
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { CalendarLegend } from "./CalendarLegend";
import { Field } from "./Field";
import { Button } from "./primitives";
import { CalendarDays, Keyboard } from "lucide-react";
import { CalendarDays } from "lucide-react";
import {
describeDay,
calendarTimeline,
progressShade,
MONTH_VIEWS,
visibleCalendarDays,
type CalendarDay,
@@ -18,8 +19,8 @@ export function CalendarHeatmap({
unit,
label,
color = "#111111",
emptyColor = "#eeeeee",
compact = false,
initialMonths = 12,
selectedDate: controlledDate,
onSelectDate,
historyLabel,
@@ -32,13 +33,15 @@ export function CalendarHeatmap({
color?: string;
emptyColor?: string;
compact?: boolean;
initialMonths?: MonthView;
selectedDate?: string;
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
selectedDayAction?: (date: string) => ReactNode;
}) {
const [months, setMonths] = useState<MonthView | "custom">(12);
const { theme } = useTheme();
const [months, setMonths] = useState<MonthView | "custom">(initialMonths);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
const [rangeError, setRangeError] = useState("");
const latestDate =
@@ -236,8 +239,8 @@ export function CalendarHeatmap({
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
? "var(--ds-empty)"
: day.value === 0 ? "var(--ds-empty)" : day.color ? themedHabitShade(day.color, color, theme) : habitProgressColor(color, (Math.max(1, Math.min(8, Math.round(day.value / day.target * 8))) - 1) / 7, theme),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
@@ -274,27 +277,14 @@ export function CalendarHeatmap({
</div>
</div>
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
<div className="ds-calendar-caption">
<span>
{months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
<span className="ds-muted">
/{" "}
{historyLabel ??
(compact ? "Demo history" : "Illustrative history")}
</span>
</span>
<details className="ds-calendar-help">
<summary><Keyboard size={16} aria-hidden="true" /> Keyboard shortcuts</summary>
<p id={instructionsId}>
<p id={instructionsId} className="ds-visually-hidden">
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</p>
</details>
</div>
{!historyLabel && <p className="ds-calendar-demo-label ds-muted">Demo history</p>}
<div id={inspectorId} className="ds-date-inspector">
<div className="ds-date-inspector-heading" aria-live="polite">
<CalendarDays size={22} aria-hidden="true" />
<div>
<span className="ds-eyebrow">SELECTED DAY</span>
<time dateTime={selected.date}>
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
month: "long",

View File

@@ -1,3 +1,5 @@
import { useTheme } from "../ThemeProvider";
import { themedHabitShade } from "../../lib/habit-palette";
import { legendShades, type CalendarDay } from "./calendar-model";
export function CalendarLegend({
@@ -11,7 +13,8 @@ export function CalendarLegend({
label: string;
shades?: string[];
}) {
const shades = suppliedShades ?? legendShades(days, color);
const { theme } = useTheme();
const shades = suppliedShades ?? legendShades(days, color, theme);
return (
<div
className="ds-calendar-legend"
@@ -31,11 +34,11 @@ export function CalendarLegend({
: "Empty"
}
>
{shades.map((shade) => (
{shades.map((shade, index) => (
<span
key={shade}
className="ds-legend-swatch"
style={{ backgroundColor: shade }}
style={{ backgroundColor: index === 0 ? "var(--ds-empty)" : suppliedShades ? themedHabitShade(shade, color, theme) : shade }}
/>
))}
</span>

View File

@@ -1,9 +1,9 @@
import { useId, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, ListChecks } from "lucide-react";
import { Check, Circle, Hash, ChevronDown, ListChecks } from "lucide-react";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { demoCalendar } from "./calendar-model";
/** Completion controls define each goal surface; history stays a separate full-width disclosure. */
/** A shared habit identity, visible history, and goal-specific completion surface. */
export function HabitChart({
name,
method,
@@ -45,30 +45,35 @@ export function HabitChart({
}) {
const headingId = useId();
const Heading = `h${headingLevel}` as const;
const complete = due && target > 0 && value >= target;
const TypeIcon = trackingMethod === "count" ? Hash : trackingMethod === "tasks" ? ListChecks : Check;
if (trackingMethod) return (
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color, "--habit-tint": /^#[0-9a-f]{6}$/i.test(color) ? `${color}0d` : "var(--ds-surface)" } as CSSProperties}>
<div className="ds-goal-body">
<div className="ds-goal-overview">
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-goal-heading">
<div className="ds-goal-title">
{trackingMethod === "manual" && children}
<Heading id={headingId} className="type-title">
{trackingMethod === "manual" ? <label htmlFor={checkInId}>{name}</label> : name}
</Heading>
<div className="ds-goal-identity">
<div className="ds-goal-meta"><span className="ds-goal-swatch" aria-hidden="true" /><TypeIcon size={16} aria-hidden="true" /><span>{method}</span>{schedule !== "Every day" && <span className="ds-goal-schedule">{schedule}</span>}</div>
<Heading id={headingId} className="type-title">{name}</Heading>
</div>
<div className="ds-goal-heading-actions">
<span className="ds-goal-status" role="status">{complete ? <Check size={16} aria-hidden="true" /> : <Circle size={14} aria-hidden="true" />}{!due ? "Day off" : complete ? "Complete" : value > 0 ? "In progress" : "Not started"}</span>
{headingActions}
</div>
</header>
{(!due || schedule !== "Every day") && <p className="ds-goal-schedule">{due ? schedule : `Not scheduled today · ${schedule}`}</p>}
{trackingMethod === "tasks" && due && target > 0 && <div className="ds-goal-task-progress">
<progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={target} />
<span role="status">{value} / {target}</span>
</div>}
</div>
{trackingMethod === "count" && <div className="ds-goal-count">{children}<span className="ds-goal-unit">{unit}</span></div>}
{editor}
{trackingMethod === "tasks" && <div className="ds-goal-tasks">{tasks}</div>}
<div className="ds-goal-body">
<div className="ds-goal-completion">
{trackingMethod === "count" && <div className="ds-goal-count">
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
{children}
</div>}
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
{trackingMethod === "tasks" && <>
<div className="ds-goal-task-progress"><span>{value} of {target} tasks done</span><progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} /></div>
<div className="ds-goal-tasks" role="group" aria-label={`${name}: ${tasksLabel}`}>{tasks}</div>
</>}
</div>
<div className="ds-goal-graph">{calendar ?? <CalendarHeatmap days={demoCalendar(value, target)} label={`${name} progress calendar`} unit={unit} color={color} compact />}</div>
</div>
{calendar}
</section>
);

View File

@@ -1,5 +1,7 @@
import { useTheme } from "../ThemeProvider";
import { habitProgressColor } from "../../lib/habit-palette";
import { useId, useState } from "react";
import { HABIT_COLORS, progressShade } from "./calendar-model";
import { HABIT_COLORS } from "./calendar-model";
import { Field } from "./Field";
export type HabitColors = {
@@ -47,6 +49,7 @@ export function HabitColorPicker({
mode?: "demo" | "create" | "edit";
compact?: boolean;
}) {
const { theme } = useTheme();
const id = useId();
const [expanded, setExpanded] = useState(false);
const showDetails = !compact || expanded;
@@ -55,14 +58,14 @@ export function HabitColorPicker({
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className={showDetails ? "ds-footnote" : "ds-visually-hidden"}>
Custom colors use six-digit hex values, such as #426582.
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
</p>
{compact && (
<>
<div className="ds-color-swatches ds-color-swatches--compact" role="group" aria-label="Suggested colors">
{HABIT_PALETTES.map((palette) => (
<button key={palette.name} type="button" aria-label={`${palette.name} ${palette.colors[0]}`} aria-pressed={value.toLowerCase() === palette.colors[0]} title={palette.name} onClick={() => onChange(palette.colors[0])}>
<span style={{ backgroundColor: palette.colors[0] }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
<span style={{ backgroundColor: habitProgressColor(palette.colors[0], 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
</button>
))}
</div>
@@ -89,7 +92,7 @@ export function HabitColorPicker({
title={color}
onClick={() => onChange(color)}
>
<span style={{ backgroundColor: color }}>
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
@@ -144,10 +147,7 @@ export function HabitColorPicker({
<span
key={count}
style={{
backgroundColor: progressShade(
{ date: "", value: count, target: 8, state: "due" },
value,
),
backgroundColor: count === 0 ? "var(--ds-empty)" : habitProgressColor(value, (count - 1) / 7, theme),
}}
/>
))}

View File

@@ -53,7 +53,7 @@ export function Modal({ id, eyebrow, title, description, children, onClose, clos
onKeyDown={event => {
if (event.key !== "Tab") return;
event.stopPropagation();
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')]
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, summary, [tabindex]')]
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
const first = controls[0];
const last = controls.at(-1);

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import { ThemeControl } from "../ThemeProvider";
import { Button } from "./primitives";
/** The existing design-system page shell, shared without page-specific styling. */
@@ -28,14 +29,14 @@ export function PageLayout({
</a>
<header className="ds-header">
<Link className="ds-wordmark" to={wordmarkTo}>
minabot<span aria-hidden="true">.</span>
Mina Habits<span aria-hidden="true">.</span>
</Link>
{header}
<div className="ds-header-actions">{header}<ThemeControl /></div>
</header>
<main id={mainId} className="ds-main" tabIndex={-1}>
{children}
<footer className="ds-footer">
<span className="ds-wordmark">minabot.</span>
<span className="ds-wordmark">Mina Habits.</span>
<span>A little, every day.</span>
<Button
variant="text"

View File

@@ -46,7 +46,6 @@ export function WelcomePanel({
<div className="ds-welcome-identity">
{avatar}
<div>
<p className="ds-eyebrow">YOUR DAILY CHECK-IN</p>
<h1 id="dashboard-title" className="type-title">Welcome back, <em>{name}.</em></h1>
</div>
</div>

View File

@@ -231,7 +231,7 @@ test("custom timeframe applies inclusive dates, supports single days and survive
expect(container.querySelectorAll(".ds-day")).toHaveLength(2);
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Aug 31, 2026");
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("2 days");
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Sep 1, 2026");
expect(container.querySelectorAll('.ds-day[tabindex="0"]')).toHaveLength(1);
await act(async () => button("3m").click());
expect(container.querySelector("form")).toBeNull();
@@ -242,7 +242,7 @@ test("custom timeframe applies inclusive dates, supports single days and survive
setDates("2026-09-04", "2026-09-04");
await act(async () => button("Apply range").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("1 day ");
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Sep 4, 2026");
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain("September 4, 2026");
});

View File

@@ -1,3 +1,5 @@
import { habitProgressColor } from "../../lib/habit-palette";
import type { Theme } from "../../lib/theme";
export const PROGRESS_SHADES = [
"#eeeeee",
"#d8d8d8",
@@ -103,7 +105,7 @@ export function describeDay(day: CalendarDay, unit: string) {
}
/** Only show shades that the chart's completion targets can produce. */
export function legendShades(days: CalendarDay[], color = "#111111") {
export function legendShades(days: CalendarDay[], color = "#111111", theme?: Theme) {
const levels = new Set<number>();
for (const target of new Set(days.map((day) => day.target))) {
if (!Number.isFinite(target) || target <= 0) continue;
@@ -118,7 +120,7 @@ export function legendShades(days: CalendarDay[], color = "#111111") {
...[...levels]
.sort((a, b) => a - b)
.map((value) =>
progressShade({ date: "", value, target: 8, state: "due" }, color),
theme ? habitProgressColor(color, (value - 1) / 7, theme) : progressShade({ date: "", value, target: 8, state: "due" }, color),
),
];
}

View File

@@ -39,14 +39,14 @@ export function SectionHeading({
children,
id,
}: {
number: string;
number?: string;
title: ReactNode;
children?: ReactNode;
id?: string;
}) {
return (
<header className="ds-section-heading">
<span className="ds-eyebrow">{number}</span>
{number && <span className="ds-eyebrow">{number}</span>}
<h2 id={id} tabIndex={-1}>{title}</h2>
{children && <p>{children}</p>}
</header>

View File

@@ -0,0 +1,50 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); });
afterEach(() => f.close());
async function seeded() {
const habit = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', color: '#397f76', tasks: [{ name: 'Stretch' }] }, 201);
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${habit.tasks[0].id}`, 'PUT', { done: true });
const other = await f.json('/habits', 'POST', { name: 'Keep', method: 'manual' }, 201);
const mixed = await f.json('/charts', 'POST', { name: 'Mixed', habitIds: [habit.id, other.id] }, 201);
const solo = await f.json('/charts', 'POST', { name: 'Solo', habitIds: [habit.id] }, 201);
await f.request(`/habits/${habit.id}`, 'DELETE');
return { habit, other, mixed, solo };
}
test('permanent deletion removes all dependent records and repairs combined charts', async () => {
const { habit, other, mixed, solo } = await seeded();
const dayIds = f.sqlite.query('SELECT id FROM habit_days WHERE habit_id = ?').all(habit.id) as { id: number }[];
expect(dayIds.length).toBeGreaterThan(0);
expect(f.sqlite.query('SELECT count(*) AS n FROM progress_events').get()).toEqual({ n: 1 });
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE')).status).toBe(204);
for (const table of ['habit_days', 'habit_revisions', 'habit_calendar_settings']) {
expect(f.sqlite.query(`SELECT count(*) AS n FROM ${table} WHERE habit_id = ?`).get(habit.id)).toEqual({ n: 0 });
}
for (const day of dayIds) for (const table of ['progress_events', 'task_occurrences']) {
expect(f.sqlite.query(`SELECT count(*) AS n FROM ${table} WHERE day_id = ?`).get(day.id)).toEqual({ n: 0 });
}
expect((await f.request(`/habits/${habit.id}/history`)).status).toBe(404);
expect((await f.request(`/habits/${habit.id}`, 'PATCH', { archived: false })).status).toBe(404);
expect((await f.json(`/charts/${mixed.id}`)).habitIds).toEqual([other.id]);
expect((await f.request(`/charts/${solo.id}`)).status).toBe(404);
expect((await f.json('/habits?archived=true')).habits.map((h: { id: string }) => h.id)).toEqual([other.id]);
});
test('deletion requires ownership, same origin, authentication, and an archived habit', async () => {
const { habit, other } = await seeded();
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE', undefined, 'b')).status).toBe(404);
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE', undefined, 'x')).status).toBe(401);
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE', undefined, 'a', { Origin: 'https://elsewhere.example' })).status).toBe(403);
expect((await f.request(`/habits/${other.id}/permanent`, 'DELETE')).status).toBe(409);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
});
test('a failed deletion rolls back history and chart changes', async () => {
const { habit, mixed, solo } = await seeded();
const before = f.sqlite.query('SELECT * FROM progress_events').all();
f.sqlite.exec("CREATE TRIGGER reject_habit_delete BEFORE DELETE ON habits BEGIN SELECT RAISE(ABORT, 'test failure'); END");
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE')).status).toBe(500);
expect(f.sqlite.query('SELECT * FROM progress_events').all()).toEqual(before);
expect((await f.json(`/charts/${mixed.id}`)).habitIds).toContain(habit.id);
expect((await f.request(`/charts/${solo.id}`)).status).toBe(200);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
});

View File

@@ -62,6 +62,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
});
app.get('/habits/:id', c => c.json(service(c).current(id(c))));
app.patch('/habits/:id', c => c.json(service(c).edit(id(c), body(c, habitPatch))));
app.delete('/habits/:id/permanent', c => { service(c).permanentlyDelete(id(c)); return c.body(null, 204); });
app.delete('/habits/:id', c => { service(c).edit(id(c), { archived: true }); return c.body(null, 204); });
app.get('/habits/:id/history', c => c.json({ revisions: service(c).history(id(c)) }));
app.get('/habits/:id/tasks', c => c.json({ tasks: service(c).tasks(id(c)) }));

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, lte, sql } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm';
import type { AppDatabase } from '../auth';
import type { PublicUser } from '../shared/user';
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
@@ -90,6 +90,25 @@ export class HabitService {
return updated;
});
}
permanentlyDelete(id: string) {
this.db.transaction(() => {
if (!this.current(id).archived) throw new ApiError(409, 'Archive the habit before permanently deleting it');
const days = this.db.select({ id: habitDays.id }).from(habitDays).where(eq(habitDays.habitId, id));
this.db.delete(progressEvents).where(inArray(progressEvents.dayId, days)).run();
this.db.delete(taskOccurrences).where(inArray(taskOccurrences.dayId, days)).run();
this.db.delete(habitDays).where(eq(habitDays.habitId, id)).run();
this.db.delete(habitRevisions).where(eq(habitRevisions.habitId, id)).run();
this.db.delete(habitCalendarSettings).where(eq(habitCalendarSettings.habitId, id)).run();
for (const chart of this.db.select().from(combinedCharts).where(eq(combinedCharts.userId, this.user.id)).all()) {
if (!chart.habitIds.includes(id)) continue;
const habitIds = chart.habitIds.filter(habitId => habitId !== id);
if (habitIds.length) this.db.update(combinedCharts).set({ habitIds, updatedAt: this.now }).where(eq(combinedCharts.id, chart.id)).run();
else this.db.delete(combinedCharts).where(eq(combinedCharts.id, chart.id)).run();
}
this.db.delete(habits).where(eq(habits.id, id)).run();
});
this.invalidate(id);
}
revise(id: string, config: HabitConfig) {
this.invalidate(id);
this.owned(id);

View File

@@ -3,8 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Minabot</title>
<meta name="description" content="A quiet place to track your habits and see your progress. Explore Minabots interactive habit calendars and connect with Discord." />
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png" />
<title>Mina Habits</title>
<meta name="description" content="A quiet place to track your habits and see your progress. Explore interactive habit calendars and connect with Discord with Mina Habits." />
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>

View File

@@ -0,0 +1,26 @@
import { expect, test } from "bun:test";
import { habitProgressColor, themedHabitShade } from "./habit-palette";
import { HABIT_PALETTES } from "../components/design-system/HabitColorPicker";
import { shade } from "../habits/calendar";
const luminance = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i+2),16)/255).map(v => v <= .04045 ? v/12.92 : ((v+.055)/1.055)**2.4).reduce((n,v,i) => n+v*[.2126,.7152,.0722][i]!,0);
test("all suggested and extreme custom colors have eight distinct ordered steps in both themes", () => {
for (const base of [...HABIT_PALETTES.flatMap(p=>[...p.colors]), '#ffffff','#000000','#ffff00','#0000ff','#ff0000','#00ff00']) {
for (const theme of ['light','dark'] as const) {
const colors = Array.from({length:8},(_,i)=>habitProgressColor(base,i/7,theme));
expect(new Set(colors).size).toBe(8);
const levels=colors.map(luminance);
for(let i=1;i<8;i++) expect(theme==='dark' ? levels[i]! > levels[i-1]! : levels[i]! < levels[i-1]!).toBe(true);
const empty=luminance(theme==='dark'?'#39413b':'#eeeeee');
expect(theme==='dark' ? levels[0]! > empty : levels[0]! < empty).toBe(true);
}
}
});
test("server shade quantization maps to the same ramp as picker preview, including bright custom colors", () => {
for (const base of ['#426582','#ffffff','#ffff00','#000000']) for (const theme of ['light','dark'] as const) {
const colors=Array.from({length:8},(_,i)=>shade((i+1)/8,8,{mainColor:base} as any,true).color);
const mapped=colors.map(c=>themedHabitShade(c,base,theme));
expect(mapped[0]).toBe(habitProgressColor(base,0,theme));
expect(mapped[7]).toBe(habitProgressColor(base,1,theme));
expect(new Set(mapped).size).toBe(8);
}
});

49
src/lib/habit-palette.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { Theme } from "./theme";
const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255);
const linear = (v: number) => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
const encoded = (v: number) => v <= .0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - .055;
const cache = new Map<string, string>();
/** A hue-preserving OKLab ramp; equal progress steps have equal lightness steps. */
export function habitProgressColor(base: string, intensity: number, theme: Theme): string {
if (!/^#[0-9a-f]{6}$/i.test(base)) base = "#58765b";
const t = Math.max(0, Math.min(1, intensity));
const key = `${base}:${t.toFixed(5)}:${theme}`;
const cached = cache.get(key);
if (cached) return cached;
const [r, g, b] = rgb(base).map(linear) as [number, number, number];
const l = Math.cbrt(.4122214708*r + .5363325363*g + .0514459929*b);
const m = Math.cbrt(.2119034982*r + .6806995451*g + .1073969566*b);
const s = Math.cbrt(.0883024619*r + .2817188376*g + .6299787005*b);
const a = 1.9779984951*l - 2.428592205*m + .4505937099*s;
const bb = .0259040371*l + .7827717662*m - .808675766*s;
const chroma = Math.hypot(a, bb);
const L = theme === "dark" ? .43 + .37*t : .86 - .38*t;
let C = Math.min(chroma, .16) * (.5 + .5*t);
let channels: number[] = [];
// Reduce chroma instead of clipping RGB, preserving hue at the gamut boundary.
for (let attempt = 0; attempt < 40; attempt++, C *= .9) {
const A = chroma > 1e-6 ? a*C/chroma : 0;
const B = chroma > 1e-6 ? bb*C/chroma : 0;
const ll = (L + .3963377774*A + .2158037573*B) ** 3;
const mm = (L - .1055613458*A - .0638541728*B) ** 3;
const ss = (L - .0894841775*A - 1.291485548*B) ** 3;
channels = [4.0767416621*ll - 3.3077115913*mm + .2309699292*ss, -1.2684380046*ll + 2.6097574011*mm - .3413193965*ss, -.0041960863*ll - .7034186147*mm + 1.707614701*ss];
if (channels.every(v => v >= 0 && v <= 1)) break;
}
const result = "#" + channels.map(v => Math.round(Math.max(0, Math.min(1, encoded(v)))*255).toString(16).padStart(2, "0")).join("");
if (cache.size > 4096) cache.clear();
cache.set(key, result);
return result;
}
/** Recover the server's existing step intensity without changing scoring or quantization. */
export function themedHabitShade(shade: string, base: string, theme: Theme): string {
if (!/^#[0-9a-f]{6}$/i.test(shade) || !/^#[0-9a-f]{6}$/i.test(base)) return habitProgressColor(base, 1, theme);
const color = rgb(base), sample = rgb(shade);
const anchor = .2126*color[0]! + .7152*color[1]! + .0722*color[2]! > .75 ? 0 : 1;
const distance = color.reduce((sum, v) => sum + (v-anchor)**2, 0);
const intensity = distance ? color.reduce((sum, v, i) => sum + (sample[i]!-anchor)*(v-anchor), 0)/distance : 1;
return habitProgressColor(base, Math.max(0, (intensity-.2)/.8), theme);
}

View File

@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
import { cardCalendar, cardStats } from "./progress-card";
import type { ShareData } from "../sharing/contracts";
const habit = (days: ShareData["habits"][number]["days"]): ShareData["habits"][number] => ({ name: "Reading", color: "#90647e", completed: days.filter(d => d.complete).length, scheduled: days.filter(d => d.due).length, days, legend: { empty: "#eee", partial: ["#ccc"], complete: "#90647e" } });
const days = [
{ date: "2024-02-28", due: true, complete: true, ratio: 1, color: "#90647e" },
{ date: "2024-02-29", due: true, complete: false, ratio: .5, color: "#ccc" },
{ date: "2024-03-01", due: false, complete: false, ratio: null, color: "#fff" },
{ date: "2024-03-02", due: true, complete: false, ratio: 0, color: "#eee" },
{ date: "2024-03-03", due: true, complete: true, ratio: 1, color: "#90647e" },
];
test("share calendar preserves dashboard week positions, leap days, shades and days off", () => {
const chart = cardCalendar(habit(days));
expect(chart.entries.map(d => [d.column, d.row])).toEqual([[1, 4], [1, 5], [1, 6], [1, 7], [2, 1]]);
expect(chart.entries.map(d => d.day.color)).toEqual(days.map(d => d.color));
expect(chart.entries[2]!.day.state).toBe("not-due");
expect(chart.entries[1]!.day.value).toBe(.5);
});
test("period summary counts the whole selected range, not the last day's completion", () => {
const data: ShareData = { from: days[0]!.date, to: days.at(-1)!.date, timezone: "UTC", habits: [habit(days)] };
expect(cardStats(data)).toEqual({ completed: 2, scheduled: 4, percent: 50 });
expect(cardStats({ ...data, from: data.to, habits: [habit(days.slice(-1))] })).toEqual({ completed: 1, scheduled: 1, percent: 100 });
expect(cardStats({ ...data, habits: [habit([days[2]!])] }).percent).toBeNull();
});

View File

@@ -1,5 +1,8 @@
import { themedHabitShade } from "./habit-palette";
import { themePalette, type Theme } from "./theme";
import type { ShareData } from "../sharing/contracts";
import type { PublicUser } from "../shared/user";
import { calendarTimeline } from "../components/design-system/calendar-model";
import { dayNumber } from "../habits/calendar";
export type CardPrivacy = { name: boolean; avatar: boolean; habitNames: boolean; timezone: boolean };
@@ -10,6 +13,14 @@ export const cardStats = (data: ShareData) => {
return { completed, scheduled, percent: scheduled ? Math.round(completed / scheduled * 100) : null };
};
/** Reuse the dashboard's Sunday-first week geometry and preserve each server-supplied shade. */
export function cardCalendar(habit: ShareData["habits"][number]) {
return calendarTimeline(habit.days.map(day => ({
date: day.date, value: day.ratio ?? 0, target: 1,
state: day.due ? "due" as const : "not-due" as const, color: day.color,
})));
}
async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
return new Promise(resolve => {
const img = new Image();
@@ -22,117 +33,105 @@ async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
});
}
/** Render once: this exact PNG is previewed, downloaded, and sent to Discord. */
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
await document.fonts.load('76px "Instrument Serif"');
const [avatar, botAvatar] = await Promise.all([
privacy.avatar && user.avatarUrl ? loadAvatar(user.avatarUrl) : null,
data.botAvatarUrl ? loadAvatar(data.botAvatarUrl) : null,
]);
/** Compact summary: fixed typography stays readable when Discord scales the attachment. */
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy, theme: Theme = "light"): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
await document.fonts.load('64px "Instrument Serif"');
const avatar = privacy.avatar && user.avatarUrl ? await loadAvatar(user.avatarUrl) : null;
const canvas = document.createElement("canvas");
const longRange = data.habits[0]!.days.length > 42;
const titleGraphGap = 12;
const rowHeight = (longRange ? 280 : 250) + titleGraphGap;
canvas.width = 1200; canvas.height = 380 + data.habits.length * rowHeight + 76;
const shortRange = dayNumber(data.to) - dayNumber(data.from) < 84;
// Reserve the full seven-row grid plus a separate legend line.
// Long ranges stay full-width so month labels and habit names never compete.
const panelHeight = shortRange ? 444 : 344;
const rows = data.habits.length;
const height = 356 + rows * panelHeight + (privacy.timezone ? 44 : 0);
// Keep the servers 1200px PNG contract while drawing on a 960-unit grid.
canvas.width = 1200;
canvas.height = height * 1.25;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Your browser could not create the image. Try another browser.");
const left = 56;
const right = 1144;
const chartLeft = 80;
const chartWidth = right - chartLeft;
const legendColumns = [chartLeft, chartLeft + 266, chartLeft + 532, chartLeft + 798] as const;
ctx.scale(1.25, 1.25);
const { ink, muted, paper, empty, rule, avatar: avatarBackground, dot } = themePalette[theme];
const green = "#285d49";
const sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const text = (value: string, x: number, y: number, size = 22, color = "#666", serif = false, maxWidth = 1088, align: CanvasTextAlign = "left") => {
ctx.font = `${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`;
ctx.fillStyle = color;
ctx.textAlign = align;
let fitted = value;
if (ctx.measureText(fitted).width > maxWidth) {
const text = (value: string, x: number, y: number, size = 32, color: string = ink, weight = "400", maxWidth = 864, align: CanvasTextAlign = "left", serif = false) => {
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`;
ctx.fillStyle = color; ctx.textAlign = align;
const chars = Array.from(value);
while (chars.length && ctx.measureText(`${chars.join("")}`).width > maxWidth) chars.pop();
fitted = `${chars.join("")}`;
}
let fitted = value;
while (chars.length && ctx.measureText(fitted).width > maxWidth) { chars.pop(); fitted = `${chars.join("")}`; }
ctx.fillText(fitted, x, y);
};
const rect = (x: number, y: number, w: number, h: number, color: string) => { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); };
rect(0, 0, 1200, canvas.height, "#fff");
rect(0, 0, 1200, 8, data.habits[0]?.color ?? "#111");
let profileX = 56;
const rect = (x: number, y: number, w: number, h: number, color: string, radius = 0) => {
if (w <= 0) return;
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, w, h, radius); ctx.fill();
};
rect(0, 0, 960, height, paper);
rect(0, 0, 960, 8, data.habits[0]?.color ?? green);
let profileX = 48;
if (privacy.avatar) {
ctx.save(); ctx.beginPath(); ctx.arc(88, 84, 32, 0, Math.PI * 2); ctx.clip();
rect(56, 52, 64, 64, "#f1f1f1");
if (avatar) ctx.drawImage(avatar, 56, 52, 64, 64);
else { ctx.fillStyle = "#888"; ctx.beginPath(); ctx.arc(88, 77, 10, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(88, 109, 22, 0, Math.PI * 2); ctx.fill(); }
ctx.restore(); profileX = 138;
ctx.save(); ctx.beginPath(); ctx.arc(80, 78, 32, 0, Math.PI * 2); ctx.clip();
rect(48, 46, 64, 64, avatarBackground);
if (avatar) ctx.drawImage(avatar, 48, 46, 64, 64);
else { ctx.fillStyle = muted; ctx.beginPath(); ctx.arc(80, 71, 11, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(80, 105, 23, 0, Math.PI * 2); ctx.fill(); }
ctx.restore(); profileX = 128;
}
text(privacy.name ? user.displayName || user.username : "A little, every day.", profileX, 94, 26, "#111", false, 700);
if (botAvatar) {
ctx.font = '34px "Instrument Serif", Georgia, serif';
const avatarX = right - ctx.measureText("minabot.").width - 12 - 40;
ctx.save();
ctx.beginPath();
ctx.roundRect(avatarX, 64, 40, 40, 4);
ctx.clip();
ctx.drawImage(botAvatar, avatarX, 64, 40, 40);
ctx.restore();
}
text("minabot.", right, 94, 34, "#111", true, 112, "right");
text("Small steps, adding up.", left, 213, 76, "#111", true);
text(`${cardDate(data.from)}${cardDate(data.to)}`, left, 260, 23);
text(privacy.name ? user.displayName || user.username : "My progress", profileX, 90, 34, ink, "600", 560 - profileX);
text("Mina Habits.", 912, 92, 44, ink, "400", 254, "right", true);
const daysInRange = dayNumber(data.to) - dayNumber(data.from) + 1;
text("A little, every day.", 48, 193, 72, ink, "400", 864, "left", true);
text(`${cardDate(data.from)} ${cardDate(data.to)}`, 48, 244, 34, muted);
const stats = cardStats(data);
text(`${stats.completed} / ${stats.scheduled} scheduled check-ins complete`, left, 317, 24, "#111", false, 800);
text(stats.percent === null ? "No days scheduled" : `${stats.percent}% complete`, right, 317, 24, "#111", false, 240, "right");
data.habits.forEach((habit, index) => {
const top = 358 + index * rowHeight;
rect(left, top, right - left, 1, "#dedede");
rect(left, top + 27, 6, 24, habit.color);
text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, chartLeft, top + 48, 24, "#111", false, 760);
text(`${habit.completed} / ${habit.scheduled} days`, right, top + 48, 21, "#666", false, 185, "right");
ctx.save();
ctx.translate(0, titleGraphGap);
if (habit.days.length <= 42) {
const step = (chartWidth + 5) / habit.days.length;
habit.days.forEach((day, i) => {
const x = chartLeft + i * step;
if (day.due) rect(x, top + 78, Math.max(8, step - 5), 62, day.color);
else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 5) / 2, top + 109, 3, 0, Math.PI * 2); ctx.fill(); }
if (habit.days.length <= 7 || i % 7 === 0) text(day.date.slice(8), x + (step - 5) / 2, top + 169, 17, "#666", false, step - 5, "center");
});
const top = 284 + index * panelHeight;
const chart = cardCalendar(habit);
rect(48, top, 864, 1, rule);
// Short ranges get a spacious calendar beside their period total. Longer
// ranges use the full card width, keeping a year as one unbroken timeline.
const chartOrigin = shortRange ? 490 : 96;
const chartY = top + (shortRange ? 79 : 100);
const chartWidth = shortRange ? 422 : 816;
const step = Math.min(shortRange ? 39 : 22, chartWidth / Math.max(1, chart.weeks));
const cell = step - (shortRange ? 5 : 3);
const chartX = chartOrigin + (shortRange ? (chartWidth - chart.weeks * step) / 2 : 0);
if (shortRange) {
text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, 48, top + 48, 38, ink, "600", 864);
text(`${habit.completed}`, 48, top + 179, 104, ink, "600", 320);
text("check-ins complete", 48, top + 228, 34, ink, "500", 360);
text(`of ${habit.scheduled} scheduled`, 48, top + 275, 32, muted, "400", 340);
text(`over ${daysInRange} days`, 48, top + 318, 32, muted, "400", 340);
} else {
const offset = (new Date(`${data.from}T12:00:00Z`).getUTCDay() + 6) % 7;
const columns = Math.ceil((offset + habit.days.length) / 7);
const step = (chartWidth + 4) / columns;
const startX = chartLeft;
text("M", left, top + 95, 14); text("W", left, top + 135, 14); text("F", left, top + 175, 14);
let previousMonthX = -100;
habit.days.forEach((day, i) => {
const n = offset + i, x = startX + Math.floor(n / 7) * step, y = top + 82 + n % 7 * 20;
if ((i === 0 || day.date.endsWith("-01")) && x - previousMonthX > 45 && x < 1108) {
text(new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", timeZone: "UTC" }), x, top + 70, 14);
previousMonthX = x;
text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, 48, top + 48, 36, ink, "600", 560);
text(`${habit.completed} / ${habit.scheduled} complete`, 912, top + 48, 32, muted, "400", 290, "right");
}
if (day.due) rect(x, y, step - 4, 16, day.color);
else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 4) / 2, y + 8, 2, 0, Math.PI * 2); ctx.fill(); }
for (const row of [1, 3, 5]) text(["S", "M", "T", "W", "T", "F", "S"][row]!, chartX - 18, chartY + row * step + cell * .8, shortRange ? 26 : 18, muted, "400", 30, "right");
if (!shortRange) {
let lastLabelX = -100;
chart.months.forEach(month => {
const x = chartX + (month.column - 1) * step;
if (x - lastLabelX < 100 || x > 850) return;
text(new Date(`${month.month}-01T12:00:00Z`).toLocaleDateString("en", { month: "short", timeZone: "UTC" }), x, chartY - 16, 26, muted, "400", 90);
lastLabelX = x;
});
}
const legendY = top + (longRange ? 241 : 209);
rect(legendColumns[0], legendY, 16, 16, habit.legend.empty);
text("No progress", legendColumns[0] + 26, legendY + 15, 18);
if (habit.legend.partial.length) {
habit.legend.partial.forEach((color, i) => rect(legendColumns[1] + i * 19, legendY, 16, 16, color));
text("Partial", legendColumns[1] + habit.legend.partial.length * 19 + 10, legendY + 15, 18);
}
rect(legendColumns[2], legendY, 16, 16, habit.legend.complete);
text("Complete", legendColumns[2] + 26, legendY + 15, 18);
ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(legendColumns[3] + 8, legendY + 8, 3, 0, Math.PI * 2); ctx.fill();
text("Not scheduled", legendColumns[3] + 26, legendY + 15, 18);
ctx.restore();
chart.entries.forEach(({ day, column, row }) => {
const x = chartX + (column - 1) * step, y = chartY + (row - 1) * step;
if (day.state === "not-due") {
ctx.fillStyle = dot; ctx.beginPath(); ctx.arc(x + cell / 2, y + cell / 2, Math.max(1.5, cell * .07), 0, Math.PI * 2); ctx.fill();
} else rect(x, y, cell, cell, day.value === 0 ? empty : themedHabitShade(day.color!, habit.color, theme), Math.min(3, cell / 6));
});
const footerY = canvas.height - 46;
text("Days off dont count against you.", left, footerY, 19);
text(privacy.timezone ? data.timezone : `${dayNumber(data.to) - dayNumber(data.from) + 1} days of small steps`, right, footerY, 19, "#666", false, 350, "right");
const legendY = top + panelHeight - 38;
text("0", 48, legendY + 8, 28, muted);
const shades = [empty, ...[...habit.legend.partial, habit.legend.complete].map(shade => themedHabitShade(shade, habit.color, theme))];
shades.forEach((color, i) => rect(76 + i * 25, legendY - 12, 20, 20, color, 2));
text("Complete", 90 + shades.length * 25, legendY + 8, 28, muted);
ctx.fillStyle = dot; ctx.beginPath(); ctx.arc(693, legendY - 2, 3, 0, Math.PI * 2); ctx.fill();
text("Not scheduled", 912, legendY + 8, 28, muted, "400", 200, "right");
});
const footerY = 284 + rows * panelHeight + 36;
text("Totals cover this entire period", 48, footerY, 30, muted);
if (privacy.timezone) text(data.timezone, 48, footerY + 44, 30, muted);
const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error("Could not export the card.")), "image/png"));
const names = privacy.habitNames ? data.habits.map(h => h.name).join(", ") : `${data.habits.length} habits`;
const names = data.habits.map((h, i) => `${privacy.habitNames ? h.name : `Habit ${i + 1}`}: ${h.completed} of ${h.scheduled} scheduled check-ins complete`).join(". ");
return { blob, avatarMissing: privacy.avatar && !!user.avatarUrl && !avatar,
alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${names}. ${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} scheduled check-ins complete. Legend: empty squares mean no progress, intermediate shades mean partial progress, full color means complete, and dots mean not scheduled.${privacy.timezone ? ` ${data.timezone}.` : ""}` };
alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} check-ins complete over ${daysInRange} days. ${names}. Calendar squares show daily progress; dots mean not scheduled. Totals cover the entire selected period; days off are excluded.${privacy.timezone ? ` ${data.timezone}.` : ""}` };
}

19
src/lib/theme.test.ts Normal file
View File

@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { resolveTheme, themePreference, themePalette } from "./theme";
test("system default follows the OS, while saved explicit choices take precedence", () => {
for (const value of [null, "invalid", "system"]) expect(themePreference(value)).toBe("system");
expect(resolveTheme(themePreference(null), true)).toBe("dark");
expect(resolveTheme("system", false)).toBe("light");
expect(resolveTheme("light", true)).toBe("light");
expect(resolveTheme("dark", false)).toBe("dark");
});
const luminance = (hex: string) => {
const rgb = hex.slice(1).match(/../g)!.map(value => parseInt(value, 16) / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return rgb[0]! * .2126 + rgb[1]! * .7152 + rgb[2]! * .0722;
};
test("both export palettes provide AA contrast for primary and secondary text", () => {
for (const palette of Object.values(themePalette)) for (const foreground of [palette.ink, palette.muted]) {
const values = [luminance(foreground), luminance(palette.paper)].sort((a, b) => a - b);
expect((values[1]! + .05) / (values[0]! + .05)).toBeGreaterThanOrEqual(4.5);
}
});

9
src/lib/theme.ts Normal file
View File

@@ -0,0 +1,9 @@
export type Theme = "light" | "dark";
export type ThemePreference = Theme | "system";
export const THEME_STORAGE_KEY = "minabot-theme";
export const themePreference = (value: string | null): ThemePreference => value === "light" || value === "dark" ? value : "system";
export const resolveTheme = (preference: ThemePreference, systemDark: boolean): Theme => preference === "system" ? systemDark ? "dark" : "light" : preference;
export const themePalette = {
light: { ink: "#202a26", muted: "#54635c", paper: "#f8f7f2", empty: "#eeeeee", rule: "#d5dcd3", avatar: "#e1e7df", dot: "#768174" },
dark: { ink: "#edf2ee", muted: "#b3beb7", paper: "#191e1b", empty: "#39413b", rule: "#414b44", avatar: "#38463d", dot: "#a0aca2" },
} as const;

View File

@@ -28,7 +28,7 @@ export function DesignSystem() {
<PageLayout mainId="ds-main" wordmarkTo="/design-system" header={<span className="ds-library-label">Interface library / 01</span>}>
<header className="ds-library-intro">
<div>
<p className="ds-eyebrow">MINABOT INTERFACE LANGUAGE</p>
<p className="ds-eyebrow">MINA HABITS INTERFACE LANGUAGE</p>
<h1 id="ds-title">Design system<span aria-hidden="true">.</span></h1>
</div>
<span className="ds-library-note">Interactive examples · nothing is saved</span>
@@ -92,7 +92,7 @@ export function DesignSystem() {
/>
<p>
{color.name}
<span className="ds-code">{color.value} · --ds-{color.token}</span>
<span className="ds-code">--ds-{color.token}</span>
</p>
</div>
))}

View File

@@ -1,4 +1,4 @@
import { Archive, Settings2 } from "lucide-react";
import { Archive, Plus, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -442,7 +442,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
>
<div className="ds-section-top">
<SectionHeading
number="YOUR HABITS"
id="habits-title"
title={
<>
@@ -581,7 +580,7 @@ function SavedHabit({
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
Add task +
<Plus size={20} aria-hidden="true" /><span>Add task</span>
</Button>
</div>
</>

View File

@@ -148,8 +148,8 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
}
const attachment = new FormData();
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
attachment.set("files[0]", image, "minabot-progress.png");
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "mina-habits-progress.png", description: "Progress card shared from Mina Habits" }] }));
attachment.set("files[0]", image, "mina-habits-progress.png");
let response: Response;
try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); }
catch { return c.json({ status: "uncertain" }); }

View File

@@ -21,6 +21,11 @@
--ds-paper: #fff;
--ds-surface: #f5f5f5;
--ds-empty: #eee;
--ds-selection: #edf2ec;
--ds-selection-border: #94ac9b;
--ds-preview-panel: #eff0eb;
--ds-preview-switch: #e6e8e1;
--ds-error: #a12c2c;
--ds-serif: "Instrument Serif", Georgia, serif;
--ds-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
position: relative;
@@ -940,13 +945,6 @@
outline: 2px solid var(--ds-ink);
outline-offset: 2px;
}
.ds-calendar-caption {
display: flex;
justify-content: space-between;
gap: 16px;
@apply type-small;
margin: 17px 0 23px;
}
.ds-calendar-legend {
display: flex;
justify-content: space-between;
@@ -977,13 +975,9 @@
flex: 0 0 11px;
}
/* Calendar detail and task controls share a compact, readable hierarchy. */
.ds-calendar-help { color: var(--ds-secondary); max-width: 30rem; }
.ds-calendar-help summary { display: flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; list-style: none; }
.ds-calendar-help summary::-webkit-details-marker { display: none; }
.ds-calendar-help summary:hover { color: var(--ds-ink); }
.ds-calendar-help p { padding: 4px 0 12px; }
.ds-calendar-caption { align-items: baseline; margin-bottom: 16px; }
.ds-calendar-demo-label { @apply type-small; margin-top: 16px !important; }
.ds-date-inspector {
margin-top: 20px;
background: var(--ds-surface);
border-left: 3px solid var(--calendar-color, var(--ds-ink));
padding: 18px 20px;
@@ -1128,7 +1122,6 @@
.ds-chart-selection {
gap: 10px 18px;
}
.ds-calendar-caption { flex-direction: column; gap: 0; }
.ds-big-value small {
margin-left: 10px;
}
@@ -1214,7 +1207,6 @@
grid-column: 1 / -1;
margin-top: -12px !important;
}
.ds-calendar--compact .ds-calendar-caption { margin-bottom: 12px; }
.ds-task-accordion {
margin-top: 16px;
border: 1px solid var(--ds-rule);
@@ -1411,25 +1403,69 @@
.ds-editable-task-row > .ds-checkbox { flex: 1; min-height: 76px; border-top: 0; }
.ds-checkbox-copy { display: grid; gap: 4px; min-width: 0; }
.ds-checkbox-description { color: var(--ds-secondary); @apply type-small; }
.ds-share-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 36px; align-items: start; }
.ds-share-controls { display: grid; gap: 24px; min-width: 0; }
/* Sharing composer: preview first, quiet controls, one clear delivery action. */
#share-progress { width: min(1120px, calc(100vw - 48px)); }
#share-progress .ds-modal-body { padding: 0; scrollbar-gutter: auto; }
#share-progress .ds-modal-heading h2 { @apply type-title; line-height: 1.15; }
.ds-share-layout { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr); align-items: start; }
.ds-share-controls { display: grid; gap: 24px; min-width: 0; padding: 28px; }
.ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
.ds-share-fieldset legend { padding: 0; margin-bottom: 10px; }
.ds-share-fieldset input:not([type="checkbox"]), .ds-share-fieldset select { width: 100%; min-width: 0; }
.ds-share-habits { display: grid; max-height: 220px; overflow: auto; margin-bottom: 8px; }
.ds-share-habits .ds-checkbox-copy { overflow-wrap: anywhere; }
.ds-share-dates { display: grid; gap: 12px; }
.ds-share-feedback { color: var(--ds-ink); @apply type-small; }
.ds-share-preview-column { display: grid; gap: 14px; min-width: 0; }
.ds-share-preview { display: grid; place-items: center; padding: 22px; min-height: 240px; background: var(--ds-surface); border: 1px solid var(--ds-rule); }
.ds-share-preview img { display: block; width: 100%; height: auto; box-shadow: 0 3px 14px #0000000d; }
.ds-share-preview > div { display: grid; gap: 16px; }
.ds-share-actions { display: flex; flex-wrap: wrap; gap: 12px; }
.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
@media (max-width: 900px) {
.ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
.ds-share-preview { padding: 12px; }
.ds-share-fieldset legend { width: 100%; padding: 0; margin-bottom: 12px; @apply type-small; font-weight: 600; line-height: 20px; }
.ds-share-fieldset input:not([type="checkbox"]) { width: 100%; min-width: 0; @apply type-small; }
.ds-share-count { float: right; color: var(--ds-secondary); @apply type-small; font-weight: 400; font-variant-numeric: tabular-nums; }
.ds-share-habits { display: grid; gap: 8px; max-height: 204px; overflow: auto; padding: 2px; margin: -2px; }
.ds-share-habits .ds-checkbox { border: 1px solid var(--ds-rule); padding: 12px; margin: 0; min-height: 46px; align-items: center; border-radius: 6px; }
.ds-share-habits .ds-checkbox:has(input:checked) { background: var(--ds-selection); border-color: var(--ds-selection-border); }
.ds-share-habits .ds-checkbox-copy { overflow-wrap: anywhere; @apply type-small; line-height: 20px; }
.ds-share-hint, .ds-share-caption { @apply type-small; line-height: 1.5; color: var(--ds-secondary); }
.ds-share-periods, .ds-share-size { display: flex; padding: 3px; gap: 3px; background: var(--ds-surface); border: 1px solid var(--ds-rule); border-radius: 7px; }
.ds-share-periods button, .ds-share-size button { flex: 1; border: 0; background: transparent; color: var(--ds-secondary); padding: 9px 6px; @apply type-small; font-weight: 500; line-height: 18px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
.ds-share-periods button[aria-pressed="true"], .ds-share-size button[aria-pressed="true"] { color: var(--ds-ink); background: var(--ds-paper); box-shadow: 0 1px 4px #00000015; }
.ds-share-periods button:disabled { cursor: default; opacity: .5; }
.ds-share-dates { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.ds-share-privacy { border-top: 1px solid var(--ds-rule); border-bottom: 1px solid var(--ds-rule); }
.ds-share-privacy summary { display: flex; align-items: center; gap: 9px; padding: 16px 0; cursor: pointer; @apply type-small; font-weight: 500; list-style: none; }
.ds-share-privacy summary::-webkit-details-marker { display: none; }
.ds-share-privacy summary span { flex: 1; }
.ds-share-privacy[open] summary > svg:last-child { transform: rotate(180deg); }
.ds-share-privacy .ds-share-fieldset { padding-bottom: 16px; gap: 2px; }
.ds-share-privacy .ds-checkbox { @apply type-small; min-height: 36px; }
.ds-share-destination { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
.ds-share-destination-icon { padding: 10px; border: 1px solid var(--ds-rule); border-radius: 10px; background: var(--ds-surface); }
.ds-share-destination > div { display: grid; gap: 3px; min-width: 0; overflow-wrap: anywhere; }
.ds-share-destination a { @apply type-small; font-weight: 600; text-underline-offset: 3px; }
.ds-share-preview-column { display: grid; gap: 16px; min-width: 0; padding: 28px; background: var(--ds-preview-panel); border-right: 1px solid var(--ds-rule); align-self: stretch; align-content: start; }
.ds-share-preview-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.ds-share-preview-heading > span { display: inline-flex; gap: 8px; align-items: center; @apply type-small; font-weight: 600; }
.ds-share-size { background: var(--ds-preview-switch); border-color: transparent; }
.ds-share-size button { padding: 5px 9px; }
.ds-share-preview { display: grid; place-items: center; min-height: 280px; padding: 24px; border-radius: 10px; background: #313338; }
.ds-share-preview img { display: block; width: 100%; height: auto; border-radius: 4px; box-shadow: 0 6px 24px #00000020; }
.ds-share-preview--thumbnail img { max-width: 360px; }
.ds-share-placeholder { display: grid; justify-items: center; gap: 16px; color: #f2f3f5; text-align: center; @apply type-small; padding: 24px 0; }
.ds-share-placeholder p { max-width: 30ch; }
.ds-share-caption { max-width: 48ch; }
.ds-share-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 20px 28px; border-top: 1px solid var(--ds-rule); background: var(--ds-paper); z-index: 1; }
.ds-share-footer-copy { @apply type-small; line-height: 1.5; min-width: 0; }
.ds-share-footer-copy span { display: block; color: var(--ds-secondary); @apply type-small; margin-top: 3px; }
.ds-share-footer-copy a { text-decoration: underline; text-underline-offset: 3px; }
.ds-share-error { color: var(--ds-error); }
.ds-share-actions { display: flex; gap: 10px; flex-shrink: 0; }
.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; @apply type-small; padding: 12px 16px; border-radius: 6px; }
.ds-share-actions .ds-button--primary { background: #285d49; border-color: #285d49; color: #fff; }
.ds-share-actions .ds-button--primary:hover:not(:disabled) { background: #1d4938; }
@media (max-width: 760px) {
#share-progress { width: calc(100vw - 24px); }
#share-progress .ds-modal-heading h2 { @apply type-title; }
.ds-share-layout { grid-template-columns: minmax(0, 1fr); }
.ds-share-preview-column { padding: 20px; border-right: 0; border-bottom: 1px solid var(--ds-rule); }
.ds-share-controls { padding: 20px; gap: 20px; }
.ds-share-preview { padding: 16px; min-height: 200px; }
.ds-share-footer { padding: 16px 20px; flex-wrap: wrap; gap: 12px; }
.ds-share-actions { width: 100%; }
.ds-share-actions .ds-button { flex: 1; padding-inline: 10px; }
}
.ds-task-add-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
@media (max-width: 640px) {
.ds-habit-title-row { gap: 8px; flex-wrap: wrap; }
@@ -1529,7 +1565,7 @@
.ds-welcome-panel--compact .ds-welcome-identity { gap: 14px; margin: 0; min-width: 0; }
.ds-welcome-panel--compact .ds-welcome-identity > div:last-child { min-width: 0; }
.ds-welcome-panel--compact .ds-avatar { width: 44px; height: 44px; flex-basis: 44px; background: var(--ds-paper); @apply type-lead; }
.ds-welcome-panel--compact h1 { margin-top: 4px; overflow-wrap: anywhere; }
.ds-welcome-panel--compact h1 { margin-top: 0; overflow-wrap: anywhere; }
.ds-welcome-panel--compact h1 em { display: inline; }
.ds-welcome-panel--compact .ds-welcome-calendar { padding: 0; border: 0; text-align: right; flex-shrink: 0; }
.ds-welcome-panel--compact .ds-welcome-timezone { justify-content: flex-end; margin-top: 4px !important; }
@@ -1541,7 +1577,7 @@
.ds-welcome-panel--compact .ds-utility-row .ds-button { padding-inline: 12px; }
.ds-habits-section { padding-top: 28px; border-top: 0; }
.ds-habits-section .ds-section-top { margin-bottom: 24px; align-items: center; flex-wrap: wrap; }
.ds-habits-section .ds-section-top h2 { @apply type-title; margin-top: 8px; }
.ds-habits-section .ds-section-top h2 { @apply type-title; margin-top: 0; }
.ds-habit-filters { display: inline-flex; width: fit-content; gap: 4px; padding: 4px; background: var(--ds-surface); border: 1px solid var(--ds-rule); border-radius: 6px; }
.ds-habit-filters .ds-button { @apply type-small; color: var(--ds-secondary); border: 1px solid transparent; background: transparent; border-radius: 3px; }
.ds-habit-filters .ds-button[aria-pressed="true"] { color: var(--ds-ink); background: var(--ds-paper); border-color: var(--ds-rule); box-shadow: 0 1px 2px #00000008; }
@@ -1589,53 +1625,89 @@
.ds-counter-input { border-color: ButtonText; }
}
/* Each goal puts its completion control before secondary history. */
/* Habits share one identity and graph layout, with controls tailored to the goal. */
.ds-visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.ds-habit-list > .ds-goal { display: flex; flex-direction: column; align-items: stretch; gap: 0; padding: 20px 0; }
.ds-goal-body { min-width: 0; }
.ds-goal-overview { min-width: 0; }
.ds-goal-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.ds-goal-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.ds-goal-title :is(h3, h4) { overflow-wrap: anywhere; min-width: 0; }
.ds-goal-title label { cursor: pointer; }
.ds-goal-title > .ds-checkbox { flex: 0 0 44px; justify-content: center; gap: 0; }
.ds-goal--manual .ds-goal-body { padding: 4px 0; }
.ds-goal--manual .ds-goal-title:has(input:checked) :is(h3, h4) { color: var(--ds-secondary); }
.ds-goal--manual .ds-goal-title input { width: 26px; height: 26px; }
.ds-goal--manual .ds-goal-title input:checked { background: var(--habit-color); border-color: var(--habit-color); }
.ds-goal-schedule { @apply type-small; color: var(--ds-secondary); margin-top: 6px; }
.ds-goal--manual .ds-goal-schedule { padding-left: 56px; }
.ds-goal--count .ds-goal-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, auto); align-items: center; gap: 24px; padding: 24px; border-radius: 8px; border: 1px solid var(--ds-rule); border-inline-start: 3px solid var(--habit-color); background: var(--habit-tint); }
.ds-goal-count { display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; }
.ds-goal-count .ds-counter { justify-content: center; padding: 6px; gap: 6px; background: var(--ds-paper); border-color: var(--ds-rule); }
.ds-goal-count .ds-counter-input { @apply type-section; width: 4ch; min-height: 60px; background: transparent; }
.ds-goal-count .ds-counter .ds-button { min-height: 44px; min-width: 44px; }
.ds-goal-count .ds-counter > .ds-muted { @apply type-ui-heading; padding-right: 0; }
.ds-goal-unit { @apply type-small; color: var(--ds-secondary); overflow-wrap: anywhere; text-align: center; }
.ds-habit-list .ds-goal .ds-habit-history { align-self: flex-end; margin-top: 10px; }
.ds-habit-list .ds-goal .ds-habit-history:has([aria-expanded="true"]) { align-self: stretch; }
.ds-goal--tasks .ds-goal-body { border: 1px solid var(--ds-rule); border-radius: 8px; overflow: clip; }
.ds-goal--tasks .ds-goal-overview { padding: 20px 24px 16px; background: var(--ds-surface); border-bottom: 1px solid var(--ds-rule); }
.ds-goal-task-progress { display: flex; align-items: center; gap: 12px; color: var(--ds-secondary); margin-top: 12px; @apply type-small; font-variant-numeric: tabular-nums; }
.ds-goal-task-progress progress { appearance: none; width: 100px; height: 4px; border: 0; border-radius: 4px; overflow: hidden; background: var(--ds-rule); accent-color: var(--habit-color); }
.ds-habit-list > .ds-goal { display: block; padding: 32px 0; }
.ds-goal-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
.ds-goal-identity { min-width: 0; }
.ds-goal-identity h3, .ds-goal-identity h4 { margin-top: 8px; overflow-wrap: anywhere; }
.ds-goal-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; color: var(--ds-secondary); @apply type-small; }
.ds-goal-swatch { width: 12px; height: 12px; flex-shrink: 0; background: var(--habit-color); }
.ds-goal-schedule { border-left: 1px solid var(--ds-rule); padding-left: 8px; }
.ds-goal-heading-actions { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
.ds-goal-status { display: inline-flex; align-items: center; gap: 8px; @apply type-small; }
.ds-goal-body { display: grid; grid-template-columns: minmax(0, 1fr) 304px; gap: 32px; align-items: start; }
.ds-goal-graph { grid-column: 1; grid-row: 1; min-width: 0; }
.ds-goal-completion { grid-column: 2; grid-row: 1; min-width: 0; padding: 24px; background: var(--ds-surface); border-top: 3px solid var(--habit-color); }
.ds-goal-count { display: grid; gap: 12px; }
.ds-goal-target { @apply type-body; font-weight: 500; overflow-wrap: anywhere; }
.ds-goal-count .ds-counter { width: fit-content; max-width: 100%; border-radius: 0; gap: 4px; padding: 0; justify-content: flex-start; }
.ds-goal-count .ds-counter-input { @apply type-body; font-family: var(--ds-sans); font-weight: 500; width: 5ch; min-width: 44px; height: 44px; min-height: 44px; padding: 8px 4px; background: var(--ds-paper); border-radius: 0; }
.ds-goal-count .ds-counter .ds-button { @apply type-lead; height: 44px; }
.ds-goal-count .ds-counter .ds-counter-save { @apply type-small; flex-basis: 100%; border-width: 1px 0 0; }
.ds-goal-count .ds-counter > .ds-muted { padding: 0 12px 0 4px; white-space: nowrap; }
.ds-goal-check-in { display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: center; gap: 0 12px; }
.ds-goal-check-in > .ds-checkbox { justify-content: center; }
.ds-goal-check-in > label[for] { @apply type-body; font-weight: 500; cursor: pointer; }
.ds-goal-completion .ds-checkbox input { width: 20px; height: 20px; min-width: 20px; min-height: 20px; flex: 0 0 20px; aspect-ratio: 1; }
.ds-goal-task-progress { display: grid; gap: 12px; margin-bottom: 16px; @apply type-body; font-weight: 500; }
.ds-goal-task-progress progress { appearance: none; width: 100%; height: 4px; border: 0; background: var(--ds-rule); accent-color: var(--habit-color); }
.ds-goal-task-progress progress::-webkit-progress-bar { background: var(--ds-rule); }
.ds-goal-task-progress progress::-webkit-progress-value { background: var(--habit-color); }
.ds-goal-task-progress progress::-moz-progress-bar { background: var(--habit-color); }
.ds-goal-tasks { padding: 4px 16px 8px; }
.ds-routine-items { list-style: none; padding: 0; margin: 0; }
.ds-routine-items .ds-editable-task:first-child { border-top: 0; }
.ds-routine-items .ds-editable-task-row { padding: 4px 8px; gap: 12px; border-radius: 4px; }
.ds-routine-items .ds-editable-task-row > .ds-checkbox { min-height: 48px; }
.ds-routine-items .ds-editable-task-row:has(input:checked) .ds-checkbox-label { color: var(--ds-secondary); text-decoration: line-through; text-decoration-color: var(--ds-rule); }
.ds-routine-items .ds-editable-task-row:hover { background: var(--ds-surface); }
.ds-goal-tasks .ds-task-add-action { padding: 8px; border-top: 1px solid var(--ds-rule); }
.ds-routine-items .ds-editable-task-row { gap: 8px; align-items: flex-start; padding-block: 0; }
.ds-routine-items .ds-item-actions { padding-top: 2px; }
.ds-routine-items .ds-editable-task-row > .ds-checkbox { min-height: 48px; min-width: 0; align-items: flex-start; gap: 12px; padding-block: 12px; @apply type-body; }
.ds-routine-items .ds-checkbox input { margin-top: 2px; }
.ds-routine-items .ds-checkbox-copy { gap: 4px; overflow-wrap: anywhere; }
.ds-goal-completion input:checked { background: var(--habit-color); border-color: var(--habit-color); }
.ds-routine-items .ds-editable-task-row:has(input:checked) .ds-checkbox-label { color: var(--ds-secondary); text-decoration: line-through; }
.ds-goal-tasks .ds-task-add-action { padding: 8px 0 0; border-top: 1px solid var(--ds-rule); }
.ds-goal-tasks .ds-task-add-action .ds-button { justify-content: flex-start; gap: 12px; padding: 0; border: 0; min-height: 44px; @apply type-body; }
.ds-goal-tasks .ds-task-add-action svg { width: 20px; height: 20px; flex: 0 0 20px; }
.ds-habit-starter-options { margin-top: 0; }
@media (max-width: 700px) {
.ds-goal-heading { gap: 8px; }
.ds-goal--count .ds-goal-body { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 20px 16px; }
.ds-goal--tasks .ds-goal-overview { padding: 16px; }
.ds-goal-tasks { padding-inline: 8px; }
.ds-goal-count { gap: 8px; }
.ds-goal-count .ds-counter { gap: 4px; }
.ds-habit-list .ds-goal .ds-habit-history { align-self: flex-start; }
@media (max-width: 1000px) {
.ds-goal-body { grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; }
.ds-goal-completion { padding: 20px; }
}
@media (max-width: 760px) {
.ds-habit-list > .ds-goal { padding: 24px 0; }
.ds-goal-heading { align-items: flex-start; flex-wrap: wrap; gap: 16px; }
.ds-goal-heading-actions { justify-content: space-between; width: 100%; }
.ds-goal-body { display: flex; flex-direction: column; gap: 24px; }
.ds-goal-completion, .ds-goal-graph { width: 100%; }
.ds-goal-count .ds-counter { justify-content: flex-start; width: fit-content; }
}
@media (forced-colors: active) {
.ds-goal-completion { border: 1px solid CanvasText; }
.ds-goal-completion input:checked { appearance: auto; }
}
:root[data-theme="dark"] .ds-root {
--ds-ink: #edf2ee;
--ds-secondary: #b3beb7;
--ds-rule: #414b44;
--ds-control-border: #89968c;
--ds-paper: #191e1b;
--ds-surface: #262e28;
--ds-empty: #39413b;
--ds-selection: #2c4033;
--ds-selection-border: #8cab94;
--ds-preview-panel: #222a24;
--ds-preview-switch: #354038;
--ds-error: #ffacac;
}
.ds-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 20px; flex-wrap: wrap; }
/* Native radios keep arrow-key selection while presenting one compact control. */
.ds-theme-control { display: inline-flex; flex: 0 0 auto; width: fit-content; padding: 4px; gap: 2px; border: 1px solid var(--ds-rule); border-radius: 12px; background: var(--ds-surface); @apply type-small; }
.ds-theme-option { position: relative; cursor: pointer; }
.ds-theme-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.ds-theme-option > span { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 36px; padding: 0 11px; border: 1px solid transparent; border-radius: 8px; color: var(--ds-secondary); white-space: nowrap; transition: background-color 120ms ease, color 120ms ease; }
.ds-theme-option svg { flex: 0 0 auto; }
.ds-theme-option:hover > span { color: var(--ds-ink); background: var(--ds-selection); }
.ds-theme-option input:checked + span { color: var(--ds-ink); background: var(--ds-paper); border-color: var(--ds-rule); box-shadow: 0 1px 3px rgb(0 0 0 / 8%); }
.ds-theme-option input:focus-visible + span { outline: 2px solid var(--ds-ink); outline-offset: 2px; }
.ds-theme-option input:disabled + span { opacity: .45; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) { .ds-theme-option > span { transition: none; } }
@media (max-width: 600px) { .ds-header { flex-wrap: wrap; } .ds-header-actions { gap: 12px; } }