diff --git a/PRD.md b/PRD.md
index 7d14cde..f466383 100644
--- a/PRD.md
+++ b/PRD.md
@@ -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.
diff --git a/README.md b/README.md
index 58dd21c..5abc22a 100644
--- a/README.md
+++ b/README.md
@@ -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 today’s 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.
diff --git a/bun.lock b/bun.lock
index 1b73201..bcfd0c4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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",
diff --git a/docs/API.md b/docs/API.md
index a245d3e..5845449 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -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 |
diff --git a/docs/design/imagegen-prompts.md b/docs/design/imagegen-prompts.md
index a583bb7..fea921a 100644
--- a/docs/design/imagegen-prompts.md
+++ b/docs/design/imagegen-prompts.md
@@ -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 Jan–Sep 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 Jan–Sep 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.
diff --git a/docs/design/mina-habits-icon.md b/docs/design/mina-habits-icon.md
new file mode 100644
index 0000000..b1d3c4f
--- /dev/null
+++ b/docs/design/mina-habits-icon.md
@@ -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.
diff --git a/docs/design/mina-habits-icon.png b/docs/design/mina-habits-icon.png
new file mode 100644
index 0000000..3d5c656
Binary files /dev/null and b/docs/design/mina-habits-icon.png differ
diff --git a/docs/discord-sharing-design.md b/docs/discord-sharing-design.md
new file mode 100644
index 0000000..47fdf25
--- /dev/null
+++ b/docs/discord-sharing-design.md
@@ -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.
diff --git a/package.json b/package.json
index 351601f..55a5bc1 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "minabot",
+ "name": "mina-habits",
"version": "0.1.0",
"private": true,
"type": "module",
diff --git a/scripts/dashboard-preview.ts b/scripts/dashboard-preview.ts
index b23f80f..3a8fe4b 100644
--- a/scripts/dashboard-preview.ts
+++ b/scripts/dashboard-preview.ts
@@ -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 }),
diff --git a/scripts/share-card-preview.html b/scripts/share-card-preview.html
new file mode 100644
index 0000000..2b82cb3
--- /dev/null
+++ b/scripts/share-card-preview.html
@@ -0,0 +1 @@
+
Sharing visual QA
diff --git a/scripts/share-card-preview.ts b/scripts/share-card-preview.ts
new file mode 100644
index 0000000..bb92766
--- /dev/null
+++ b/scripts/share-card-preview.ts
@@ -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}`);
diff --git a/scripts/share-card-preview.tsx b/scripts/share-card-preview.tsx
new file mode 100644
index 0000000..8f1a3b2
--- /dev/null
+++ b/scripts/share-card-preview.tsx
@@ -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 {count} {count === 1 ? "habit" : "habits"}{empty ? " · no schedule" : ""}{privateCard ? " · private" : ""} {url && } ;
+}
+createRoot(document.getElementById("root")!).render(Card readability at 320px 390 × 844 mobile composer );
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 56d8cc1..cc536b4 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -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("#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('[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('.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('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(".ds-day--due")!.style.backgroundColor,
- ).toBe("#ebedf0");
+ ).toBe("var(--ds-empty)");
expect(
container.querySelector(".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");
diff --git a/src/App.tsx b/src/App.tsx
index 1057df8..54c8998 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 (
-
+
} />
} />
-
+
);
}
diff --git a/src/account/routes.ts b/src/account/routes.ts
index ded6a4b..5f64031 100644
--- a/src/account/routes.ts
+++ b/src/account/routes.ts
@@ -29,7 +29,7 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType {
diff --git a/src/assets/apple-touch-icon.png b/src/assets/apple-touch-icon.png
new file mode 100644
index 0000000..5d6183b
Binary files /dev/null and b/src/assets/apple-touch-icon.png differ
diff --git a/src/assets/favicon.png b/src/assets/favicon.png
new file mode 100644
index 0000000..26b8aa5
Binary files /dev/null and b/src/assets/favicon.png differ
diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx
index 0c3f6c8..de34dfa 100644
--- a/src/components/AccountSettings.tsx
+++ b/src/components/AccountSettings.tsx
@@ -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
+ Follow your device or choose a theme for this browser.}>
Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.}>
Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.}>
- Export my dataJSON file
+ Export my dataJSON file
Permanently remove your account and habit history, and sign out every device.}>
{!deleting ? setDeleting(true)} aria-expanded={false}> Delete account
:
diff --git a/src/components/ArchivedHabits.tsx b/src/components/ArchivedHabits.tsx
index abacdef..9c0cbbb 100644
--- a/src/components/ArchivedHabits.tsx
+++ b/src/components/ArchivedHabits.tsx
@@ -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([]);
+ const [deleting, setDeleting] = useState(null);
+ const [deleteError, setDeleteError] = useState("");
+ const archiveFocus = useRef(null);
const [selected, setSelected] = useState();
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
+ {notice}
{error}
{error && setAttempt(value => value + 1)}>Retry archive }
{loading ? Loading archive…
: !habits.length && No archived habits.
When you put a habit aside, its history will be waiting here.
}
- {notice}
{habits.map(habit =>
@@ -49,10 +68,18 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
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"}
void restore(habit)}> Restore
+ { setDeleteError(""); setDeleting(habit); }}> Delete permanently
{selected === habit.habitId && setAttempt(value => value + 1)} />
}
)}
+ {deleting && archiveFocus.current} onClose={() => setDeleting(null)}>
+ {deleteError}
+
+ setDeleting(null)}>Keep habit
+ void permanentlyDelete(deleting)}>{busy ? "Deleting…" : "Delete permanently"}
+
+ }
;
}
diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx
index 97acc0d..b715593 100644
--- a/src/components/HabitHistory.tsx
+++ b/src/components/HabitHistory.tsx
@@ -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();
- const [historyExpanded, setHistoryExpanded] = useState(false);
const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState(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={
-
-
setHistoryExpanded((value) => !value)}
- >
-
- {historyExpanded ? "Hide history" : "View history"}
-
-
- {historyExpanded && chart}
-
-
- }
+ calendar={chart}
tasks={tasks}
tasksLabel="Tasks"
headingLevel={3}
diff --git a/src/components/ShareProgress.tsx b/src/components/ShareProgress.tsx
index 5e32df1..356f980 100644
--- a/src/components/ShareProgress.tsx
+++ b/src/components/ShareProgress.tsx
@@ -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("/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("/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 (
- document.getElementById("open-sharing")}>
+ document.getElementById("open-sharing")}>
+
+ Your card
+
setThumbnail(true)}>Thumbnail setThumbnail(false)}>Larger
+
+
+ {error ?
{error}
{ids.length > 0 &&
setRenderAttempt(n => n + 1)}>Retry preview }
: ready ?
:
}
+
+ {thumbnail ? "A small-size preview, like an image in chat." : "A closer look at your card."} Same image in Discord and your download.
+ {ready?.avatarMissing && Your avatar couldn’t load. A neutral profile icon is used.
}
+
+
- Choose your habits
- Up to six per card. Task names are never included.
- {today.habits.map(habit => = 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}
- {id => { const value = e.target.value; setRange(value); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}>Past 7 days Past 30 days Past year Custom dates }
+ Time period
+ {[["7", "7 days"], ["30", "30 days"], ["365", "1 year"], ["custom", "Custom"]].map(([value, label]) => { setRange(value!); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}>{label} )}
{range === "custom" && {id => setFrom(e.target.value)} />} {id => setTo(e.target.value)} />}
}
-
Show on the card
- {([ ["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"] ] as const).map(([key, label]) => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)}
+
+ Habits {ids.length} / 6
+ {today.habits.map(habit => = 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}
+ Choose up to six. Task names stay private.
-
-
-
- {error ?
{error}
setRenderAttempt(n => n + 1)}>Retry preview : ready ?
:
Creating your card…
}
-
-
This exact image will be downloaded or sent. Days off are excluded from the completion rate.
- {ready?.avatarMissing &&
Your avatar couldn’t load. The card uses a neutral profile icon.
}
-
- {ready ?
Download PNG :
Download PNG }
-
void send()}> {sending ? "Sending…" : sent?.status === "sent" ? "Sent to Discord" : sent ? "Check Discord" : "Send to Discord"}
-
- {connectionError ?
{connectionError}
setConnectionAttempt(n => n + 1)}>Retry Discord : connection?.connected ?
The bot will post this card to {connection.name} .
:
{connection?.message ?? "Loading the Discord sharing channel…"}
}
- {sendError &&
{sendError}
}
- {sent &&
{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && View in Discord ↗ }> : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? your channel : "your channel"} before creating another card; this attempt won’t be resent.>}
}
+
Privacy & details
+ Show on the card
+ {([["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"]] as const).map(([key, label]) => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)}
+
+
+
Discord destination
+ {connectionError ?
{connectionError}
setConnectionAttempt(n => n + 1)}>Retry connection : connection?.connected ? <>
{connection.name} Posted by the Mina Habits bot.
> :
{connection?.message ?? "Loading channel…"}
}
+
+
);
}
diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx
new file mode 100644
index 0000000..c0d7d7f
--- /dev/null
+++ b/src/components/ThemeProvider.tsx
@@ -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(() => {
+ 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 {children} ;
+}
+export function ThemeControl({ disabled = false }: { disabled?: boolean }) {
+ const { preference, setPreference } = useTheme();
+ const id = useId();
+ return (
+
+ {([
+ { value: "system", label: "System", Icon: Monitor },
+ { value: "light", label: "Light", Icon: Sun },
+ { value: "dark", label: "Dark", Icon: Moon },
+ ] as const).map(({ value, label, Icon }) => (
+
+ setPreference(value)} />
+ {label}
+
+ ))}
+
+ );
+}
diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx
index 831ffee..90b4e1a 100644
--- a/src/components/design-system/CalendarHeatmap.tsx
+++ b/src/components/design-system/CalendarHeatmap.tsx
@@ -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(12);
+ const { theme } = useTheme();
+ const [months, setMonths] = useState(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({
{legend ?? }
-
-
- {months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
-
- /{" "}
- {historyLabel ??
- (compact ? "Demo history" : "Illustrative history")}
-
-
-
- Keyboard shortcuts
-
- Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
-
-
-
+
+ Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
+
+ {!historyLabel && Demo history
}
-
SELECTED DAY
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
month: "long",
diff --git a/src/components/design-system/CalendarLegend.tsx b/src/components/design-system/CalendarLegend.tsx
index 8c2b596..1a30e83 100644
--- a/src/components/design-system/CalendarLegend.tsx
+++ b/src/components/design-system/CalendarLegend.tsx
@@ -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 (
- {shades.map((shade) => (
+ {shades.map((shade, index) => (
))}
diff --git a/src/components/design-system/HabitChart.tsx b/src/components/design-system/HabitChart.tsx
index 4f90fc6..d2ec10c 100644
--- a/src/components/design-system/HabitChart.tsx
+++ b/src/components/design-system/HabitChart.tsx
@@ -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 (
-
-
-
-
- {(!due || schedule !== "Every day") &&
{due ? schedule : `Not scheduled today · ${schedule}`}
}
- {trackingMethod === "tasks" && due && target > 0 &&
}
+
+
+ {editor}
+
+
+ {trackingMethod === "count" &&
}
+ {trackingMethod === "manual" &&
{children}{complete ? "Done for today" : "Mark as done"}
}
+ {trackingMethod === "tasks" && <>
+
{value} of {target} tasks done
+
{tasks}
+ >}
+
+
{calendar ?? }
- {calendar}
);
diff --git a/src/components/design-system/HabitColorPicker.tsx b/src/components/design-system/HabitColorPicker.tsx
index 9acc8d8..785ee4c 100644
--- a/src/components/design-system/HabitColorPicker.tsx
+++ b/src/components/design-system/HabitColorPicker.tsx
@@ -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({
Habit color
- 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.
{compact && (
<>
{HABIT_PALETTES.map((palette) => (
onChange(palette.colors[0])}>
- {value.toLowerCase() === palette.colors[0] ? "✓" : ""}
+ {value.toLowerCase() === palette.colors[0] ? "✓" : ""}
))}
@@ -89,7 +92,7 @@ export function HabitColorPicker({
title={color}
onClick={() => onChange(color)}
>
-
+
{value.toLowerCase() === color ? "✓" : ""}
@@ -144,10 +147,7 @@ export function HabitColorPicker({
))}
diff --git a/src/components/design-system/Modal.tsx b/src/components/design-system/Modal.tsx
index 617a7fb..98d3186 100644
--- a/src/components/design-system/Modal.tsx
+++ b/src/components/design-system/Modal.tsx
@@ -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('button, a[href], input, select, textarea, [tabindex]')]
+ const controls = [...event.currentTarget.querySelectorAll('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);
diff --git a/src/components/design-system/PageLayout.tsx b/src/components/design-system/PageLayout.tsx
index 4921f1b..7decf1f 100644
--- a/src/components/design-system/PageLayout.tsx
+++ b/src/components/design-system/PageLayout.tsx
@@ -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({
- minabot.
+ Mina Habits.
- {header}
+ {header}
{children}
- minabot.
+ Mina Habits.
A little, every day.
{avatar}
-
YOUR DAILY CHECK-IN
Welcome back, {name}.
diff --git a/src/components/design-system/accessibility.test.tsx b/src/components/design-system/accessibility.test.tsx
index c8e5008..976cb29 100644
--- a/src/components/design-system/accessibility.test.tsx
+++ b/src/components/design-system/accessibility.test.tsx
@@ -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");
});
diff --git a/src/components/design-system/calendar-model.ts b/src/components/design-system/calendar-model.ts
index 6acee46..0353bdd 100644
--- a/src/components/design-system/calendar-model.ts
+++ b/src/components/design-system/calendar-model.ts
@@ -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
();
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),
),
];
}
diff --git a/src/components/design-system/primitives.tsx b/src/components/design-system/primitives.tsx
index d39b124..ab994a8 100644
--- a/src/components/design-system/primitives.tsx
+++ b/src/components/design-system/primitives.tsx
@@ -39,14 +39,14 @@ export function SectionHeading({
children,
id,
}: {
- number: string;
+ number?: string;
title: ReactNode;
children?: ReactNode;
id?: string;
}) {
return (
- {number}
+ {number && {number} }
{title}
{children && {children}
}
diff --git a/src/habits/permanent-delete.test.ts b/src/habits/permanent-delete.test.ts
new file mode 100644
index 0000000..bd7906e
--- /dev/null
+++ b/src/habits/permanent-delete.test.ts
@@ -0,0 +1,50 @@
+import { afterEach, beforeEach, expect, test } from 'bun:test';
+import { fixture } from './test-fixture';
+let f: ReturnType;
+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);
+});
diff --git a/src/habits/routes.ts b/src/habits/routes.ts
index b1d2ee8..61280a8 100644
--- a/src/habits/routes.ts
+++ b/src/habits/routes.ts
@@ -62,6 +62,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType 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)) }));
diff --git a/src/habits/service.ts b/src/habits/service.ts
index b37d938..bc57187 100644
--- a/src/habits/service.ts
+++ b/src/habits/service.ts
@@ -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);
diff --git a/src/index.html b/src/index.html
index 7a0075b..9a31937 100644
--- a/src/index.html
+++ b/src/index.html
@@ -3,8 +3,10 @@
- Minabot
-
+
+
+ Mina Habits
+
diff --git a/src/lib/habit-palette.test.ts b/src/lib/habit-palette.test.ts
new file mode 100644
index 0000000..68d9f93
--- /dev/null
+++ b/src/lib/habit-palette.test.ts
@@ -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);
+ }
+});
diff --git a/src/lib/habit-palette.ts b/src/lib/habit-palette.ts
new file mode 100644
index 0000000..b0638dd
--- /dev/null
+++ b/src/lib/habit-palette.ts
@@ -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();
+
+/** 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);
+}
diff --git a/src/lib/progress-card.test.ts b/src/lib/progress-card.test.ts
new file mode 100644
index 0000000..b75536a
--- /dev/null
+++ b/src/lib/progress-card.test.ts
@@ -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();
+});
diff --git a/src/lib/progress-card.ts b/src/lib/progress-card.ts
index becf695..b490c62 100644
--- a/src/lib/progress-card.ts
+++ b/src/lib/progress-card.ts
@@ -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 {
return new Promise(resolve => {
const img = new Image();
@@ -22,117 +33,105 @@ async function loadAvatar(url: string): Promise {
});
}
-/** 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 server’s 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;
+ 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);
let fitted = value;
- if (ctx.measureText(fitted).width > maxWidth) {
- const chars = Array.from(value);
- while (chars.length && ctx.measureText(`${chars.join("")}…`).width > maxWidth) chars.pop();
- fitted = `${chars.join("")}…`;
- }
+ 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;
- }
- 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(); }
+ 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");
+ }
+ 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 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 = canvas.height - 46;
- text("Days off don’t 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 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((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}.` : ""}` };
}
diff --git a/src/lib/theme.test.ts b/src/lib/theme.test.ts
new file mode 100644
index 0000000..cd36437
--- /dev/null
+++ b/src/lib/theme.test.ts
@@ -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);
+ }
+});
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
new file mode 100644
index 0000000..ae7fd33
--- /dev/null
+++ b/src/lib/theme.ts
@@ -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;
diff --git a/src/pages/DesignSystem.tsx b/src/pages/DesignSystem.tsx
index a0f1f64..e41465c 100644
--- a/src/pages/DesignSystem.tsx
+++ b/src/pages/DesignSystem.tsx
@@ -28,7 +28,7 @@ export function DesignSystem() {
Interface library / 01}>
-
MINABOT — INTERFACE LANGUAGE
+
MINA HABITS — INTERFACE LANGUAGE
Design system.
Interactive examples · nothing is saved
@@ -92,7 +92,7 @@ export function DesignSystem() {
/>
{color.name}
- {color.value} · --ds-{color.token}
+ --ds-{color.token}
))}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 967c1f7..428503d 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -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
>
@@ -581,7 +580,7 @@ function SavedHabit({
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
- Add task +
+ Add task
>
diff --git a/src/sharing/routes.ts b/src/sharing/routes.ts
index f781b1a..58a8cf2 100644
--- a/src/sharing/routes.ts
+++ b/src/sharing/routes.ts
@@ -148,8 +148,8 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType .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; } }