Compare commits

...

14 Commits

Author SHA1 Message Date
syntaxbullet
00729096bf feat: add theme provider and control components for light/dark mode
- Implemented ThemeProvider to manage theme state and preferences.
- Added ThemeControl component for users to switch between system, light, and dark themes.
- Integrated localStorage for theme preference persistence.
- Updated document styles based on theme changes.

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

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

test: add tests for habit palette and progress card functionality

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

feat: define theme types and preferences

- Introduced Theme and ThemePreference types for better type safety.
- Created utility functions for resolving theme based on user preference and system settings.
- Defined theme palettes for light and dark modes to ensure adequate contrast.
2026-09-05 10:07:28 +02:00
syntaxbullet
9e9871c08a Keep habit tints readable and use shared typography 2026-09-05 08:13:58 +02:00
syntaxbullet
63e1573dae Give each habit completion type a distinct layout 2026-09-05 08:11:00 +02:00
syntaxbullet
2f67b775bf Keep calendar timeline stable when inspecting dates 2026-09-05 08:10:51 +02:00
syntaxbullet
1f017caee5 Remove redundant habit copy and document goal layouts 2026-09-05 08:07:55 +02:00
syntaxbullet
b832b5734e Center each habit layout on its completion action 2026-09-05 08:06:16 +02:00
syntaxbullet
3945998fa8 Keep narrow headings aligned and expanded history contained 2026-09-05 08:01:00 +02:00
syntaxbullet
7ebf936218 Polish dashboard hierarchy and unify daily controls 2026-09-05 07:58:35 +02:00
syntaxbullet
0d0aca5604 Document daily workflow and align starter color with compact palette 2026-09-05 07:54:26 +02:00
syntaxbullet
bfba95b1ed Verify compact Today flow and explicit history access 2026-09-05 07:52:42 +02:00
syntaxbullet
7ebbf4ed6c Bring daily check-ins forward with compact workspace layout 2026-09-05 07:52:28 +02:00
syntaxbullet
419bdce36b Improve count logging with direct editable totals 2026-09-05 07:51:25 +02:00
syntaxbullet
9f8154e412 Simplify habit creation with editable starters and compact colors 2026-09-05 07:51:22 +02:00
syntaxbullet
a2f5616b96 Keep habit history behind an accessible disclosure 2026-09-05 07:50:41 +02:00
50 changed files with 1338 additions and 337 deletions

4
PRD.md
View File

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

View File

