Compare commits
14 Commits
892487eb4c
...
00729096bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00729096bf | ||
|
|
9e9871c08a | ||
|
|
63e1573dae | ||
|
|
2f67b775bf | ||
|
|
1f017caee5 | ||
|
|
b832b5734e | ||
|
|
3945998fa8 | ||
|
|
7ebf936218 | ||
|
|
0d0aca5604 | ||
|
|
bfba95b1ed | ||
|
|
7ebbf4ed6c | ||
|
|
419bdce36b | ||
|
|
9f8154e412 | ||
|
|
a2f5616b96 |
4
PRD.md
4
PRD.md
@@ -1,11 +1,11 @@
|
||||
# Minabot — Habit Tracking PRD
|
||||
# Mina Habits — Habit Tracking PRD
|
||||
|
||||
Status: Initial product requirements
|
||||
Date: 2026-09-04
|
||||
|
||||
## Application intent
|
||||
|
||||
Minabot helps people record daily habit progress and understand their consistency through GitHub-style calendar heatmaps. Users can inspect each habit independently and combine selected habits into shared charts.
|
||||
Mina Habits helps people record daily habit progress and understand their consistency through GitHub-style calendar heatmaps. Users can inspect each habit independently and combine selected habits into shared charts.
|
||||
|
||||
Individual charts answer, “How much progress did I make on this habit?” Combined charts answer, “How many of the habits due that day did I fully complete?” Partial progress is visible and useful, but does not count as a completed habit in a combined chart.
|
||||
|
||||
|
||||
28
README.md
28
README.md
@@ -1,4 +1,4 @@
|
||||
# Minabot
|
||||
# Mina Habits
|
||||
|
||||
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router
|
||||
client includes a homepage for signed-out visitors and a personal habit dashboard.
|
||||
@@ -67,7 +67,9 @@ its own recurrence. Both habit and task schedules support daily, selected weekda
|
||||
day intervals, and week intervals with an editable start date and weekday.
|
||||
Archive asks for confirmation in a modal and preserves earlier history.
|
||||
**Archived habits** opens the archive modal, with history inspection and restoration
|
||||
starting today. Task deletion uses the same modal confirmation. Tasks
|
||||
starting today. **Delete permanently** removes an archived habit and all its history
|
||||
after confirmation; it also removes the habit from combined charts and deletes
|
||||
charts left empty. Task deletion uses the same modal confirmation. Tasks
|
||||
remain manageable on days off, while their checkboxes stay disabled.
|
||||
Settings, sharing, new habits, and historical check-ins also open in native dialogs.
|
||||
Dialogs keep the background inactive, contain keyboard focus, support Escape and
|
||||
@@ -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
|
||||
|
||||
2
bun.lock
2
bun.lock
@@ -3,7 +3,7 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-react-template",
|
||||
"name": "mina-habits",
|
||||
"dependencies": {
|
||||
"@nivo/calendar": "^0.99.0",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
13
docs/design/mina-habits-icon.md
Normal file
13
docs/design/mina-habits-icon.md
Normal 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.
|
||||
BIN
docs/design/mina-habits-icon.png
Normal file
BIN
docs/design/mina-habits-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 894 KiB |
21
docs/discord-sharing-design.md
Normal file
21
docs/discord-sharing-design.md
Normal 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.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "minabot",
|
||||
"name": "mina-habits",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
1
scripts/share-card-preview.html
Normal file
1
scripts/share-card-preview.html
Normal 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>
|
||||
5
scripts/share-card-preview.ts
Normal file
5
scripts/share-card-preview.ts
Normal 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}`);
|
||||
30
scripts/share-card-preview.tsx
Normal file
30
scripts/share-card-preview.tsx
Normal 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>);
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
BIN
src/assets/apple-touch-icon.png
Normal file
BIN
src/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/favicon.png
Normal file
BIN
src/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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> :
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
78
src/components/HabitForm.test.tsx
Normal file
78
src/components/HabitForm.test.tsx
Normal 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();
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/components/ThemeProvider.tsx
Normal file
47
src/components/ThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
103
src/components/design-system/Counter.test.tsx
Normal file
103
src/components/design-system/Counter.test.tsx
Normal 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);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
50
src/habits/permanent-delete.test.ts
Normal file
50
src/habits/permanent-delete.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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)) }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
26
src/lib/habit-palette.test.ts
Normal file
26
src/lib/habit-palette.test.ts
Normal 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
49
src/lib/habit-palette.ts
Normal 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);
|
||||
}
|
||||
24
src/lib/progress-card.test.ts
Normal file
24
src/lib/progress-card.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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
19
src/lib/theme.test.ts
Normal 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
9
src/lib/theme.ts
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }); }
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user