@@ -1,4 +1,4 @@
# Minabot
# Mina Habits
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router
client includes a homepage for signed-out visitors and a personal habit dashboard.
@@ -67,7 +67,9 @@ its own recurrence. Both habit and task schedules support daily, selected weekda
day intervals, and week intervals with an editable start date and weekday.
Archive asks for confirmation in a modal and preserves earlier history.
**Archived habits** opens the archive modal, with history inspection and restoration
starting today. Task deletion uses the same modal confirmation. Tasks
starting today. **Delete permanently** removes an archived habit and all its history
after confirmation; it also removes the habit from combined charts and deletes
charts left empty. Task deletion uses the same modal confirmation. Tasks
remain manageable on days off, while their checkboxes stay disabled.
Settings, sharing, new habits, and historical check-ins also open in native dialogs.
Dialogs keep the background inactive, contain keyboard focus, support Escape and
@@ -81,6 +83,28 @@ checkboxes, or task occurrences using that date’s saved requirements. A date p
also reaches history outside the displayed year. Future dates and days off are read-only.
Combined-chart management is not exposed on this homepage.
### Daily check-in experience
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. 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 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.
New habits offer editable Reading, Get outside, and Evening routine starters, with
eight suggested colors and **More colors** for the full palette and custom colors.
For browser QA without changing application data, run
`bun scripts/dashboard-preview.ts` and visit
`http://127.0.0.1:3107/__preview/login`. This uses an in-memory database and a

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
# Minabot visual studies
# Mina Habits visual studies
Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are visual references, not data-accurate calendar renders. Two independent generations; no input images. The no-card, monochromatic editorial layouts informed the scoped design-system stylesheet and reusable components. Fonts are bundled locally under src/assets/fonts with their OFL license.
@@ -6,13 +6,13 @@ Generated with the built-in imagegen tool on 2026-09-04 from PRD.md. These are v
Saved asset: `src/assets/design/today.png`
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Mina Habits, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "Mina Habits" and links "Today", "Habits", "Charts". Primary request: Today view. Large heading "A little, every day." and small date "Friday, September 4". Below a horizontal rule, title "Today" with right aligned "1 of 3 complete". Three spacious open habit rows: "Drink water" with caption "Daily · 8 glasses" and minus 7 / 8 plus stepper; "Read a little" with caption "Daily · Mark as done" and checked black checkbox; "Evening reset" with caption "Daily · 2 of 3 tasks" and nested task checkboxes "Clear desk" checked, "Plan tomorrow" checked, "Stretch" unchecked. Lower open section "Your rhythm" with small GitHub-style calendar grid of square cells, neutral grayscale intensity and month labels. Caption "Only fully completed habits count. Today: 1 of 3." No cards anywhere. Crisp elegant readable interface.
## Habit detail
Saved asset: `src/assets/design/habit-detail.png`
Use case: ui-mockup. Asset type: high-fidelity desktop web app design reference for Minabot, a habit tracker. Ultra-minimalistic flat design, pure white background, black typography, only neutral grays for secondary labels and calendar intensities. Instrument Serif regular and italic for large editorial headings, restrained sans-serif for controls and metadata. NO CARDS, no bordered panels, no rounded containers, no shadows, no gradients, no illustrations, no streaks, no rewards. Very generous whitespace, thin horizontal hairline dividers, tiny square checkbox controls, sharp rectangular black primary buttons. Wide landscape screenshot, clean browser-free canvas. Simple header with serif wordmark "minabot" and links "Today", "Habits", "Charts". Primary request: Individual habit detail view. Breadcrumb "Habits / Drink water". Large Instrument Serif heading "Drink water" and small subtitle "Eight glasses. One day at a time." Right aligned text action "Edit habit". Open ruled section with label "Friday, September 4", huge serif "7 / 8" with unit "glasses", minus and plus buttons, text "In progress · 1 glass to go". Next open section "Progress" with GitHub-style 7-row square-cell calendar across 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.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -184,6 +184,30 @@ describe("homepage", () => {
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
});
test("archive permanent deletion supports cancel, failure, retry, and removal", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Discard me", method: "manual" }, 201);
await f.request(`/habits/${habit.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
await act(async () => buttonNamed("Delete permanently Discard me").click());
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
await act(async () => buttonNamed("Keep habit").click());
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => buttonNamed("Delete permanently Discard me").click());
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
await act(async () => confirm().click());
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("Temporary failure");
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => confirm().click());
expect(container.querySelector("#delete-archived-habit")).toBeNull();
expect(container.textContent).toContain("Discard me permanently deleted.");
expect(container.textContent).toContain("No archived habits.");
expect((await f.request(`/habits/${habit.id}`)).status).toBe(404);
});
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
@@ -191,6 +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-goal-graph .ds-calendar")?.getAttribute("data-months")).toBe("3");
expect(container.querySelector(".ds-goal-graph [hidden]")).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
await act(async () => buttonNamed("Edit selected check-in").click());
@@ -207,7 +233,7 @@ describe("homepage", () => {
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
await act(async () => buttonNamed("Increase glasses of water").click());
expect(container.querySelector(".ds-counter output")?.textContent).toBe("4 / 8");
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
@@ -272,9 +298,24 @@ describe("homepage", () => {
await f.json("/habits", "POST", { name: "Sunday walk", method: "manual", schedule: { type: "weekdays", days: [0] } }, 201);
await render("/");
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("0 / 3");
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).toContain("Count target");
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("In progress");
const checkIn = container.querySelector<HTMLInputElement>('.ds-goal--manual input[type="checkbox"]')!;
expect(container.querySelector('.ds-goal--manual .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 .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.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());
const checkbox = (name: string) => [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => (input.getAttribute("aria-label") || input.closest("label")?.textContent) === name)!;
expect(checkbox("Sunday walk done").disabled).toBe(true);
await act(async () => checkbox("Reading done").click());
@@ -359,6 +400,8 @@ describe("homepage", () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Sunday", method: "tasks", schedule: { type: "weekdays", days: [0] }, tasks: [{ name: "Walk" }] }, 201);
await render("/");
expect(container.querySelector(".ds-habit-chart")).toBeNull();
await act(async () => buttonNamed("All habits · 1").click());
expect(container.textContent).toContain("Not scheduled today");
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Edit task Walk").click());
@@ -410,6 +453,7 @@ describe("homepage", () => {
if (schedule.type === "weekly") await selectValue(form.querySelectorAll("select")[1]!, String(schedule.weekday));
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect((await f.json(`/habits/${habit.id}/tasks/${id}`)).schedule).toEqual(schedule);
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
expect(container.querySelector("form")).toBeNull();
await act(async () => buttonNamed("Edit task Walk").click());
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
@@ -448,7 +492,7 @@ describe("homepage", () => {
await selectMethod("tasks");
await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
await act(async () => buttonNamed("Add task +").click());
await act(async () => buttonNamed("Add task").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -489,10 +533,10 @@ describe("homepage", () => {
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
expect(container.querySelector(".ds-counter output")?.textContent).toBe("0 / 8");
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
expect(buttonNamed("Increase Water").disabled).toBe(false);
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector(".ds-counter output")?.textContent).toBe("1 / 8");
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
expect((await f.json("/today")).habits[0].value).toBe(1);
});
});
@@ -539,11 +583,11 @@ test("unscheduled dates stay inspectable without a progress-square fill", async
);
expect(
container.querySelector<HTMLElement>(".ds-day--due")!.style.backgroundColor,
).toBe("#ebedf0");
).toBe("var(--ds-empty)");
expect(
container.querySelector<HTMLElement>(".ds-day--future")!.style
.backgroundColor,
).toBe("#ebedf0");
).toBe("var(--ds-empty)");
expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
await act(async () => restDay.click());
expect(restDay.getAttribute("aria-pressed")).toBe("true");
@@ -660,7 +704,7 @@ describe("design system tabs", () => {
await act(async () => panel().querySelector<HTMLButtonElement>('[aria-label="Increase example count"]')!.click());
await clickTab("calendar-states");
await clickTab("components");
expect(panel().querySelector("output")?.textContent).toBe("4 / 8");
expect(panel().querySelector<HTMLInputElement>('[aria-label="Total example count"]')?.value).toBe("4");
const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!;
await act(async () => viewHabit.click());
expect(panel().id).toBe("ds-panel-calendar-states");

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { HabitForm } from "./HabitForm";
import { HabitColorPicker } from "./design-system/HabitColorPicker";
const dom = new Window({ url: "http://localhost:3000/design-system" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
beforeAll(async () => {
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
function button(text: string) {
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
}
test("habit starters remain editable and custom creation resets the draft", async () => {
await act(async () => root.render(<HabitForm date="2026-09-05" onCancel={() => {}} onCreated={() => {}} onExpired={() => {}} />));
await act(async () => button("Reading").click());
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("Read");
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("count");
expect(container.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe("10");
await act(async () => button("Evening routine").click());
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("tasks");
expect([...container.querySelectorAll("input")].map(input => input.value)).toContain("Prepare for tomorrow");
await act(async () => button("Start from scratch").click());
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("");
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("manual");
});
test("compact palette discloses full controls and preserves custom color", async () => {
function Picker() {
const [color, setColor] = useState("#123456");
return <HabitColorPicker value={color} onChange={setColor} compact />;
}
await act(async () => root.render(<Picker />));
expect(container.querySelector('[aria-label="Suggested colors"]')?.querySelectorAll("button").length).toBe(8);
const disclosure = button("More colors");
const details = document.getElementById(disclosure.getAttribute("aria-controls")!)!;
expect(details.hidden).toBe(true);
await act(async () => disclosure.click());
expect(details.hidden).toBe(false);
expect(container.querySelector<HTMLInputElement>('input[type="text"]')?.value).toBe("#123456");
await act(async () => button("Fewer colors").click());
expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull();
});

View File

@@ -7,6 +7,12 @@ import { HabitColorPicker } from "./design-system/HabitColorPicker";
import { habitInput, type Schedule } from "../habits/contracts";
import { habitRequest } from "../lib/dashboard";
const STARTERS = [
{ label: "Reading", name: "Read", method: "count", target: 10, unit: "pages", tasks: [""], color: "#426582" },
{ label: "Get outside", name: "Get outside", method: "manual", target: 1, unit: "times", tasks: [""], color: "#58765b" },
{ label: "Evening routine", name: "Wind down", method: "tasks", target: 1, unit: "times", tasks: ["Put things away", "Prepare for tomorrow"], color: "#79618d" },
] as const;
/** Account behavior composed from the design system's existing form components. */
export function HabitForm({
date,
@@ -23,14 +29,26 @@ export function HabitForm({
const saving = useRef(false);
const [name, setName] = useState("");
const [method, setMethod] = useState<"manual" | "count" | "tasks">("manual");
const [target, setTarget] = useState(8);
const [unit, setUnit] = useState("glasses");
const [target, setTarget] = useState(10);
const [unit, setUnit] = useState("pages");
const [tasks, setTasks] = useState([""]);
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
const [color, setColor] = useState("#58765b");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
function applyStarter(starter: (typeof STARTERS)[number] | null) {
setName(starter?.name ?? "");
setMethod(starter?.method ?? "manual");
setTarget(starter?.target ?? 10);
setUnit(starter?.unit ?? "pages");
setTasks(starter ? [...starter.tasks] : [""]);
setColor(starter?.color ?? "#58765b");
setSchedule({ type: "daily" });
setError("");
form.current?.querySelector<HTMLInputElement>("input")?.focus();
}
async function submit(event: FormEvent) {
event.preventDefault();
if (saving.current) return;
@@ -75,9 +93,17 @@ export function HabitForm({
}
return (
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Choose what counts as complete. Your schedule follows your account’s timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Schedules follow your account’s timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
<form ref={form} className="ds-edit-content" onSubmit={submit}>
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
<div className="ds-habit-starters" role="group" aria-label="Start with an idea">
<div className="ds-habit-starter-options">
{STARTERS.map((starter) => (
<Button key={starter.label} variant="secondary" onClick={() => applyStarter(starter)}>{starter.label}</Button>
))}
<Button variant="text" onClick={() => applyStarter(null)}>Start from scratch</Button>
</div>
</div>
<Field label="Habit name">
{(id) => (
<input
@@ -101,13 +127,13 @@ export function HabitForm({
>
<option value="manual">A simple check-in</option>
<option value="count">A count target</option>
<option value="tasks">A list of tasks</option>
<option value="tasks">A routine checklist</option>
</select>
)}
</Field>
{method === "count" && (
<div className="ds-form-grid">
<Field label="Daily target">
<Field label="Target per scheduled day">
{(id) => (
<input
id={id}
@@ -186,7 +212,7 @@ export function HabitForm({
onChange={setSchedule}
anchorDate={date}
/>
<HabitColorPicker value={color} onChange={setColor} mode="create" />
<HabitColorPicker value={color} onChange={setColor} mode="create" compact />
</fieldset>
{error && (
<p className="ds-form-feedback" role="alert">

View File

@@ -122,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}
@@ -140,7 +141,7 @@ export function HabitHistory({
/>
{editingDay && <Modal id={`check-in-${habit.habitId}`} eyebrow="YOUR HISTORY" title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
<div className="ds-history-editor">
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field><p className="ds-supporting-copy">Update the progress recorded for this day.</p></div>
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field></div>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div></Modal>}
@@ -150,6 +151,8 @@ export function HabitHistory({
return (
<HabitChart
id={`history-${habit.habitId}`}
trackingMethod={habit.requirements?.method ?? habit.method ?? undefined}
checkInId={`habit-check-${habit.habitId}`}
name={habit.name ?? "Habit"}
method={
habit.method === "count"

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import { useTheme } from "../ThemeProvider";
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { CalendarLegend } from "./CalendarLegend";
import { Field } from "./Field";
import { Button } from "./primitives";
import { CalendarDays, Keyboard } from "lucide-react";
import { CalendarDays } from "lucide-react";
import {
describeDay,
calendarTimeline,
progressShade,
MONTH_VIEWS,
visibleCalendarDays,
type CalendarDay,
@@ -18,8 +19,8 @@ export function CalendarHeatmap({
unit,
label,
color = "#111111",
emptyColor = "#eeeeee",
compact = false,
initialMonths = 12,
selectedDate: controlledDate,
onSelectDate,
historyLabel,
@@ -32,23 +33,32 @@ export function CalendarHeatmap({
color?: string;
emptyColor?: string;
compact?: boolean;
initialMonths?: MonthView;
selectedDate?: string;
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
selectedDayAction?: (date: string) => ReactNode;
}) {
const [months, setMonths] = useState<MonthView | "custom">(12);
const { theme } = useTheme();
const [months, setMonths] = useState<MonthView | "custom">(initialMonths);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
const [rangeError, setRangeError] = useState("");
const latestDate =
allDays.findLast((day) => day.state !== "future")?.date ??
allDays.at(-1)?.date;
// A date selected in the editor must stay visible even outside the current window.
const anchor =
controlledDate && allDays.some((day) => day.date === controlledDate)
? controlledDate
: latestDate;
// Inspecting a square changes selection, not the window. Only an external
// date outside the visible preset needs a new anchor to bring it into view.
const [windowAnchor, setWindowAnchor] = useState({ latestDate, date: latestDate });
let anchor = windowAnchor.latestDate === latestDate ? windowAnchor.date : latestDate;
if (months !== "custom" && anchor && controlledDate &&
allDays.some(day => day.date === controlledDate) &&
!visibleCalendarDays(allDays, months, anchor).some(day => day.date === controlledDate)) {
anchor = controlledDate;
}
if (windowAnchor.latestDate !== latestDate || windowAnchor.date !== anchor) {
setWindowAnchor({ latestDate, date: anchor });
}
const [localSelectedDate, setLocalSelectedDate] = useState(anchor);
const selectedDate = controlledDate ?? localSelectedDate;
function setSelectedDate(date: string) {
@@ -229,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)}`}
@@ -267,27 +277,14 @@ export function CalendarHeatmap({
</div>
</div>
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
<div className="ds-calendar-caption">
<span>
{months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
<span className="ds-muted">
/{" "}
{historyLabel ??
(compact ? "Demo history" : "Illustrative history")}
</span>
</span>
<details className="ds-calendar-help">
<summary><Keyboard size={16} aria-hidden="true" /> Keyboard shortcuts</summary>
<p id={instructionsId}>
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</p>
</details>
</div>
<p id={instructionsId} className="ds-visually-hidden">
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</p>
{!historyLabel && <p className="ds-calendar-demo-label ds-muted">Demo history</p>}
<div id={inspectorId} className="ds-date-inspector">
<div className="ds-date-inspector-heading" aria-live="polite">
<CalendarDays size={22} aria-hidden="true" />
<div>
<span className="ds-eyebrow">SELECTED DAY</span>
<time dateTime={selected.date}>
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
month: "long",

View File

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

View File

@@ -0,0 +1,103 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { Counter } from "./primitives";
const dom = new Window({ url: "http://localhost:3000/design-system" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
beforeAll(async () => {
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
const input = () => container.querySelector<HTMLInputElement>("input")!;
const control = (name: string) => container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`)!;
async function enter(text: string) {
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input(), text);
input().dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
}
async function key(key: string) {
await act(async () => input().dispatchEvent(new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent));
}
test("totals save explicitly, support above-target values and retain step controls", async () => {
const updates: number[] = [];
function Demo() {
const [value, setValue] = useState(8);
return <Counter label="pages" value={value} target={8} onChange={next => { updates.push(next); setValue(next); }} />;
}
await act(async () => root.render(<Demo />));
await enter("30");
expect(updates).toEqual([]);
await key("Enter");
expect(updates).toEqual([30]);
expect(input().value).toBe("30");
expect(control("Save total pages")).toBeNull();
await act(async () => control("Increase pages").click());
expect(input().value).toBe("31");
await enter("40");
await act(async () => control("Save total pages").click());
expect(updates).toEqual([30, 31, 40]);
});
test("invalid totals never submit and Escape restores the saved value", async () => {
const updates: number[] = [];
await act(async () => root.render(<Counter label="pages" value={3} target={8} onChange={next => updates.push(next)} />));
for (const invalid of ["", "-1", "1.5", "abc", "1000000001"]) {
await enter(invalid);
expect(input().getAttribute("aria-invalid")).toBe("true");
expect(control("Save total pages").disabled).toBe(true);
await key("Enter");
}
expect(updates).toEqual([]);
await key("Escape");
expect(input().value).toBe("3");
expect(container.querySelector('[role="alert"]')).toBeNull();
});
test("server refresh replaces a draft and API bounds disable increment and decrement", async () => {
const render = (value: number, disabled = false) => root.render(<Counter label="pages" value={value} target={8} disabled={disabled} onChange={() => {}} />);
await act(async () => render(0));
expect(control("Decrease pages").disabled).toBe(true);
await enter("12");
await act(async () => render(1_000_000_000));
expect(input().value).toBe("1000000000");
expect(control("Increase pages").disabled).toBe(true);
await act(async () => render(2, true));
expect(input().disabled).toBe(true);
expect(control("Decrease pages").disabled).toBe(true);
});

View File

@@ -1,9 +1,9 @@
import { useId, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, ListChecks } from "lucide-react";
import { Check, Circle, Hash, ChevronDown, ListChecks } from "lucide-react";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { demoCalendar } from "./calendar-model";
/** Open chart section: intentionally no card surface or enclosing border. */
/** A shared habit identity, visible history, and goal-specific completion surface. */
export function HabitChart({
name,
method,
@@ -21,6 +21,8 @@ export function HabitChart({
due = true,
id,
headingLevel = 4,
trackingMethod,
checkInId,
}: {
name: string;
method: string;
@@ -38,9 +40,43 @@ export function HabitChart({
due?: boolean;
id?: string;
headingLevel?: 3 | 4;
trackingMethod?: "manual" | "count" | "tasks";
checkInId?: string;
}) {
const headingId = useId();
const Heading = `h${headingLevel}` as const;
const complete = due && target > 0 && value >= target;
const TypeIcon = trackingMethod === "count" ? Hash : trackingMethod === "tasks" ? ListChecks : Check;
if (trackingMethod) return (
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-goal-heading">
<div className="ds-goal-identity">
<div className="ds-goal-meta"><span className="ds-goal-swatch" aria-hidden="true" /><TypeIcon size={16} aria-hidden="true" /><span>{method}</span>{schedule !== "Every day" && <span className="ds-goal-schedule">{schedule}</span>}</div>
<Heading id={headingId} className="type-title">{name}</Heading>
</div>
<div className="ds-goal-heading-actions">
<span className="ds-goal-status" role="status">{complete ? <Check size={16} aria-hidden="true" /> : <Circle size={14} aria-hidden="true" />}{!due ? "Day off" : complete ? "Complete" : value > 0 ? "In progress" : "Not started"}</span>
{headingActions}
</div>
</header>
{editor}
<div className="ds-goal-body">
<div className="ds-goal-completion">
{trackingMethod === "count" && <div className="ds-goal-count">
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
{children}
</div>}
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
{trackingMethod === "tasks" && <>
<div className="ds-goal-task-progress"><span>{value} of {target} tasks done</span><progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} /></div>
<div className="ds-goal-tasks" role="group" aria-label={`${name}: ${tasksLabel}`}>{tasks}</div>
</>}
</div>
<div className="ds-goal-graph">{calendar ?? <CalendarHeatmap days={demoCalendar(value, target)} label={`${name} progress calendar`} unit={unit} color={color} compact />}</div>
</div>
</section>
);
return (
<section className="ds-habit-chart" id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-habit-chart-heading">

View File

@@ -1,5 +1,7 @@
import { useId } from "react";
import { HABIT_COLORS, progressShade } from "./calendar-model";
import { useTheme } from "../ThemeProvider";
import { habitProgressColor } from "../../lib/habit-palette";
import { useId, useState } from "react";
import { HABIT_COLORS } from "./calendar-model";
import { Field } from "./Field";
export type HabitColors = {
@@ -40,20 +42,37 @@ export function isHabitColor(value: string) {
export function HabitColorPicker({
value,
onChange,
mode = "demo",
compact = false,
}: {
value: string;
onChange: (color: string) => void;
mode?: "demo" | "create" | "edit";
compact?: boolean;
}) {
const { theme } = useTheme();
const id = useId();
const [expanded, setExpanded] = useState(false);
const showDetails = !compact || expanded;
const valid = isHabitColor(value);
return (
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className="ds-footnote">
Choose a suggested shade or enter a custom color for this habit’s calendar.
<p id={`${id}-hint`} className={showDetails ? "ds-footnote" : "ds-visually-hidden"}>
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
</p>
{compact && (
<>
<div className="ds-color-swatches ds-color-swatches--compact" role="group" aria-label="Suggested colors">
{HABIT_PALETTES.map((palette) => (
<button key={palette.name} type="button" aria-label={`${palette.name} ${palette.colors[0]}`} aria-pressed={value.toLowerCase() === palette.colors[0]} title={palette.name} onClick={() => onChange(palette.colors[0])}>
<span style={{ backgroundColor: habitProgressColor(palette.colors[0], 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
</button>
))}
</div>
<button type="button" className="ds-button ds-button--text" aria-expanded={expanded} aria-controls={`${id}-details`} onClick={() => setExpanded(!expanded)}>{expanded ? "Fewer colors" : "More colors"}</button>
</>
)}
<div id={`${id}-details`} hidden={!showDetails}>
<div className="ds-color-palettes">
{HABIT_PALETTES.map((palette) => (
<div
@@ -73,7 +92,7 @@ export function HabitColorPicker({
title={color}
onClick={() => onChange(color)}
>
<span style={{ backgroundColor: color }}>
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
@@ -112,6 +131,7 @@ export function HabitColorPicker({
)}
</Field>
</div>
</div>
{valid && (
<div
className="ds-color-live-preview"
@@ -127,10 +147,7 @@ export function HabitColorPicker({
<span
key={count}
style={{
backgroundColor: progressShade(
{ date: "", value: count, target: 8, state: "due" },
value,
),
backgroundColor: count === 0 ? "var(--ds-empty)" : habitProgressColor(value, (count - 1) / 7, theme),
}}
/>
))}
@@ -138,13 +155,6 @@ export function HabitColorPicker({
<span className="ds-muted">0 → complete</span>
</div>
)}
<p className="ds-footnote">
{mode === "create"
? "Saved with your habit."
: mode === "edit"
? "Save to apply this color; Cancel restores the saved color."
: "Charts preview changes immediately. Save keeps the demo color; Cancel reverts it."}
</p>
{!valid && (
<p id={`${id}-error`} role="alert" className="ds-form-feedback">
Use a six-digit hex color, such as #426582.

View File

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

View File

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

View File

@@ -8,37 +8,61 @@ export function WelcomePanel({
date,
timezone,
children,
compact = false,
}: {
name: string;
avatarUrl: string | null;
date?: string;
timezone: string;
children: ReactNode;
compact?: boolean;
}) {
const [failedAvatar, setFailedAvatar] = useState<string | null>(null);
// The API date already belongs to the account's timezone. Keep its date parts
// intact instead of interpreting them in the browser's timezone.
const day = date ? new Date(`${date}T12:00:00Z`) : null;
const avatar = (
<div className="ds-avatar">
{avatarUrl && failedAvatar !== avatarUrl ? (
<img
src={avatarUrl}
alt={`${name}’s Discord avatar`}
width={64}
height={64}
onError={() => setFailedAvatar(avatarUrl)}
/>
) : (
<span aria-label={`${name}’s avatar`} role="img">
{Array.from(name.trim())[0]?.toUpperCase() || "?"}
</span>
)}
</div>
);
if (compact) return (
<section className="ds-welcome-panel ds-welcome-panel--compact" aria-labelledby="dashboard-title">
<div className="ds-welcome-top">
<div className="ds-welcome-identity">
{avatar}
<div>
<h1 id="dashboard-title" className="type-title">Welcome back, <em>{name}.</em></h1>
</div>
</div>
<div className="ds-welcome-calendar">
{date ? <time dateTime={date} className="type-small">{formatTrackingDate(date)}</time> : <p role="status">Your day is loading…</p>}
<p className="ds-welcome-timezone"><Globe2 size={16} aria-hidden="true" /><span>{timezone}</span></p>
</div>
</div>
<div className="ds-welcome-body">{children}</div>
</section>
);
return (
<section className="ds-welcome-panel" aria-labelledby="dashboard-title">
<div className="ds-welcome-content">
<div className="ds-welcome-identity">
<div className="ds-avatar">
{avatarUrl && failedAvatar !== avatarUrl ? (
<img
src={avatarUrl}
alt={`${name}’s Discord avatar`}
width={64}
height={64}
onError={() => setFailedAvatar(avatarUrl)}
/>
) : (
<span aria-label={`${name}’s avatar`} role="img">
{Array.from(name.trim())[0]?.toUpperCase() || "?"}
</span>
)}
</div>
{avatar}
<p className="ds-eyebrow">YOUR DAILY CHECK-IN</p>
</div>
<h1 id="dashboard-title" className="type-display">

View File

@@ -66,7 +66,7 @@ test("calendar associates exact keyboard guidance and preserves focus when selec
expect(selected.tabIndex).toBe(0);
});
test("calendar focuses the accepted date after a controlled month window changes", async () => {
test("calendar focuses the accepted date when selection crosses a month boundary", async () => {
function Calendar() {
const [date, setDate] = useState("2026-09-01");
return <CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" selectedDate={date} onSelectDate={setDate} />;
@@ -105,6 +105,55 @@ test("rejected calendar clicks and repeated arrows do not leave stale focus requ
expect(rejected.getAttribute("aria-pressed")).toBe("true");
});
test("selecting tracked, empty, unscheduled and future dates never changes the displayed timeline", async () => {
const days = demoCalendar(3, 8).map((day, index) => ({ ...day,
state: day.state === "future" ? day.state : index % 2 === 0 ? "not-due" as const : "due" as const,
value: index % 3 === 0 ? 0 : day.value,
}));
function Calendar() {
const [date, setDate] = useState<string>();
return <CalendarHeatmap days={days} unit="pages" label="Reading history" selectedDate={date} onSelectDate={setDate} />;
}
await act(async () => root.render(<Calendar />));
const timeline = () => ({
dates: [...container.querySelectorAll(".ds-day")].map(day => day.getAttribute("aria-label")?.slice(0, 10)),
months: [...container.querySelectorAll(".ds-calendar-month-label")].map(month => month.textContent),
range: container.querySelector(".ds-calendar-range-label")?.textContent,
weeks: (container.querySelector(".ds-calendar") as HTMLElement).style.getPropertyValue("--calendar-weeks"),
});
for (const preset of [12, 6, 4, 3, "Custom"]) {
await act(async () => button(typeof preset === "number" ? `${preset}m` : preset).click());
const before = timeline();
for (const index of [1, 0, 8, 7, before.dates.length - 1]) {
const date = before.dates[index]!;
await act(async () => container.querySelector<HTMLButtonElement>(`.ds-day[aria-label^="${date}:"]`)!.click());
expect(timeline()).toEqual(before);
expect(container.querySelector('.ds-day[aria-pressed="true"]')?.getAttribute("aria-label")).toStartWith(`${date}:`);
}
for (const key of ["ArrowLeft", "Home", "End"]) {
const selected = container.querySelector<HTMLButtonElement>('.ds-day[aria-pressed="true"]')!;
await act(async () => selected.dispatchEvent(new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent));
expect(timeline()).toEqual(before);
}
}
});
test("an external editor date outside the window remains reachable without moving focus", async () => {
let setDate!: (date: string) => void;
function Calendar() {
const [date, update] = useState("2026-09-04");
setDate = update;
return <><input aria-label="Editor date" /><CalendarHeatmap days={demoCalendar(3, 8)} unit="pages" label="Reading history" selectedDate={date} onSelectDate={update} /></>;
}
await act(async () => root.render(<Calendar />));
await act(async () => button("3m").click());
const editor = container.querySelector<HTMLInputElement>('input[aria-label="Editor date"]')!;
editor.focus();
await act(async () => setDate("2026-03-04"));
expect(container.querySelector('.ds-day[aria-pressed="true"]')?.getAttribute("aria-label")).toStartWith("2026-03-04:");
expect(document.activeElement).toBe(editor);
});
test("editor controls expose their explanatory hints", async () => {
await act(async () => root.render(<EditingWorkbench />));
expect(description(container.querySelector("input[readonly]")!)).toContain("Method is fixed");
@@ -135,7 +184,7 @@ test("invalid custom colors associate both guidance and corrective feedback", as
await act(async () => root.render(<HabitColorPicker value="#bad" onChange={() => {}} />));
const input = container.querySelector('input[type="text"]')!;
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(description(input)).toContain("Choose a suggested shade");
expect(description(input)).toContain("Custom colors use six-digit hex values");
expect(description(input)).toContain("Use a six-digit hex color");
});
@@ -182,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();
@@ -193,7 +242,7 @@ test("custom timeframe applies inclusive dates, supports single days and survive
setDates("2026-09-04", "2026-09-04");
await act(async () => button("Apply range").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("1 day ");
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Sep 4, 2026");
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain("September 4, 2026");
});

View File

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

View File

@@ -1,4 +1,4 @@
import { useId } from "react";
import { useId, useState } from "react";
import type {
ButtonHTMLAttributes,
AnchorHTMLAttributes,
@@ -39,14 +39,14 @@ export function SectionHeading({
children,
id,
}: {
number: string;
number?: string;
title: ReactNode;
children?: ReactNode;
id?: string;
}) {
return (
<header className="ds-section-heading">
<span className="ds-eyebrow">{number}</span>
{number && <span className="ds-eyebrow">{number}</span>}
<h2 id={id} tabIndex={-1}>{title}</h2>
{children && <p>{children}</p>}
</header>
@@ -56,19 +56,20 @@ export function SectionHeading({
export function Checkbox({
label,
description,
hideLabel = false,
className = "",
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; description?: string }) {
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; description?: string; hideLabel?: boolean }) {
const descriptionId = useId();
return (
<label className={`ds-checkbox ${className}`}>
<input
type="checkbox"
aria-label={description ? label : undefined}
aria-label={description || hideLabel ? label : undefined}
{...props}
aria-describedby={[props["aria-describedby"], description ? descriptionId : undefined].filter(Boolean).join(" ") || undefined}
/>
<span className="ds-checkbox-copy">
<span className={`ds-checkbox-copy${hideLabel ? " ds-visually-hidden" : ""}`}>
<span className="ds-checkbox-label">{label}</span>
{description && <span id={descriptionId} className="ds-checkbox-description">{description}</span>}
</span>
@@ -89,28 +90,60 @@ export function Counter({
onChange: (value: number) => void;
disabled?: boolean;
}) {
const hintId = useId();
const [edit, setEdit] = useState({ source: value, label, text: String(value) });
// A changed day or refreshed server total replaces an unsubmitted draft.
const refreshed = edit.source !== value || edit.label !== label;
if (refreshed) setEdit({ source: value, label, text: String(value) });
const draft = refreshed ? String(value) : edit.text;
const changed = draft !== String(value);
const maximum = 1_000_000_000;
const valid = /^\d+$/.test(draft) && Number(draft) <= maximum;
const updateDraft = (text: string) => setEdit({ source: value, label, text });
const save = () => {
if (!disabled && changed && valid) {
updateDraft(String(Number(draft)));
onChange(Number(draft));
}
};
return (
<div className="ds-counter" role="group" aria-label={label}>
<Button
variant="text"
aria-label={`Decrease ${label}`}
disabled={disabled || value <= 0}
onClick={() => onChange(Math.max(0, value - 1))}
onClick={() => { updateDraft(String(value)); onChange(Math.max(0, value - 1)); }}
>
−
</Button>
<output aria-live="polite">
<span>{value}</span>
<span className="ds-muted"> / {target}</span>
</output>
<input
className="ds-counter-input"
type="text"
inputMode="numeric"
aria-label={`Total ${label}`}
aria-describedby={changed && !valid ? hintId : undefined}
aria-invalid={changed && !valid ? true : undefined}
title="Enter a total and press Enter to save. Escape cancels."
value={draft}
disabled={disabled}
onChange={event => updateDraft(event.target.value)}
onKeyDown={event => {
if (event.key === "Enter") { event.preventDefault(); save(); }
if (event.key === "Escape") { event.preventDefault(); updateDraft(String(value)); }
}}
/>
<span className="ds-muted"> / {target}</span>
<Button
variant="text"
aria-label={`Increase ${label}`}
disabled={disabled || value >= target}
onClick={() => onChange(Math.min(target, value + 1))}
disabled={disabled || value >= maximum}
onClick={() => { updateDraft(String(value)); onChange(Math.min(maximum, value + 1)); }}
>
+
</Button>
{changed && <Button className="ds-counter-save" variant="secondary" disabled={disabled || !valid} onClick={save} aria-label={`Save total ${label}`}>Save</Button>}
{changed && !valid && <span className="ds-counter-error" id={hintId} role="alert">Enter a whole number from 0 to 1,000,000,000.</span>}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,5 +1,8 @@
import { themedHabitShade } from "./habit-palette";
import { themePalette, type Theme } from "./theme";
import type { ShareData } from "../sharing/contracts";
import type { PublicUser } from "../shared/user";
import { calendarTimeline } from "../components/design-system/calendar-model";
import { dayNumber } from "../habits/calendar";
export type CardPrivacy = { name: boolean; avatar: boolean; habitNames: boolean; timezone: boolean };
@@ -10,6 +13,14 @@ export const cardStats = (data: ShareData) => {
return { completed, scheduled, percent: scheduled ? Math.round(completed / scheduled * 100) : null };
};
/** Reuse the dashboard's Sunday-first week geometry and preserve each server-supplied shade. */
export function cardCalendar(habit: ShareData["habits"][number]) {
return calendarTimeline(habit.days.map(day => ({
date: day.date, value: day.ratio ?? 0, target: 1,
state: day.due ? "due" as const : "not-due" as const, color: day.color,
})));
}
async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
return new Promise(resolve => {
const img = new Image();
@@ -22,117 +33,105 @@ async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
});
}
/** Render once: this exact PNG is previewed, downloaded, and sent to Discord. */
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
await document.fonts.load('76px "Instrument Serif"');
const [avatar, botAvatar] = await Promise.all([
privacy.avatar && user.avatarUrl ? loadAvatar(user.avatarUrl) : null,
data.botAvatarUrl ? loadAvatar(data.botAvatarUrl) : null,
]);
/** Compact summary: fixed typography stays readable when Discord scales the attachment. */
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy, theme: Theme = "light"): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
await document.fonts.load('64px "Instrument Serif"');
const avatar = privacy.avatar && user.avatarUrl ? await loadAvatar(user.avatarUrl) : null;
const canvas = document.createElement("canvas");
const longRange = data.habits[0]!.days.length > 42;
const titleGraphGap = 12;
const rowHeight = (longRange ? 280 : 250) + titleGraphGap;
canvas.width = 1200; canvas.height = 380 + data.habits.length * rowHeight + 76;
const shortRange = dayNumber(data.to) - dayNumber(data.from) < 84;
// Reserve the full seven-row grid plus a separate legend line.
// Long ranges stay full-width so month labels and habit names never compete.
const panelHeight = shortRange ? 444 : 344;
const rows = data.habits.length;
const height = 356 + rows * panelHeight + (privacy.timezone ? 44 : 0);
// Keep the 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<Blob>((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error("Could not export the card.")), "image/png"));
const names = privacy.habitNames ? data.habits.map(h => h.name).join(", ") : `${data.habits.length} habits`;
const names = data.habits.map((h, i) => `${privacy.habitNames ? h.name : `Habit ${i + 1}`}: ${h.completed} of ${h.scheduled} scheduled check-ins complete`).join(". ");
return { blob, avatarMissing: privacy.avatar && !!user.avatarUrl && !avatar,
alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${names}. ${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} scheduled check-ins complete. Legend: empty squares mean no progress, intermediate shades mean partial progress, full color means complete, and dots mean not scheduled.${privacy.timezone ? ` ${data.timezone}.` : ""}` };
alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} check-ins complete over ${daysInRange} days. ${names}. Calendar squares show daily progress; dots mean not scheduled. Totals cover the entire selected period; days off are excluded.${privacy.timezone ? ` ${data.timezone}.` : ""}` };
}

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

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

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

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

View File

@@ -28,7 +28,7 @@ export function DesignSystem() {
<PageLayout mainId="ds-main" wordmarkTo="/design-system" header={<span className="ds-library-label">Interface library / 01</span>}>
<header className="ds-library-intro">
<div>
<p className="ds-eyebrow">MINABOT — INTERFACE LANGUAGE</p>
<p className="ds-eyebrow">MINA HABITS — INTERFACE LANGUAGE</p>
<h1 id="ds-title">Design system<span aria-hidden="true">.</span></h1>
</div>
<span className="ds-library-note">Interactive examples · nothing is saved</span>
@@ -92,7 +92,7 @@ export function DesignSystem() {
/>
<p>
{color.name}
<span className="ds-code">{color.value} · --ds-{color.token}</span>
<span className="ds-code">--ds-{color.token}</span>
</p>
</div>
))}
@@ -181,7 +181,7 @@ export function DesignSystem() {
<div className="ds-component-row">
<div>
<h3>A little more</h3>
<span className="ds-code">Counter · bounded 0–8</span>
<span className="ds-code">Counter · editable total</span>
</div>
<Counter
label="example count"

View File

@@ -1,4 +1,4 @@
import { Archive, Settings2 } from "lucide-react";
import { Archive, Plus, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -166,6 +166,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [revision, setRevision] = useState(0);
const [attempt, setAttempt] = useState(0);
const [loading, setLoading] = useState(true);
const [habitView, setHabitView] = useState<"today" | "all">("today");
const [adding, setAdding] = useState(false);
const [sharing, setSharing] = useState(false);
const [settings, setSettings] = useState(false);
@@ -300,6 +301,8 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const refreshed = await habitRequest<TodayResponse>("/today");
if (mounted.current) {
setToday(refreshed);
// Keep a habit reachable when an edit makes it unscheduled (including an empty routine).
if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all");
setNeedsRefresh(false);
setRevision((value) => value + 1);
}
@@ -337,6 +340,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
return (
<>
<WelcomePanel
compact
name={user.displayName || user.username}
avatarUrl={user.avatarUrl}
date={today?.date}
@@ -365,27 +369,28 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</p>
)}
</div>
<div className="ds-action-row ds-primary-actions">
<Button
id="add-habit"
aria-haspopup="dialog"
disabled={adding || busy || loading || needsRefresh}
onClick={() => setAdding(true)}
aria-expanded={adding}
aria-controls={adding ? "new-habit" : undefined}
>
Add a habit +
</Button>
{today.habits.length > 0 && (
<>
<Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
<ButtonLink href="#habits-title">View your habits ↓</ButtonLink>
</>
)}
</div>
<div className="ds-action-row ds-utility-row">
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button>
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
<div className="ds-workspace-actions">
<div className="ds-action-row ds-primary-actions">
<Button
id="add-habit"
aria-haspopup="dialog"
disabled={adding || busy || loading || needsRefresh}
onClick={() => setAdding(true)}
aria-expanded={adding}
aria-controls={adding ? "new-habit" : undefined}
>
Add a habit +
</Button>
{today.habits.length > 0 && (
<>
<Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
</>
)}
</div>
<div className="ds-action-row ds-utility-row">
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button>
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
</div>
</div>
</>
)}
@@ -421,32 +426,37 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
focusAddAfterRefresh.current = true;
setAdding(false);
setNotice(`${name} created.`);
setHabitView("all");
setAttempt((value) => value + 1);
}}
/>
)}
<p className="ds-form-feedback" role="status">
<p className="ds-form-feedback ds-save-notice" role="status">
{notice}
</p>
{today.habits.length > 0 && (
<section
className="ds-section"
className="ds-section ds-habits-section"
aria-labelledby="habits-title"
aria-busy={busy || loading}
>
<div className="ds-section-top">
<SectionHeading
number="YOUR HABITS"
id="habits-title"
title={
<>
One day <em>at a time.</em>
Today, <em>at your pace.</em>
</>
}
/>
<div className="ds-action-row ds-habit-filters" role="group" aria-label="Show habits">
<Button variant={habitView === "today" ? "primary" : "text"} aria-pressed={habitView === "today"} onClick={() => setHabitView("today")}>Today · {today.due}</Button>
<Button variant={habitView === "all" ? "primary" : "text"} aria-pressed={habitView === "all"} onClick={() => setHabitView("all")}>All habits · {today.habits.length}</Button>
</div>
<div className="ds-habit-chart-grid">
{today.habits.map((habit) => (
</div>
{habitView === "today" && today.due === 0 && <p className="ds-supporting-copy">You’re all set for today. Choose All habits to view or edit your routines.</p>}
<div className="ds-habit-chart-grid ds-habit-list">
{today.habits.filter(habit => habitView === "all" || habit.due).map((habit) => (
<SavedHabit
key={habit.habitId}
habit={habit}
@@ -499,6 +509,7 @@ function SavedHabit({
const [editing, setEditing] = useState<{ mode: ItemMode; color: string } | null>(null);
const [addingTask, setAddingTask] = useState(false);
const blocked = disabled || !habit.due;
const method = habit.requirements?.method ?? habit.method;
return (
<HabitHistory
habit={habit}
@@ -527,24 +538,26 @@ function SavedHabit({
tasks={
habit.requirements?.method === "tasks" ? (
<>
{habit.requirements.tasks.map((config) => {
const occurrence = habit.tasks.find((task) => task.taskId === config.id);
return (
<SavedTask
key={config.id}
task={{ taskId: config.id, name: config.name, done: occurrence?.done ?? false }}
disabled={disabled}
blocked={blocked || !occurrence}
scheduled={!!occurrence}
schedule={config.schedule}
habitSchedule={habit.requirements!.schedule}
date={date}
onCheck={(done) => void onUpdate(habit, { done }, config.id)}
onSave={(patch) => onManage(habit, patch, config.id)}
onDelete={() => onManage(habit, null, config.id)}
/>
);
})}
<ul className="ds-routine-items">
{habit.requirements.tasks.map((config) => {
const occurrence = habit.tasks.find((task) => task.taskId === config.id);
return (
<SavedTask
key={config.id}
task={{ taskId: config.id, name: config.name, done: occurrence?.done ?? false }}
disabled={disabled}
blocked={blocked || !occurrence}
scheduled={!!occurrence}
schedule={config.schedule}
habitSchedule={habit.requirements!.schedule}
date={date}
onCheck={(done) => void onUpdate(habit, { done }, config.id)}
onSave={(patch) => onManage(habit, patch, config.id)}
onDelete={() => onManage(habit, null, config.id)}
/>
);
})}
</ul>
{addingTask && (
<InlineTaskEditor
name=""
@@ -560,9 +573,6 @@ function SavedHabit({
/>
)}
<div className="ds-task-add-action">
{!habit.requirements.tasks.length && (
<p className="ds-footnote">Add a task to start checking in.</p>
)}
<Button
variant="text"
aria-haspopup="dialog"
@@ -570,23 +580,26 @@ function SavedHabit({
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
Add task +
<Plus size={20} aria-hidden="true" /><span>Add task</span>
</Button>
</div>
</>
) : undefined
}
>
{habit.method === "count" ? (
{method === "count" ? (
<Counter
key={date}
label={habit.name ?? "habit count"}
value={habit.value}
target={habit.target ?? 0}
disabled={blocked}
onChange={(count) => void onUpdate(habit, { count })}
/>
) : habit.method === "manual" ? (
) : method === "manual" ? (
<Checkbox
id={`habit-check-${habit.habitId}`}
hideLabel
label={`${habit.name} done`}
checked={habit.complete}
disabled={blocked}
@@ -622,11 +635,11 @@ function SavedTask({
}) {
const [mode, setMode] = useState<ItemMode | null>(null);
return (
<div className="ds-editable-task">
<li className="ds-editable-task">
<div className="ds-editable-task-row">
<Checkbox
label={task.name}
description={`${scheduleLabel(schedule)}${scheduled ? "" : " · Not scheduled today"}`}
description={!scheduled ? "Not scheduled today" : schedule.type === "daily" ? undefined : scheduleLabel(schedule)}
checked={task.done}
disabled={blocked || !!mode}
onChange={(event) => onCheck(event.target.checked)}
@@ -652,6 +665,6 @@ function SavedTask({
onClose={() => setMode(null)}
/>
)}
</div>
</li>
);
}

View File

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

View File

@@ -21,6 +21,11 @@
--ds-paper: #fff;
--ds-surface: #f5f5f5;
--ds-empty: #eee;
--ds-selection: #edf2ec;
--ds-selection-border: #94ac9b;
--ds-preview-panel: #eff0eb;
--ds-preview-switch: #e6e8e1;
--ds-error: #a12c2c;
--ds-serif: "Instrument Serif", Georgia, serif;
--ds-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
position: relative;
@@ -940,13 +945,6 @@
outline: 2px solid var(--ds-ink);
outline-offset: 2px;
}
.ds-calendar-caption {
display: flex;
justify-content: space-between;
gap: 16px;
@apply type-small;
margin: 17px 0 23px;
}
.ds-calendar-legend {
display: flex;
justify-content: space-between;
@@ -977,13 +975,9 @@
flex: 0 0 11px;
}
/* Calendar detail and task controls share a compact, readable hierarchy. */
.ds-calendar-help { color: var(--ds-secondary); max-width: 30rem; }
.ds-calendar-help summary { display: flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; list-style: none; }
.ds-calendar-help summary::-webkit-details-marker { display: none; }
.ds-calendar-help summary:hover { color: var(--ds-ink); }
.ds-calendar-help p { padding: 4px 0 12px; }
.ds-calendar-caption { align-items: baseline; margin-bottom: 16px; }
.ds-calendar-demo-label { @apply type-small; margin-top: 16px !important; }
.ds-date-inspector {
margin-top: 20px;
background: var(--ds-surface);
border-left: 3px solid var(--calendar-color, var(--ds-ink));
padding: 18px 20px;
@@ -1128,7 +1122,6 @@
.ds-chart-selection {
gap: 10px 18px;
}
.ds-calendar-caption { flex-direction: column; gap: 0; }
.ds-big-value small {
margin-left: 10px;
}
@@ -1214,7 +1207,6 @@
grid-column: 1 / -1;
margin-top: -12px !important;
}
.ds-calendar--compact .ds-calendar-caption { margin-bottom: 12px; }
.ds-task-accordion {
margin-top: 16px;
border: 1px solid var(--ds-rule);
@@ -1411,25 +1403,69 @@
.ds-editable-task-row > .ds-checkbox { flex: 1; min-height: 76px; border-top: 0; }
.ds-checkbox-copy { display: grid; gap: 4px; min-width: 0; }
.ds-checkbox-description { color: var(--ds-secondary); @apply type-small; }
.ds-share-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 36px; align-items: start; }
.ds-share-controls { display: grid; gap: 24px; min-width: 0; }
/* Sharing composer: preview first, quiet controls, one clear delivery action. */
#share-progress { width: min(1120px, calc(100vw - 48px)); }
#share-progress .ds-modal-body { padding: 0; scrollbar-gutter: auto; }
#share-progress .ds-modal-heading h2 { @apply type-title; line-height: 1.15; }
.ds-share-layout { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr); align-items: start; }
.ds-share-controls { display: grid; gap: 24px; min-width: 0; padding: 28px; }
.ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
.ds-share-fieldset legend { padding: 0; margin-bottom: 10px; }
.ds-share-fieldset input:not([type="checkbox"]), .ds-share-fieldset select { width: 100%; min-width: 0; }
.ds-share-habits { display: grid; max-height: 220px; overflow: auto; margin-bottom: 8px; }
.ds-share-habits .ds-checkbox-copy { overflow-wrap: anywhere; }
.ds-share-dates { display: grid; gap: 12px; }
.ds-share-feedback { color: var(--ds-ink); @apply type-small; }
.ds-share-preview-column { display: grid; gap: 14px; min-width: 0; }
.ds-share-preview { display: grid; place-items: center; padding: 22px; min-height: 240px; background: var(--ds-surface); border: 1px solid var(--ds-rule); }
.ds-share-preview img { display: block; width: 100%; height: auto; box-shadow: 0 3px 14px #0000000d; }
.ds-share-preview > div { display: grid; gap: 16px; }
.ds-share-actions { display: flex; flex-wrap: wrap; gap: 12px; }
.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
@media (max-width: 900px) {
.ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
.ds-share-preview { padding: 12px; }
.ds-share-fieldset legend { width: 100%; padding: 0; margin-bottom: 12px; @apply type-small; font-weight: 600; line-height: 20px; }
.ds-share-fieldset input:not([type="checkbox"]) { width: 100%; min-width: 0; @apply type-small; }
.ds-share-count { float: right; color: var(--ds-secondary); @apply type-small; font-weight: 400; font-variant-numeric: tabular-nums; }
.ds-share-habits { display: grid; gap: 8px; max-height: 204px; overflow: auto; padding: 2px; margin: -2px; }
.ds-share-habits .ds-checkbox { border: 1px solid var(--ds-rule); padding: 12px; margin: 0; min-height: 46px; align-items: center; border-radius: 6px; }
.ds-share-habits .ds-checkbox:has(input:checked) { background: var(--ds-selection); border-color: var(--ds-selection-border); }
.ds-share-habits .ds-checkbox-copy { overflow-wrap: anywhere; @apply type-small; line-height: 20px; }
.ds-share-hint, .ds-share-caption { @apply type-small; line-height: 1.5; color: var(--ds-secondary); }
.ds-share-periods, .ds-share-size { display: flex; padding: 3px; gap: 3px; background: var(--ds-surface); border: 1px solid var(--ds-rule); border-radius: 7px; }
.ds-share-periods button, .ds-share-size button { flex: 1; border: 0; background: transparent; color: var(--ds-secondary); padding: 9px 6px; @apply type-small; font-weight: 500; line-height: 18px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
.ds-share-periods button[aria-pressed="true"], .ds-share-size button[aria-pressed="true"] { color: var(--ds-ink); background: var(--ds-paper); box-shadow: 0 1px 4px #00000015; }
.ds-share-periods button:disabled { cursor: default; opacity: .5; }
.ds-share-dates { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.ds-share-privacy { border-top: 1px solid var(--ds-rule); border-bottom: 1px solid var(--ds-rule); }
.ds-share-privacy summary { display: flex; align-items: center; gap: 9px; padding: 16px 0; cursor: pointer; @apply type-small; font-weight: 500; list-style: none; }
.ds-share-privacy summary::-webkit-details-marker { display: none; }
.ds-share-privacy summary span { flex: 1; }
.ds-share-privacy[open] summary > svg:last-child { transform: rotate(180deg); }
.ds-share-privacy .ds-share-fieldset { padding-bottom: 16px; gap: 2px; }
.ds-share-privacy .ds-checkbox { @apply type-small; min-height: 36px; }
.ds-share-destination { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
.ds-share-destination-icon { padding: 10px; border: 1px solid var(--ds-rule); border-radius: 10px; background: var(--ds-surface); }
.ds-share-destination > div { display: grid; gap: 3px; min-width: 0; overflow-wrap: anywhere; }
.ds-share-destination a { @apply type-small; font-weight: 600; text-underline-offset: 3px; }
.ds-share-preview-column { display: grid; gap: 16px; min-width: 0; padding: 28px; background: var(--ds-preview-panel); border-right: 1px solid var(--ds-rule); align-self: stretch; align-content: start; }
.ds-share-preview-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.ds-share-preview-heading > span { display: inline-flex; gap: 8px; align-items: center; @apply type-small; font-weight: 600; }
.ds-share-size { background: var(--ds-preview-switch); border-color: transparent; }
.ds-share-size button { padding: 5px 9px; }
.ds-share-preview { display: grid; place-items: center; min-height: 280px; padding: 24px; border-radius: 10px; background: #313338; }
.ds-share-preview img { display: block; width: 100%; height: auto; border-radius: 4px; box-shadow: 0 6px 24px #00000020; }
.ds-share-preview--thumbnail img { max-width: 360px; }
.ds-share-placeholder { display: grid; justify-items: center; gap: 16px; color: #f2f3f5; text-align: center; @apply type-small; padding: 24px 0; }
.ds-share-placeholder p { max-width: 30ch; }
.ds-share-caption { max-width: 48ch; }
.ds-share-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 20px 28px; border-top: 1px solid var(--ds-rule); background: var(--ds-paper); z-index: 1; }
.ds-share-footer-copy { @apply type-small; line-height: 1.5; min-width: 0; }
.ds-share-footer-copy span { display: block; color: var(--ds-secondary); @apply type-small; margin-top: 3px; }
.ds-share-footer-copy a { text-decoration: underline; text-underline-offset: 3px; }
.ds-share-error { color: var(--ds-error); }
.ds-share-actions { display: flex; gap: 10px; flex-shrink: 0; }
.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; @apply type-small; padding: 12px 16px; border-radius: 6px; }
.ds-share-actions .ds-button--primary { background: #285d49; border-color: #285d49; color: #fff; }
.ds-share-actions .ds-button--primary:hover:not(:disabled) { background: #1d4938; }
@media (max-width: 760px) {
#share-progress { width: calc(100vw - 24px); }
#share-progress .ds-modal-heading h2 { @apply type-title; }
.ds-share-layout { grid-template-columns: minmax(0, 1fr); }
.ds-share-preview-column { padding: 20px; border-right: 0; border-bottom: 1px solid var(--ds-rule); }
.ds-share-controls { padding: 20px; gap: 20px; }
.ds-share-preview { padding: 16px; min-height: 200px; }
.ds-share-footer { padding: 16px 20px; flex-wrap: wrap; gap: 12px; }
.ds-share-actions { width: 100%; }
.ds-share-actions .ds-button { flex: 1; padding-inline: 10px; }
}
.ds-task-add-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
@media (max-width: 640px) {
.ds-habit-title-row { gap: 8px; flex-wrap: wrap; }
@@ -1522,3 +1558,156 @@
.ds-modal-heading h2 { @apply type-title; }
.ds-modal-body { padding: 20px; }
}
/* Compact workspace variants share the same spacing and control language. */
.ds-welcome-panel--compact { display: block; padding: 24px 28px; margin: 28px 0 0; }
.ds-welcome-top { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.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: 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; }
.ds-welcome-body { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 20px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--ds-rule); }
.ds-welcome-panel--compact .ds-welcome-progress { margin: 0; gap: 10px; @apply type-small; }
.ds-workspace-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.ds-welcome-panel--compact .ds-primary-actions { margin: 0; gap: 8px; }
.ds-welcome-panel--compact .ds-utility-row { border: 0; margin: 0; padding: 0; gap: 0; }
.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: 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; }
.ds-habit-list { grid-template-columns: minmax(0, 1fr); gap: 0; }
.ds-habit-list > .ds-habit-chart { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 16px; padding: 24px 0; border-top: 1px solid var(--ds-rule); }
.ds-habit-list .ds-habit-chart-heading { grid-column: 1 / -1; gap: 16px; }
.ds-habit-list .ds-habit-chart-heading > div:first-child { flex: 1 1 280px; }
.ds-habit-list .ds-habit-chart-heading > .ds-counter { flex: 0 1 auto; }
.ds-habit-list .ds-habit-chart-progress { margin: 8px 0 0 !important; justify-content: flex-start; flex-wrap: wrap; gap: 8px 16px; }
.ds-habit-list .ds-habit-chart-progress > span + span { color: var(--ds-ink); }
.ds-habit-list .ds-habit-history { min-width: 0; max-width: 100%; justify-self: end; margin-top: 8px; }
.ds-habit-list .ds-habit-history:has([aria-expanded="true"]) { grid-column: 1 / -1; justify-self: stretch; }
.ds-habit-list .ds-task-accordion { grid-column: 1 / -1; }
.ds-habit-history-toggle { display: inline-flex; align-items: center; gap: 8px; @apply type-small; color: var(--ds-secondary); }
.ds-habit-history-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
.ds-habit-history-panel { margin-top: 16px; }
.ds-save-notice { margin: 0 !important; padding-top: 12px; min-height: 0; }
.ds-save-notice:empty { padding: 0; }
.ds-counter { flex-wrap: wrap; max-width: 100%; gap: 4px; padding: 4px; border: 1px solid var(--ds-rule); border-radius: 6px; background: var(--ds-paper); }
.ds-counter-input { box-sizing: border-box; width: 7ch; min-width: 56px; min-height: 44px; padding: 8px; border: 1px solid transparent; background: var(--ds-surface); color: var(--ds-ink); text-align: center; font: inherit; font-variant-numeric: tabular-nums; border-radius: 3px; }
.ds-counter-input:hover:not(:disabled) { border-color: var(--ds-control-border); }
.ds-counter .ds-muted { padding-right: 8px; font-variant-numeric: tabular-nums; }
.ds-counter .ds-button.ds-counter-save { width: auto; padding: 8px 12px; @apply type-small; font-weight: 500; }
.ds-counter-error { flex-basis: 100%; padding: 4px 8px; @apply type-small; }
.ds-habit-starters { margin-bottom: 16px; padding: 16px; background: var(--ds-surface); border-radius: 4px; }
.ds-habit-starter-options { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.ds-habit-starter-options .ds-button { @apply type-small; background: var(--ds-paper); }
.ds-habit-starter-options .ds-button--text { background: transparent; }
.ds-color-swatches--compact { flex-wrap: wrap; }
@media (max-width: 700px) {
.ds-welcome-panel--compact { padding: 20px; margin-top: 20px; }
.ds-welcome-top { align-items: flex-start; flex-wrap: wrap; gap: 16px; }
.ds-welcome-panel--compact .ds-welcome-calendar { text-align: left; }
.ds-welcome-panel--compact .ds-welcome-timezone { justify-content: flex-start; }
.ds-welcome-body { margin-top: 20px; padding-top: 16px; gap: 16px; }
.ds-workspace-actions { gap: 8px; }
.ds-habit-list .ds-habit-chart-heading { align-items: flex-start; }
.ds-habit-list .ds-habit-chart-heading > div:first-child { flex-basis: 100%; }
.ds-habit-list > .ds-habit-chart { grid-template-columns: minmax(0, 1fr); }
.ds-habit-list .ds-habit-history { justify-self: start; }
.ds-habits-section .ds-section-top { gap: 16px; align-items: flex-start; }
}
@media (forced-colors: active) {
.ds-habit-filters .ds-button[aria-pressed="true"] { border-color: Highlight; }
.ds-counter-input { border-color: ButtonText; }
}
/* 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: 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-routine-items { list-style: none; padding: 0; margin: 0; }
.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: 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; } }