diff --git a/README.md b/README.md index cb0a6a9..b5d0edd 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Zaati OS is an open-source, private-by-default personal operating system. Schedu You choose the LLM. You own the data. You control the deployment. Zaati OS has no hosted account, required telemetry, central database, or platform fee. +Explore the [three populated dashboard examples](examples/README.md), with local screenshots, forkable JSON compositions, and a documented [design system](docs/design-system.md). Run `npm run examples` after installing dependencies. +

Animated preview of the Zaati OS synthetic dashboard

diff --git a/docs/design-system.md b/docs/design-system.md index 9e5d50f..dbf1ef1 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -37,7 +37,35 @@ Charts are explorable rather than decorative. Line points and bars expose the sa Use semantic tokens such as `background`, `card`, `muted`, `primary`, `warning`, and chart tokens. Do not hardcode provider or source colors inside components. -## Accessibility +## Dashboard composition recipes + +The [populated examples](../examples/README.md) are executable composition references. They use the production renderer and existing snapshot schema. Personal data never belongs in examples or screenshots. + +| Element | Rule | Reason | +| ------------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| Primary metric | First metric uses `primary` / `primary-foreground`; values use 24–32px tabular numerals | Establish one visual anchor without a marketing hero | +| Supporting metrics | Semantic accent edge, 9% tinted surface, full-contrast labels | Separate measures while preserving legibility | +| Cartesian charts | Two-column span preferred; 480px minimum canvas with contained horizontal scroll | Keep tick labels readable at 320px | +| Donut | Stack inside narrow cards; switch to side-by-side at a 480px container | Respond to the card rather than viewport width | +| Exact values | Native `View data` disclosure, formatted table; full donut legend values | Make precision available to touch and keyboard users | +| Evidence | Keep dates, stale status, and assumptions adjacent to the decision | Avoid implying live or complete data | +| Tables | Prefer two columns or full width for prose-heavy rows | Avoid unreadable evidence columns | + +Categorical chart colors distinguish series, not good and bad. Line styles also differ so color is not the only encoding. A numeric increase is not automatically a favorable outcome; producers should supply a change only when its meaning is clear. Metric emphasis indicates priority, not health. + +### Motion and interaction + +Entrance motion runs once, with no perpetual pulsing or autoplay. Blocks settle over 320ms; line and progress reveals use a 520ms clip wipe so the data's geometry never stretches sideways; bars grow over 480ms. Chart interaction changes emphasis without moving the measured endpoint. Static cards remain in place on hover. Reduced-motion preference reduces animations and transitions to 0.01ms. + +Donut legend buttons toggle persistent selection with click, tap, Enter, or Space. Hover and focus provide temporary inspection; `aria-pressed` describes selection only. Long category names wrap. The center uses compact notation; full values remain in the legend. Cartesian data is also available through a native disclosure, with no pointer precision required. + +### References and boundaries + +Composition research: [shadcn dashboard-01](https://ui.shadcn.com/blocks), [chart gallery](https://ui.shadcn.com/charts/area), and [chart documentation](https://ui.shadcn.com/docs/components/chart), reviewed September 5, 2026. Adopted the strong metric row, dominant visualization, contextual table, and progressive disclosure patterns. Zaati retains its owned shadcn-compatible primitives and lightweight SVG renderer; it does not add Recharts or copy reference code. + +Palette changes belong in semantic tokens or validated instance themes. Snapshots select content, spans, and audited block kinds, never CSS, JavaScript, SVG, or arbitrary component trees. The showcase build is explicit and ignores local private configuration. Normal personal builds retain the existing behavior. + +## Accessibility verification All controls need accessible names, keyboard focus, usable touch targets, and sufficient contrast. Color cannot be the only status signal. Charts need an accessible label and should have table or narrative evidence in the snapshot when exact values matter. diff --git a/docs/ui-audit-showcase.md b/docs/ui-audit-showcase.md new file mode 100644 index 0000000..89a1cba --- /dev/null +++ b/docs/ui-audit-showcase.md @@ -0,0 +1,48 @@ +# UI audit: template dashboard showcase + +Reviewed September 5–6, 2026. Scope: `zaati-os` only. No private dashboard, source snapshot store, scheduled task, or deployment configuration was changed. + +## Intent + +A reader should find the leading answer, understand its evidence, and identify a concrete next step. Visual contrast and motion support that sequence. Examples must be useful to a new forker without personal knowledge or new renderer code. + +## Findings and implementation + +| Finding | Resolution | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| Metrics had nearly the same weight as labels | Larger tabular values, primary-color leading metric, semantic accent edges and tinted supporting tiles | +| New tinted tiles initially reduced label contrast | Full-contrast tile labels; verified in the browser before capture | +| Static cards lifted on hover as if clickable | Removed lift while preserving quiet border feedback | +| Line entrance stretched the measured geometry | Clip reveal keeps points in their correct coordinates throughout motion | +| Bar hover moved measured endpoints | Removed positional scaling; highlight changes emphasis only | +| Narrow SVG charts shrank tick labels excessively | Minimum 480px chart canvas, keyboard-focusable contained scroll region | +| Exact-value tables were screen-reader-only | Native `View data` disclosure exposes formatted evidence to everyone | +| Donut layout responded to viewport, not card width | Container query switches to horizontal composition only when the card has space | +| Donut legend did not have persistent touch selection | Click/tap toggle and native keyboard activation, separate selected and inspected state | +| Large donut totals and long labels could collide | Compact center value, full legend values, wrapped category names | +| Example rows left gaps or squeezed tables | Revised spans and reading order; full-width holdings/work tables and wider source evidence | +| Work distribution made the neighboring list excessively tall | Wide donut with side-by-side legend, narrow action list | +| Headline metrics lacked supporting example evidence | Removed unsupported goal-pace, monthly-change, and due-count headlines in showcase fixtures | +| Daily schedule contradicted its buffer and reply windows | Buffer starts at 15:30; urgent reply is scheduled before the protected 08:30 focus window | +| Screenshots could race lazy loading and animations | Wait for page readiness, chart resolution, and animation completion | +| Historical demo screenshots looked entirely expired | Review clock pinned to the latest synthetic snapshot; actual stale-source warnings remain | +| Public captures lacked an explicit synthetic-only guard | Screenshot capture rejects datasets not marked entirely synthetic | +| Forkers lacked isolated populated examples | Three complete envelopes under `/examples`, explicit local runner, schema/privacy validation and isolation regression test | + +## Verification protocol + +The full default-page pass also caught low contrast on a negative change in Weekly review. Change values now use readable neutral text, since a negative number alone does not imply a bad outcome. The money showcase reconciles total cash runway to the monthly living budget (22,600 / 3,100 = 7.3 months), and separately measures the emergency reserve (15,800 / 3,100 = 5.1 months). + +The production build is exercised in local Chromium at 320, 390, 768, 1024, and 1440 CSS pixels, in light and dark modes. The showcase covers onboarding, component lab, daily, money, and work views. The normal build additionally covers agenda, inbox, news, and review. Automated checks include axe WCAG A/AA rules, document overflow, chart disclosure, selection toggling, keyboard Space activation, reduced-motion CSS, mobile navigation, theme studio, and prompt drawer. The component catalog is also checked with ocean, plum, and sand palettes in both themes. + +Full-page PNGs in [examples/screenshots](../examples/screenshots) are rendered from synthetic envelopes with the production components. Visual inspection covers hierarchy, wrapping, chart legibility, card balance, source dates, and meaningful next actions. The examples use the default sage palette; alternative palettes remain user-selectable. + +The repository gate is `npm run check`: formatting, lint, repository rules, schema and privacy validation, production build, coverage, tutorial, performance budgets, and browser accessibility. The separate showcase capture command is documented in [examples/README.md](../examples/README.md). + +## Practical limits + +The full repository check passed with 53 tests. Coverage was 97.57% lines, 86.68% branches, and 94.81% functions. The normal demo build measured 111 KB JavaScript, 10 KB CSS, and 87 KB dashboard data, all gzip and within the existing budgets. Screenshot evidence includes desktop and mobile, light and dark, for all three examples. + +This audit uses Chromium emulation and browser automation, not a physical-device lab or a human screen-reader session. Axe is a baseline, not accessibility certification. At narrow widths, charts and wide tables intentionally scroll within their cards; the page itself must not overflow. Financial values are illustrative and formatted/rounded for display. No live account data or remote connector was used. + +The public template's security and snapshot boundary is unchanged. These improvements add no chart library, external font, telemetry, or arbitrary snapshot-controlled markup. Design decisions and adaptation rules are maintained in the [design system](design-system.md). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..91a6bcd --- /dev/null +++ b/examples/README.md @@ -0,0 +1,43 @@ +# Dashboard examples + +Three complete, synthetic dashboards rendered by the production shadcn-compatible components. These are starter compositions for forkers, not personal records or live feeds. + +```sh +npm ci +npm run examples +``` + +Open the local Vite URL with one of these query strings: + +| Dashboard | URL query | Reading order | +| -------------- | ------------------------------------------------ | --------------------------------------------------------------------- | +| Daily cockpit | `?view=overview%3Adaily&at=2026-08-24T07:35:00Z` | Available focus, attention pattern, today's schedule, next actions | +| Money overview | `?view=money%3Apulse&at=2026-08-24T07:35:00Z` | Tracked value, scenarios and allocation, monthly plan, exact holdings | +| Work focus | `?view=work%3Afocus&at=2026-08-24T07:35:00Z` | Work in progress, state distribution, next moves, delivery evidence | + +`at` fixes the review clock to the fixture date. Without it, historical examples correctly appear stale. Every screen retains source dates and uncertainty. Scenarios are illustrative, not predictions. + +## Adapt an example + +Complete envelopes live in `snapshots/{domain}/{source}/2026-08-24.json`. Keep the envelope and use the existing eleven block kinds. Change content, block order, spans, and instance theme before considering renderer changes. Keep a two-column chart next to a one-column supporting block; use full-width tables when text needs room. Put the most important metric first because it receives the high-contrast treatment. + +Examples are validated by `npm run data:validate`, including public/synthetic privacy requirements. `npm run examples` explicitly ignores private snapshots and local instance configuration. It builds the shared ignored `public/data/dashboard-data.json`, so stop other local dev servers first. Run `npm run dev` or `npm run build` afterward to restore normal instance selection. Never set `ZAATI_EXAMPLES=true` in a personal production deployment. + +No new block types, provider integration, remote fonts, or chart dependency are required. Locale, currency, time zone, density, and palette remain instance settings. Do not mix fixture currencies when adapting examples. + +## Reproduce the screenshots + +```sh +ZAATI_EXAMPLES=true npm run build +ZAATI_CAPTURE_SCREENSHOTS=true ZAATI_SCREENSHOT_DIR=examples/screenshots npm run accessibility:check +``` + +Install Chrome or set `CHROME_PATH`. Capture rejects non-synthetic datasets, waits for lazy charts and entrance animations, then audits five widths in both themes. PNGs are actual local Chromium renders, not mockups. + +| Example | Desktop light | Desktop dark | Mobile | +| ------- | -------------------------------------------- | ------------------------------------------ | ------------------------------------------- | +| Daily | [Light](screenshots/overview-1440-light.png) | [Dark](screenshots/overview-1440-dark.png) | [390px](screenshots/overview-390-light.png) | +| Money | [Light](screenshots/money-1440-light.png) | [Dark](screenshots/money-1440-dark.png) | [390px](screenshots/money-390-light.png) | +| Work | [Light](screenshots/work-1440-light.png) | [Dark](screenshots/work-1440-dark.png) | [390px](screenshots/work-390-light.png) | + +See the [design system](../docs/design-system.md) and [UI audit](../docs/ui-audit-showcase.md). diff --git a/examples/screenshots/money-1440-dark.png b/examples/screenshots/money-1440-dark.png new file mode 100644 index 0000000..8e95ed3 Binary files /dev/null and b/examples/screenshots/money-1440-dark.png differ diff --git a/examples/screenshots/money-1440-light.png b/examples/screenshots/money-1440-light.png new file mode 100644 index 0000000..fdfe3b1 Binary files /dev/null and b/examples/screenshots/money-1440-light.png differ diff --git a/examples/screenshots/money-390-dark.png b/examples/screenshots/money-390-dark.png new file mode 100644 index 0000000..1528193 Binary files /dev/null and b/examples/screenshots/money-390-dark.png differ diff --git a/examples/screenshots/money-390-light.png b/examples/screenshots/money-390-light.png new file mode 100644 index 0000000..cbfb1b2 Binary files /dev/null and b/examples/screenshots/money-390-light.png differ diff --git a/examples/screenshots/overview-1440-dark.png b/examples/screenshots/overview-1440-dark.png new file mode 100644 index 0000000..5a43884 Binary files /dev/null and b/examples/screenshots/overview-1440-dark.png differ diff --git a/examples/screenshots/overview-1440-light.png b/examples/screenshots/overview-1440-light.png new file mode 100644 index 0000000..d6d3a9b Binary files /dev/null and b/examples/screenshots/overview-1440-light.png differ diff --git a/examples/screenshots/overview-390-dark.png b/examples/screenshots/overview-390-dark.png new file mode 100644 index 0000000..cd81a20 Binary files /dev/null and b/examples/screenshots/overview-390-dark.png differ diff --git a/examples/screenshots/overview-390-light.png b/examples/screenshots/overview-390-light.png new file mode 100644 index 0000000..519af3c Binary files /dev/null and b/examples/screenshots/overview-390-light.png differ diff --git a/examples/screenshots/work-1440-dark.png b/examples/screenshots/work-1440-dark.png new file mode 100644 index 0000000..2a18804 Binary files /dev/null and b/examples/screenshots/work-1440-dark.png differ diff --git a/examples/screenshots/work-1440-light.png b/examples/screenshots/work-1440-light.png new file mode 100644 index 0000000..3a8363f Binary files /dev/null and b/examples/screenshots/work-1440-light.png differ diff --git a/examples/screenshots/work-390-dark.png b/examples/screenshots/work-390-dark.png new file mode 100644 index 0000000..aaaf946 Binary files /dev/null and b/examples/screenshots/work-390-dark.png differ diff --git a/examples/screenshots/work-390-light.png b/examples/screenshots/work-390-light.png new file mode 100644 index 0000000..fffdcbb Binary files /dev/null and b/examples/screenshots/work-390-light.png differ diff --git a/examples/snapshots/money/pulse/2026-08-24.json b/examples/snapshots/money/pulse/2026-08-24.json new file mode 100644 index 0000000..2f27ab0 --- /dev/null +++ b/examples/snapshots/money/pulse/2026-08-24.json @@ -0,0 +1,626 @@ +{ + "schema_version": "0.1.1", + "schema_ref": "schemas/domains/money.schema.json", + "snapshot_id": "money:pulse:2026-08-24", + "source_id": "money:pulse", + "domain": "money", + "source": "pulse", + "generated_at": "2026-08-24T07:00:00Z", + "effective_period": { + "start": "2026-08-23T07:00:00Z", + "end": "2026-08-24T07:00:00Z", + "timezone": "UTC" + }, + "status": "partial", + "producer": { + "worker_id": "money-pulse-daily", + "workflow": "example", + "version": "0.1.1" + }, + "sources": [ + { + "label": "Demo banking connection", + "status": "ok", + "as_of": "2026-08-24T06:40:00Z", + "reference": "synthetic://banking" + }, + { + "label": "Demo brokerage summary", + "status": "ok", + "as_of": "2026-08-24T06:35:00Z", + "reference": "synthetic://brokerage" + }, + { + "label": "Demo crypto summary", + "status": "stale", + "as_of": "2026-08-23T18:00:00Z", + "reference": "synthetic://crypto" + } + ], + "freshness": { + "expires_at": "2026-08-25T19:00:00Z" + }, + "quality": { + "confidence": "medium", + "warnings": ["One synthetic source has not refreshed today."] + }, + "privacy": { + "classification": "public", + "contains_personal_data": false, + "synthetic": true + }, + "data": { + "title": "A clear view of what you have.", + "summary": "104,800 synthetic dollars tracked. Cash covers 7.3 months of the 3,100 monthly living budget. Keep contributions steady, but refresh stale crypto prices before judging daily movement.", + "attention": "low", + "facts": { + "measures": [ + { + "id": "tracked-value", + "label": "Tracked value", + "kind": "money", + "value": 104800, + "currency": "USD", + "as_of": "2026-08-24T06:40:00Z" + }, + { + "id": "invested-value", + "label": "Invested value", + "kind": "money", + "value": 82200, + "currency": "USD", + "as_of": "2026-08-24T06:35:00Z" + }, + { + "id": "savings-rate", + "label": "Savings rate", + "kind": "percent", + "value": 38.5, + "as_of": "2026-08-24T06:40:00Z" + }, + { + "id": "runway", + "label": "Cash runway", + "kind": "duration", + "value": 7.3, + "unit": "months", + "as_of": "2026-08-24T06:40:00Z" + }, + { + "id": "contribution-rate", + "label": "Monthly contribution", + "kind": "money", + "value": 1800, + "currency": "USD", + "as_of": "2026-08-24T06:40:00Z" + } + ], + "goals": [ + { + "id": "reserve", + "label": "Emergency reserve", + "current": 5.1, + "target": 6, + "unit": "months" + }, + { + "id": "investing", + "label": "Annual investing target", + "current": 14400, + "target": 21600, + "unit": "USD" + } + ], + "accounts": [ + { + "id": "daily-cash", + "label": "Daily cash", + "type": "cash", + "status": "connected", + "balance": 6800, + "currency": "USD", + "as_of": "2026-08-24T06:40:00Z" + }, + { + "id": "reserve-cash", + "label": "Emergency reserve", + "type": "cash", + "status": "connected", + "balance": 15800, + "currency": "USD", + "as_of": "2026-08-24T06:40:00Z" + }, + { + "id": "long-term", + "label": "Long-term portfolio", + "type": "investment", + "status": "connected", + "balance": 77100, + "currency": "USD", + "as_of": "2026-08-24T06:35:00Z" + }, + { + "id": "digital-assets", + "label": "Digital assets", + "type": "investment", + "status": "stale", + "balance": 5100, + "currency": "USD", + "as_of": "2026-08-23T18:00:00Z" + } + ], + "holdings": [ + { + "id": "global-equity", + "label": "Global equity fund", + "symbol": "WORLD", + "asset_type": "fund", + "units": 420, + "unit_price": 118.5, + "market_value": 49770, + "currency": "USD", + "weight_percent": 47.5, + "day_change_percent": 0.6, + "as_of": "2026-08-24T06:35:00Z" + }, + { + "id": "quality-stock", + "label": "Quality companies basket", + "symbol": "QUAL", + "asset_type": "stock", + "units": 160, + "unit_price": 121.25, + "market_value": 19400, + "currency": "USD", + "weight_percent": 18.5, + "day_change_percent": -0.2, + "as_of": "2026-08-24T06:35:00Z" + }, + { + "id": "bond-fund", + "label": "Short bond fund", + "symbol": "BOND", + "asset_type": "bond", + "units": 155, + "unit_price": 51.16, + "market_value": 7930, + "currency": "USD", + "weight_percent": 7.6, + "day_change_percent": 0.1, + "as_of": "2026-08-24T06:35:00Z" + }, + { + "id": "bitcoin", + "label": "Bitcoin", + "symbol": "BTC", + "asset_type": "crypto", + "units": 0.062, + "unit_price": 65000, + "market_value": 4030, + "currency": "USD", + "weight_percent": 3.8, + "day_change_percent": 1.4, + "as_of": "2026-08-23T18:00:00Z" + }, + { + "id": "ethereum", + "label": "Ethereum", + "symbol": "ETH", + "asset_type": "crypto", + "units": 0.42, + "unit_price": 2548, + "market_value": 1070, + "currency": "USD", + "weight_percent": 1, + "day_change_percent": -0.8, + "as_of": "2026-08-23T18:00:00Z" + } + ], + "projections": [ + { + "year": 2026, + "scenario": "conservative", + "value": 104800, + "currency": "USD" + }, + { + "year": 2026, + "scenario": "baseline", + "value": 104800, + "currency": "USD" + }, + { + "year": 2026, + "scenario": "stretch", + "value": 104800, + "currency": "USD" + }, + { + "year": 2027, + "scenario": "conservative", + "value": 124000, + "currency": "USD" + }, + { + "year": 2027, + "scenario": "baseline", + "value": 128000, + "currency": "USD" + }, + { + "year": 2027, + "scenario": "stretch", + "value": 132000, + "currency": "USD" + }, + { + "year": 2028, + "scenario": "conservative", + "value": 142000, + "currency": "USD" + }, + { + "year": 2028, + "scenario": "baseline", + "value": 150000, + "currency": "USD" + }, + { + "year": 2028, + "scenario": "stretch", + "value": 160000, + "currency": "USD" + }, + { + "year": 2029, + "scenario": "conservative", + "value": 158000, + "currency": "USD" + }, + { + "year": 2029, + "scenario": "baseline", + "value": 166000, + "currency": "USD" + }, + { + "year": 2029, + "scenario": "stretch", + "value": 181000, + "currency": "USD" + }, + { + "year": 2030, + "scenario": "conservative", + "value": 171000, + "currency": "USD" + }, + { + "year": 2030, + "scenario": "baseline", + "value": 178000, + "currency": "USD" + }, + { + "year": 2030, + "scenario": "stretch", + "value": 205000, + "currency": "USD" + } + ] + }, + "presentation": { + "layout": "dashboard", + "blocks": [ + { + "id": "money-metrics", + "kind": "metric-group", + "title": "Current setup", + "description": "Cash runway uses all cash divided by the 3,100 monthly living budget.", + "span": "full", + "metrics": [ + { + "label": "Tracked value", + "value": 104800, + "format": "currency", + "tone": "positive" + }, + { + "label": "Invested", + "value": 82200, + "format": "currency", + "tone": "info" + }, + { + "label": "Cash", + "value": 22600, + "format": "currency", + "tone": "neutral" + }, + { + "label": "Runway", + "value": 7.3, + "unit": "months", + "tone": "positive" + } + ] + }, + { + "id": "five-year-projection", + "kind": "line-chart", + "title": "Three paths to 2030", + "description": "Illustrative annual balances, 2026\u20132030. These are scenarios, not predicted returns.", + "span": "two", + "y_format": "currency", + "series": [ + { + "key": "conservative", + "label": "Conservative" + }, + { + "key": "baseline", + "label": "Baseline" + }, + { + "key": "stretch", + "label": "Stretch" + } + ], + "points": [ + { + "x": "2026", + "values": { + "conservative": 104800, + "baseline": 104800, + "stretch": 104800 + } + }, + { + "x": "2027", + "values": { + "conservative": 124000, + "baseline": 128000, + "stretch": 132000 + } + }, + { + "x": "2028", + "values": { + "conservative": 142000, + "baseline": 150000, + "stretch": 160000 + } + }, + { + "x": "2029", + "values": { + "conservative": 158000, + "baseline": 166000, + "stretch": 181000 + } + }, + { + "x": "2030", + "values": { + "conservative": 171000, + "baseline": 178000, + "stretch": 205000 + } + } + ] + }, + { + "id": "allocation", + "kind": "donut-chart", + "title": "Portfolio allocation", + "description": "Cash is included so the chart reconciles to the tracked total.", + "span": "one", + "value_format": "currency", + "center_label": "Tracked total", + "segments": [ + { + "label": "Global equity fund", + "value": 49770 + }, + { + "label": "Quality stocks", + "value": 19400 + }, + { + "label": "Bonds", + "value": 7930 + }, + { + "label": "Crypto", + "value": 5100 + }, + { + "label": "Cash", + "value": 22600 + } + ] + }, + { + "id": "monthly-flow", + "kind": "bar-chart", + "title": "Monthly plan", + "description": "Where the illustrative take-home amount is assigned this month.", + "span": "two", + "value_format": "currency", + "bars": [ + { + "label": "Living", + "value": 3100, + "tone": "neutral" + }, + { + "label": "Investing", + "value": 1800, + "tone": "positive" + }, + { + "label": "Reserve", + "value": 700, + "tone": "info" + }, + { + "label": "Flexible", + "value": 900, + "tone": "neutral" + } + ] + }, + { + "id": "money-goals", + "kind": "progress", + "title": "Explicit goals", + "span": "one", + "items": [ + { + "label": "Emergency reserve", + "value": 5.1, + "max": 6, + "value_label": "5.1 of 6 months", + "tone": "positive" + }, + { + "label": "Annual investing", + "value": 14400, + "max": 21600, + "value_label": "$14.4k of $21.6k", + "tone": "info" + } + ], + "description": "The 15,800 emergency reserve covers 5.1 months of living costs; daily cash is excluded." + }, + { + "id": "holdings", + "kind": "table", + "title": "Invested holdings", + "description": "Rounded synthetic positions. Quote timestamps and stale pricing are listed in Connections below.", + "span": "full", + "columns": [ + { + "key": "asset", + "label": "Asset" + }, + { + "key": "type", + "label": "Type" + }, + { + "key": "units", + "label": "Units", + "format": "number" + }, + { + "key": "price", + "label": "Price", + "format": "currency" + }, + { + "key": "value", + "label": "Value", + "format": "currency" + }, + { + "key": "weight", + "label": "Weight", + "format": "percent" + }, + { + "key": "day", + "label": "Day", + "format": "percent" + } + ], + "rows": [ + { + "asset": "WORLD \u00b7 Global equity fund", + "type": "Fund", + "units": 420, + "price": 118.5, + "value": 49770, + "weight": 47.5, + "day": 0.6 + }, + { + "asset": "QUAL \u00b7 Quality companies", + "type": "Stock basket", + "units": 160, + "price": 121.25, + "value": 19400, + "weight": 18.5, + "day": -0.2 + }, + { + "asset": "BOND \u00b7 Short bond fund", + "type": "Bond", + "units": 155, + "price": 51.16, + "value": 7930, + "weight": 7.6, + "day": 0.1 + }, + { + "asset": "BTC \u00b7 Bitcoin", + "type": "Crypto", + "units": 0.062, + "price": 65000, + "value": 4030, + "weight": 3.8, + "day": 1.4 + }, + { + "asset": "ETH \u00b7 Ethereum", + "type": "Crypto", + "units": 0.42, + "price": 2548, + "value": 1070, + "weight": 1, + "day": -0.8 + } + ] + }, + { + "id": "account-connections", + "kind": "list", + "title": "Connections", + "description": "Only normalized totals are retained\u2014never account numbers or statements.", + "span": "full", + "items": [ + { + "id": "bank", + "title": "Banking summary", + "description": "Daily cash and emergency reserve refreshed at 06:40 UTC.", + "status": "Connected", + "tone": "positive" + }, + { + "id": "brokerage", + "title": "Brokerage summary", + "description": "Three long-term positions refreshed at 06:35 UTC.", + "status": "Connected", + "tone": "positive" + }, + { + "id": "crypto", + "title": "Crypto summary", + "description": "Two positions last priced yesterday at 18:00 UTC.", + "status": "Stale", + "tone": "warning" + } + ] + }, + { + "id": "money-warning", + "kind": "notice", + "title": "Projection, not promise", + "body": "These scenarios assume a fixed monthly contribution and illustrative returns. Crypto prices are stale, taxes and fees are excluded, and the page is context\u2014not financial advice or an automated decision.", + "tone": "warning", + "span": "full" + } + ] + } + } +} diff --git a/examples/snapshots/overview/daily/2026-08-24.json b/examples/snapshots/overview/daily/2026-08-24.json new file mode 100644 index 0000000..ea9067d --- /dev/null +++ b/examples/snapshots/overview/daily/2026-08-24.json @@ -0,0 +1,399 @@ +{ + "schema_version": "0.1.1", + "schema_ref": "schemas/domains/overview.schema.json", + "snapshot_id": "overview:daily:2026-08-24", + "source_id": "overview:daily", + "domain": "overview", + "source": "daily", + "generated_at": "2026-08-24T07:35:00Z", + "effective_period": { + "start": "2026-08-24T00:00:00Z", + "end": "2026-08-24T23:59:59Z", + "timezone": "UTC" + }, + "status": "partial", + "producer": { + "worker_id": "overview-daily", + "workflow": "example", + "version": "0.1.1" + }, + "sources": [ + { + "label": "Synthetic agenda", + "status": "ok", + "as_of": "2026-08-24T07:20:00Z", + "reference": "agenda:primary:2026-08-24" + }, + { + "label": "Synthetic inbox", + "status": "ok", + "as_of": "2026-08-24T07:15:00Z", + "reference": "inbox:attention:2026-08-24" + }, + { + "label": "Synthetic work", + "status": "ok", + "as_of": "2026-08-24T07:10:00Z", + "reference": "work:focus:2026-08-24" + }, + { + "label": "Synthetic money", + "status": "stale", + "as_of": "2026-08-23T18:00:00Z", + "reference": "money:pulse:2026-08-24" + }, + { + "label": "Synthetic news", + "status": "ok", + "as_of": "2026-08-24T06:55:00Z", + "reference": "news:briefing:2026-08-24" + } + ], + "freshness": { + "expires_at": "2026-08-25T13:35:00Z", + "next_expected_at": "2026-08-25T07:35:00Z" + }, + "quality": { + "confidence": "medium", + "warnings": ["The synthetic money source is intentionally marked stale to demonstrate a real failure state."] + }, + "privacy": { + "classification": "public", + "contains_personal_data": false, + "synthetic": true + }, + "data": { + "title": "Make room for your best work.", + "summary": "Two focus windows. Four replies to batch. One buffer to keep. Start with the work that is ready, and let the rest wait its turn.", + "attention": "medium", + "facts": { + "decisions": [ + { + "id": "protect-focus", + "title": "Protect the first focus block", + "reason": "The agenda and work queue both identify it as the highest-value window.", + "source_ids": ["agenda:primary:2026-08-24", "work:focus:2026-08-24"] + }, + { + "id": "batch-replies", + "title": "Use two bounded reply windows", + "reason": "The inbox has four actionable messages but only two carry hard timing constraints.", + "source_ids": ["inbox:attention:2026-08-24", "agenda:primary:2026-08-24"] + }, + { + "id": "defer-money-conclusion", + "title": "Defer daily portfolio conclusions", + "reason": "The crypto summary is stale, so today's allocation and movement do not fully reconcile.", + "source_ids": ["money:pulse:2026-08-24"] + } + ], + "actions": [ + { + "id": "reply-window", + "title": "Reply to the scheduling question", + "reason": "The inbox snapshot records a decision due today.", + "source_ids": ["inbox:attention:2026-08-24"] + }, + { + "id": "ship-slice", + "title": "Finish the complete renderer slice", + "reason": "It is startable, high priority, and supported by the protected morning block.", + "source_ids": ["work:focus:2026-08-24", "agenda:primary:2026-08-24"] + }, + { + "id": "preserve-buffer", + "title": "Leave 15:30 onward unclaimed", + "reason": "The agenda reserves this time to absorb review delay or unfinished admin.", + "source_ids": ["agenda:primary:2026-08-24"] + } + ] + }, + "presentation": { + "layout": "dashboard", + "blocks": [ + { + "id": "day-at-glance", + "kind": "metric-group", + "title": "Day at a glance", + "span": "full", + "metrics": [ + { + "label": "Focus time", + "value": 3.5, + "unit": "hours", + "tone": "positive" + }, + { + "label": "Needs a reply", + "value": 4, + "format": "number", + "tone": "warning" + }, + { + "label": "Open blockers", + "value": 1, + "format": "number", + "tone": "neutral" + } + ] + }, + { + "id": "attention-shape", + "kind": "line-chart", + "title": "Less interruption. More focus.", + "description": "Completed blocks and interruptions, August 17\u201323. Counts, not hours.", + "span": "two", + "y_format": "number", + "series": [ + { + "key": "focus", + "label": "Focus blocks" + }, + { + "key": "interruptions", + "label": "Interruptions" + } + ], + "points": [ + { + "x": "Mon", + "values": { + "focus": 2, + "interruptions": 5 + } + }, + { + "x": "Tue", + "values": { + "focus": 3, + "interruptions": 4 + } + }, + { + "x": "Wed", + "values": { + "focus": 3, + "interruptions": 3 + } + }, + { + "x": "Thu", + "values": { + "focus": 4, + "interruptions": 2 + } + }, + { + "x": "Fri", + "values": { + "focus": 3, + "interruptions": 3 + } + }, + { + "x": "Sat", + "values": { + "focus": 1, + "interruptions": 1 + } + }, + { + "x": "Sun", + "values": { + "focus": 2, + "interruptions": 1 + } + } + ] + }, + { + "id": "today", + "kind": "calendar", + "title": "Today", + "description": "Two focus windows, one sync, and a protected buffer.", + "span": "one", + "date": "2026-08-24", + "events": [ + { + "id": "focus-1", + "title": "Build the first usable slice", + "start": "2026-08-24T08:30:00Z", + "end": "2026-08-24T10:30:00Z", + "tone": "positive" + }, + { + "id": "sync", + "title": "Project sync", + "start": "2026-08-24T11:00:00Z", + "end": "2026-08-24T11:30:00Z", + "tone": "info" + }, + { + "id": "focus-2", + "title": "Review and ship", + "start": "2026-08-24T13:00:00Z", + "end": "2026-08-24T14:30:00Z", + "tone": "positive" + }, + { + "id": "buffer", + "title": "Open buffer", + "start": "2026-08-24T15:30:00Z", + "end": "2026-08-24T16:00:00Z", + "tone": "neutral" + } + ] + }, + { + "id": "next-actions", + "kind": "list", + "title": "Decide or do", + "description": "Only actions with a concrete next step appear here.", + "span": "two", + "items": [ + { + "id": "reply", + "title": "Reply to the scheduling question", + "description": "Confirm one of the two proposed windows.", + "meta": "Before the 08:30 focus window", + "status": "Needs reply", + "tone": "warning" + }, + { + "id": "review", + "title": "Review the small release", + "description": "Check the acceptance criteria and approve or request changes.", + "meta": "20 minutes", + "status": "Ready", + "tone": "positive" + }, + { + "id": "plan", + "title": "Choose tomorrow's first task", + "description": "Write a single startable action before ending the day.", + "meta": "End of day", + "status": "Later", + "tone": "neutral" + } + ] + }, + { + "id": "goal-progress", + "kind": "progress", + "title": "Last week, against your goals", + "span": "one", + "items": [ + { + "label": "Weekly deep work", + "value": 7.5, + "max": 10, + "value_label": "7.5 of 10 hours", + "tone": "positive" + }, + { + "label": "Movement", + "value": 4, + "max": 5, + "value_label": "4 of 5 sessions", + "tone": "info" + }, + { + "label": "Admin cleared", + "value": 6, + "max": 10, + "value_label": "6 of 10 items", + "tone": "warning" + } + ] + }, + { + "id": "protect-buffer", + "kind": "notice", + "title": "Protect the buffer", + "body": "The afternoon has enough space to absorb surprises. Filling it now would turn a calm plan into calendar Tetris.", + "tone": "info", + "span": "full" + }, + { + "id": "decision-sequence", + "kind": "timeline", + "title": "Decision sequence", + "span": "one", + "items": [ + { + "label": "Before 08:30", + "title": "Open the acceptance criteria", + "description": "Start the focus block with the finish line visible.", + "tone": "positive" + }, + { + "label": "Before 08:30", + "title": "Resolve the scheduling choice", + "description": "Resolve this before the focus window rather than interrupting it.", + "tone": "warning" + }, + { + "label": "13:00", + "title": "Review and ship", + "description": "Use the second focus block for evidence and release.", + "tone": "positive" + }, + { + "label": "15:30", + "title": "Stop scheduling", + "description": "Keep the buffer available for reality.", + "tone": "info" + } + ] + }, + { + "id": "source-register", + "kind": "table", + "title": "Source state", + "span": "two", + "columns": [ + { + "key": "source", + "label": "Source" + }, + { + "key": "state", + "label": "State" + }, + { + "key": "consequence", + "label": "Decision consequence" + } + ], + "rows": [ + { + "source": "Agenda", + "state": "Fresh", + "consequence": "Schedule is usable" + }, + { + "source": "Inbox", + "state": "Fresh", + "consequence": "Four actions retained" + }, + { + "source": "Work", + "state": "Fresh", + "consequence": "One startable priority" + }, + { + "source": "Money", + "state": "Partial", + "consequence": "No daily movement claim" + }, + { + "source": "News", + "state": "Fresh", + "consequence": "No urgent action" + } + ] + } + ] + } + } +} diff --git a/examples/snapshots/work/focus/2026-08-24.json b/examples/snapshots/work/focus/2026-08-24.json new file mode 100644 index 0000000..68b529c --- /dev/null +++ b/examples/snapshots/work/focus/2026-08-24.json @@ -0,0 +1,277 @@ +{ + "schema_version": "0.1.1", + "schema_ref": "schemas/domains/work.schema.json", + "snapshot_id": "work:focus:2026-08-24", + "source_id": "work:focus", + "domain": "work", + "source": "focus", + "generated_at": "2026-08-24T07:10:00Z", + "effective_period": { + "start": "2026-08-24T00:00:00Z", + "end": "2026-08-24T23:59:59Z", + "timezone": "UTC" + }, + "status": "success", + "producer": { + "worker_id": "work-focus-daily", + "workflow": "example", + "version": "0.1.1" + }, + "sources": [ + { + "label": "Synthetic task tracker", + "status": "ok", + "as_of": "2026-08-24T07:10:00Z", + "reference": "synthetic://tasks" + } + ], + "freshness": { + "expires_at": "2026-08-25T19:10:00Z" + }, + "quality": { + "confidence": "high", + "warnings": [] + }, + "privacy": { + "classification": "public", + "contains_personal_data": false, + "synthetic": true + }, + "data": { + "title": "One finished thing beats five starts.", + "summary": "Five owned items: one in progress, two ready, one waiting, and one blocked. Finish the renderer before pulling another task into progress.", + "attention": "medium", + "facts": { + "items": [ + { + "id": "renderer", + "title": "Adaptive block renderer", + "status": "in-progress", + "priority": "high", + "next_action": "Complete the table component" + }, + { + "id": "deployment", + "title": "Private deployment recipe", + "status": "ready", + "priority": "medium", + "next_action": "Verify the Access checklist" + }, + { + "id": "connector", + "title": "Connector example", + "status": "blocked", + "priority": "medium", + "next_action": "Wait for source permission" + }, + { + "id": "fixtures", + "title": "Renderer fixture coverage", + "status": "ready", + "priority": "high", + "next_action": "Add one valid example per audited block", + "due_at": "2026-08-25T16:00:00Z" + }, + { + "id": "docs", + "title": "Contract extension guide", + "status": "waiting", + "priority": "medium", + "next_action": "Review after fixtures land" + } + ] + }, + "presentation": { + "layout": "dashboard", + "blocks": [ + { + "id": "work-counts", + "kind": "metric-group", + "title": "Current work", + "span": "full", + "metrics": [ + { + "label": "In progress", + "value": 1, + "tone": "positive" + }, + { + "label": "Ready", + "value": 2, + "tone": "info" + }, + { + "label": "Blocked", + "value": 1, + "tone": "warning" + }, + { + "label": "Waiting", + "value": 1, + "tone": "neutral" + } + ] + }, + { + "id": "work-distribution", + "kind": "donut-chart", + "title": "Where work stands", + "description": "All five owned items, grouped by state. Select a state to inspect its count.", + "span": "two", + "center_label": "Owned items", + "value_format": "number", + "segments": [ + { + "label": "In progress", + "value": 1 + }, + { + "label": "Ready", + "value": 2 + }, + { + "label": "Waiting", + "value": 1 + }, + { + "label": "Blocked", + "value": 1 + } + ] + }, + { + "id": "work-next", + "kind": "list", + "title": "Best next moves", + "span": "one", + "items": [ + { + "id": "w1", + "title": "Finish one end-to-end path", + "description": "Prefer a complete thin slice over three half-started components.", + "meta": "90 minutes", + "tone": "positive" + }, + { + "id": "w2", + "title": "Write the open decision", + "description": "Make the blocker answerable in one message.", + "meta": "10 minutes", + "tone": "info" + } + ] + }, + { + "id": "delivery-progress", + "kind": "progress", + "title": "Delivery evidence", + "span": "one", + "items": [ + { + "label": "Complete slice", + "value": 7, + "max": 10, + "value_label": "7 of 10 checks", + "tone": "positive" + }, + { + "label": "Fixture coverage", + "value": 8, + "max": 11, + "value_label": "8 of 11 blocks", + "tone": "info" + }, + { + "label": "Release evidence", + "value": 3, + "max": 5, + "value_label": "3 of 5 gates", + "tone": "warning" + } + ] + }, + { + "id": "delivery-sequence", + "kind": "timeline", + "title": "Path to done", + "span": "two", + "items": [ + { + "label": "Now", + "title": "Finish the renderer path", + "description": "Implementation, fixture, and test move together.", + "tone": "positive" + }, + { + "label": "Next", + "title": "Run the full contract gate", + "description": "Fix only evidence-backed failures.", + "tone": "info" + }, + { + "label": "Then", + "title": "Publish the extension guide", + "description": "Document the exact safe boundary.", + "tone": "neutral" + } + ] + }, + { + "id": "owned-work", + "kind": "table", + "title": "Owned work", + "span": "full", + "columns": [ + { + "key": "item", + "label": "Item" + }, + { + "key": "state", + "label": "State" + }, + { + "key": "next", + "label": "Next action" + } + ], + "rows": [ + { + "item": "Adaptive block renderer", + "state": "In progress", + "next": "Complete the table component" + }, + { + "item": "Private deployment recipe", + "state": "Ready", + "next": "Verify Access checklist" + }, + { + "item": "Connector example", + "state": "Blocked", + "next": "Wait for source permission" + }, + { + "item": "Renderer fixture coverage", + "state": "Ready", + "next": "Add one example per audited block" + }, + { + "item": "Contract extension guide", + "state": "Waiting", + "next": "Review after fixtures land" + } + ] + }, + { + "id": "external-blocker", + "kind": "notice", + "title": "The connector is not startable", + "body": "Source permission is outside the current work boundary. Keep it visible, but do not let it interrupt the complete renderer slice.", + "tone": "warning", + "span": "full" + } + ] + } + } +} diff --git a/package.json b/package.json index cb79bdd..ace75f6 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "setup": "node scripts/setup.mjs", "dev": "npm run data:build && vite", + "examples": "node scripts/run-examples.mjs", "build": "npm run contracts:check && npm run build:app", "build:app": "npm run data:build && npm run typecheck && vite build", "preview": "vite preview", diff --git a/scripts/build-data-index.mjs b/scripts/build-data-index.mjs index 6b168d1..f834daa 100644 --- a/scripts/build-data-index.mjs +++ b/scripts/build-data-index.mjs @@ -6,6 +6,7 @@ import { snapshotFreshness } from "./lib/freshness.mjs" import { resolvedExperience } from "./lib/setup-options.mjs" const root = process.cwd() +const showcase = process.env.ZAATI_EXAMPLES === "true" async function snapshotFiles(directory) { try { const entries = await readdir(path.join(root, directory), { withFileTypes: true }) @@ -30,21 +31,22 @@ const readJson = async (file) => JSON.parse(await readFile(path.join(root, file) const localInstance = await readFile(path.join(root, "config/instance.local.json"), "utf8") .then(() => true) .catch(() => false) -const rawInstance = await readJson(localInstance ? "config/instance.local.json" : "config/instance.example.json") +const rawInstance = await readJson(localInstance && !showcase ? "config/instance.local.json" : "config/instance.example.json") +if (showcase) rawInstance.enabled_sources = ["overview:daily", "money:pulse", "work:focus"] const instance = { ...rawInstance, experience: resolvedExperience(rawInstance, { - hasLocalConfig: localInstance, - demoOverride: process.env.ZAATI_DEMO_MODE === "true", + hasLocalConfig: localInstance && !showcase, + demoOverride: showcase || process.env.ZAATI_DEMO_MODE === "true", }), } const privateRoot = process.env.ZAATI_DATA_DIR || "data/snapshots" const tutorialMode = process.env.ZAATI_TUTORIAL_MODE === "true" const historyLimit = Math.min(366, Math.max(1, Number(process.env.ZAATI_HISTORY_LIMIT || 31))) if (!Number.isInteger(historyLimit)) throw new Error("ZAATI_HISTORY_LIMIT must be an integer from 1 to 366.") -const privateFiles = await snapshotFiles(privateRoot) +const privateFiles = showcase ? [] : await snapshotFiles(privateRoot) const usingExamples = privateFiles.length === 0 && instance.experience.mode === "demo" -const files = usingExamples ? await snapshotFiles("data/examples") : privateFiles +const files = usingExamples ? await snapshotFiles(showcase ? "examples/snapshots" : "data/examples") : privateFiles const encryptedFiles = files.filter((file) => file.endsWith(".enc")) if (!usingExamples && !tutorialMode && instance.storage.snapshot_encryption && files.some((file) => !file.endsWith(".enc"))) throw new Error("Snapshot encryption is enabled, but plaintext private snapshots were found.") diff --git a/scripts/check-accessibility.mjs b/scripts/check-accessibility.mjs index 14b35b5..42c6dd5 100644 --- a/scripts/check-accessibility.mjs +++ b/scripts/check-accessibility.mjs @@ -1,6 +1,6 @@ import { spawn } from "node:child_process" import { constants } from "node:fs" -import { access, mkdtemp, rm, writeFile } from "node:fs/promises" +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import { createRequire } from "node:module" import { tmpdir } from "node:os" import path from "node:path" @@ -76,10 +76,21 @@ function cdp(url) { } async function waitForApp(client) { + await new Promise((resolve) => setTimeout(resolve, 250)) const deadline = Date.now() + 20000 while (Date.now() < deadline) { - const result = await client.send("Runtime.evaluate", { expression: "Boolean(document.querySelector('h1'))", returnByValue: true }) - if (result.result?.value) return + const result = await client.send("Runtime.evaluate", { + expression: + "document.readyState === 'complete' && Boolean(document.querySelector('h1')) && !document.querySelector('[aria-label=\"Loading chart\"]')", + returnByValue: true, + }) + if (result.result?.value) { + await client.send("Runtime.evaluate", { + expression: "Promise.all(document.getAnimations().map(a => a.finished.catch(() => {})))", + awaitPromise: true, + }) + return + } await new Promise((resolve) => setTimeout(resolve, 150)) } throw new Error("The dashboard did not become ready for accessibility validation.") @@ -113,8 +124,11 @@ async function audit(client, label) { async function capture(client, name) { if (process.env.ZAATI_CAPTURE_SCREENSHOTS !== "true") return - const result = await client.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }) - await writeFile(path.resolve("docs/assets", name), Buffer.from(result.data, "base64")) + if (process.env.ZAATI_SCREENSHOT_DIR && /^(dashboard|onboarding)-/.test(name)) return + const directory = process.env.ZAATI_SCREENSHOT_DIR || "docs/assets" + await mkdir(directory, { recursive: true }) + const result = await client.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true }) + await writeFile(path.resolve(directory, name), Buffer.from(result.data, "base64")) } async function stop(child) { @@ -128,7 +142,10 @@ const profile = await mkdtemp(path.join(tmpdir(), "zaati-a11y-")) const vite = path.resolve("node_modules/.bin", process.platform === "win32" ? "vite.cmd" : "vite") const preview = process.env.ZAATI_A11Y_URL ? null - : spawn(vite, ["preview", "--host", host, "--port", String(appPort)], { stdio: "ignore", shell: process.platform === "win32" }) + : spawn(vite, ["preview", "--outDir", process.env.ZAATI_A11Y_DIST || "dist", "--host", host, "--port", String(appPort)], { + stdio: "ignore", + shell: process.platform === "win32", + }) let chrome try { await waitForJson(`${appUrl}/data/dashboard-data.json`) @@ -152,8 +169,17 @@ try { await client.send("Runtime.enable") await client.send("Page.enable") const dashboard = await waitForJson(`${appUrl}/data/dashboard-data.json`) + if (process.env.ZAATI_CAPTURE_SCREENSHOTS === "true" && !dashboard.syntheticData) + throw new Error("Screenshot capture requires an entirely synthetic dashboard.") const views = ["start", ...(dashboard.demoMode ? ["components"] : []), ...dashboard.sources.map((source) => source.definition.id)] - const viewUrl = (view) => `${appUrl}?view=${encodeURIComponent(view)}&at=${encodeURIComponent(dashboard.generatedAt)}` + const reviewAt = dashboard.demoMode + ? dashboard.sources + .map((source) => source.snapshot?.generated_at) + .filter(Boolean) + .sort() + .at(-1) || dashboard.generatedAt + : dashboard.generatedAt + const viewUrl = (view) => `${appUrl}?view=${encodeURIComponent(view)}&at=${encodeURIComponent(reviewAt)}` const viewports = [320, 390, 768, 1024, 1440] for (const width of viewports) { await client.send("Emulation.setDeviceMetricsOverride", { @@ -169,10 +195,69 @@ try { for (const view of views) { await client.send("Page.navigate", { url: viewUrl(view) }) await audit(client, `${width}px ${mode} ${view}`) + const overflow = await client.send("Runtime.evaluate", { + expression: "document.documentElement.scrollWidth > innerWidth", + returnByValue: true, + }) + if (overflow.result?.value) throw new Error(`${width}px ${mode} ${view} overflows the document.`) + if ([390, 1440].includes(width) && ["overview:daily", "money:pulse", "work:focus"].includes(view)) + await capture(client, `${view.split(":")[0]}-${width}-${mode}.png`) } } } + const chartSource = dashboard.sources.find(({ snapshot }) => { + const blocks = snapshot?.data.presentation.blocks || [] + return blocks.some((block) => block.kind === "donut-chart") && blocks.some((block) => ["line-chart", "bar-chart"].includes(block.kind)) + }) + if (chartSource) { + await client.send("Page.navigate", { url: viewUrl(chartSource.definition.id) }) + await waitForApp(client) + const interaction = await client.send("Runtime.evaluate", { + expression: `(async () => { + const button = document.querySelector('.chart-donut button'); + if (!button) return false; + button.click(); await new Promise(r => setTimeout(r, 50)); const selected = button.getAttribute('aria-pressed') === 'true'; + button.click(); await new Promise(r => setTimeout(r, 50)); const cleared = button.getAttribute('aria-pressed') === 'false'; + const details = document.querySelector('.chart-values'); details.querySelector('summary').click(); + return { selected, cleared, data: details.open && details.querySelectorAll('td').length > 0 }; + })()`, + returnByValue: true, + awaitPromise: true, + }) + if (!interaction.result?.value?.data || !interaction.result?.value?.selected || !interaction.result?.value?.cleared) + throw new Error("Chart selection or data disclosure failed.") + await client.send("Runtime.evaluate", { expression: "document.querySelector('.chart-donut button').focus()" }) + await client.send("Input.dispatchKeyEvent", { type: "keyDown", key: " ", code: "Space", windowsVirtualKeyCode: 32 }) + await client.send("Input.dispatchKeyEvent", { type: "keyUp", key: " ", code: "Space", windowsVirtualKeyCode: 32 }) + const keyboard = await client.send("Runtime.evaluate", { + expression: "document.activeElement?.getAttribute('aria-pressed') === 'true'", + returnByValue: true, + }) + if (!keyboard.result?.value) throw new Error("Keyboard donut selection failed.") + console.log("Chart disclosure, selection toggle, and keyboard Space activation passed.") + } + await client.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] }) + await client.send("Page.navigate", { url: viewUrl("money:pulse") }) + await audit(client, "Reduced-motion money dashboard") + const motion = await client.send("Runtime.evaluate", { + expression: + "Array.from(document.querySelectorAll('.zaati-block, .chart-series-reveal, .chart-bar')).every(el => parseFloat(getComputedStyle(el).animationDuration) <= 0.001)", + returnByValue: true, + }) + if (!motion.result?.value) throw new Error("Reduced motion is not respected.") + await client.send("Emulation.setEmulatedMedia", { features: [] }) + for (const palette of ["ocean", "plum", "sand"]) { + for (const mode of ["light", "dark"]) { + await client.send("Runtime.evaluate", { + expression: `localStorage.setItem('zaati-palette', '${palette}'); localStorage.setItem('zaati-theme', '${mode}')`, + }) + await client.send("Page.navigate", { url: viewUrl("components") }) + await audit(client, `${palette} ${mode} component palette`) + } + } + await client.send("Runtime.evaluate", { expression: "localStorage.removeItem('zaati-palette')" }) + await client.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }) await client.send("Runtime.evaluate", { expression: `localStorage.setItem("zaati-theme", "light")` }) await client.send("Page.navigate", { url: viewUrl("start") }) diff --git a/scripts/run-examples.mjs b/scripts/run-examples.mjs new file mode 100644 index 0000000..0c2ad01 --- /dev/null +++ b/scripts/run-examples.mjs @@ -0,0 +1,12 @@ +import { spawnSync, spawn } from "node:child_process" +import process from "node:process" + +const env = { ...process.env, ZAATI_EXAMPLES: "true" } +const build = spawnSync(process.execPath, ["scripts/build-data-index.mjs"], { env, stdio: "inherit" }) +if (build.status !== 0) process.exit(build.status || 1) +const server = spawn(process.execPath, ["node_modules/vite/bin/vite.js", "--host", "127.0.0.1", ...process.argv.slice(2)], { + env, + stdio: "inherit", +}) +server.on("exit", (code) => process.exit(code || 0)) +for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => server.kill(signal)) diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs index c325402..48a1806 100644 --- a/scripts/validate-data.mjs +++ b/scripts/validate-data.mjs @@ -134,7 +134,7 @@ for (const sourceId of instance.enabled_sources || []) const privateRoot = process.env.ZAATI_DATA_DIR || "data/snapshots" const tutorialMode = process.env.ZAATI_TUTORIAL_MODE === "true" const privateFiles = await discoverSnapshots(privateRoot) -const exampleFiles = await discoverSnapshots("data/examples") +const exampleFiles = [...(await discoverSnapshots("data/examples")), ...(await discoverSnapshots("examples/snapshots"))] const snapshotFiles = [...exampleFiles, ...privateFiles] const encryptedFiles = privateFiles.filter((file) => file.endsWith(".enc")) if (!tutorialMode && instance.storage?.snapshot_encryption && privateFiles.some((file) => !file.endsWith(".enc"))) @@ -171,7 +171,7 @@ for (const file of snapshotFiles) { const expectedDate = path.basename(file, file.endsWith(".enc") ? ".json.enc" : ".json") if (snapshot.snapshot_id !== `${snapshot.source_id}:${expectedDate}`) errors.push(`${file}: snapshot_id must end with the file date`) if ( - file.startsWith("data/examples/") && + exampleFiles.includes(file) && (!snapshot.privacy?.synthetic || snapshot.privacy?.contains_personal_data || snapshot.privacy?.classification !== "public") ) { errors.push(`${file}: examples must be public, synthetic, and contain no personal data`) @@ -186,7 +186,7 @@ for (const file of snapshotFiles) { } errors.push( ...validateSnapshotPolicy(snapshot, registration, { - allowSynthetic: file.startsWith("data/examples/") || tutorialMode, + allowSynthetic: exampleFiles.includes(file) || tutorialMode, expectedDate, }).map((error) => `${file}${error}`), ) diff --git a/src/components/BlockRenderer.tsx b/src/components/BlockRenderer.tsx index dd4d5bc..91c03dd 100644 --- a/src/components/BlockRenderer.tsx +++ b/src/components/BlockRenderer.tsx @@ -150,6 +150,7 @@ function Panel({ }) { return (
{block.metrics.map((metric) => ( -
+
- + {formatValue(metric.value, metric.format, instance)} {metric.unit ? {metric.unit} : null}
{metric.change !== undefined ? ( -

= 0 ? "text-positive-foreground" : "text-destructive")}> +

{metric.change > 0 ? "+" : ""} {formatValue(metric.change, metric.format === "percent" ? "percent" : "number", instance)} diff --git a/src/components/ChartVisual.tsx b/src/components/ChartVisual.tsx index 808082e..c886f33 100644 --- a/src/components/ChartVisual.tsx +++ b/src/components/ChartVisual.tsx @@ -96,6 +96,8 @@ export default function ChartVisual({ function DonutVisual({ block, descriptionId, instance }: { block: DonutChartBlock; descriptionId: string; instance: InstanceConfig }) { const [activeIndex, setActiveIndex] = useState(null) + const [selectedIndex, setSelectedIndex] = useState(null) + const highlighted = activeIndex ?? selectedIndex const valueFormat = block.value_format || "number" const total = block.segments.reduce((sum, segment) => sum + segment.value, 0) const radius = 76 @@ -106,11 +108,11 @@ function DonutVisual({ block, descriptionId, instance }: { block: DonutChartBloc offset: (block.segments.slice(0, index).reduce((sum, previous) => sum + previous.value, 0) / total) * circumference, })) return ( -

+
{block.title} allocation chart. Exact values follow in a list.
-
+
- {format(activeIndex === null ? total : block.segments[activeIndex].value, valueFormat, instance)} + {axisFormat(highlighted === null ? total : block.segments[highlighted].value, valueFormat, instance)} - {activeIndex === null ? block.center_label : block.segments[activeIndex].label} + {highlighted === null ? block.center_label : block.segments[highlighted].label}
@@ -147,7 +149,11 @@ function DonutVisual({ block, descriptionId, instance }: { block: DonutChartBloc {block.segments.map((segment, index) => (
  • @@ -186,111 +192,113 @@ function LineVisual({ block, descriptionId, instance }: { block: LineChartBlock;
    {block.title} line chart. Exact values follow in an accessible table.
    - - {ticks.map((tick) => { - const y = yPosition(tick, range.min, range.max) - return ( - - - - {axisFormat(tick, valueFormat, instance)} - - - ) - })} - - {block.series.map((series, seriesIndex) => { - const points = block.points - .map((point, index) => `${x(index)},${yPosition(point.values[series.key], range.min, range.max)}`) - .join(" ") +
    + + {ticks.map((tick) => { + const y = yPosition(tick, range.min, range.max) return ( - + + + + {axisFormat(tick, valueFormat, instance)} + + ) })} - - {block.series.flatMap((series, seriesIndex) => - block.points.map((point, pointIndex) => { - const pointX = x(pointIndex) - const pointY = yPosition(point.values[series.key], range.min, range.max) - const active = activePoint?.pointIndex === pointIndex && activePoint.seriesIndex === seriesIndex - const label = `${series.label}, ${point.x}: ${format(point.values[series.key], valueFormat, instance)}` - return ( - setActivePoint(null)} - onFocus={() => setActivePoint({ pointIndex, seriesIndex })} - onPointerEnter={() => setActivePoint({ pointIndex, seriesIndex })} - onPointerLeave={() => setActivePoint(null)} - role="img" - tabIndex={0} - > - - + {block.series.map((series, seriesIndex) => { + const points = block.points + .map((point, index) => `${x(index)},${yPosition(point.values[series.key], range.min, range.max)}`) + .join(" ") + return ( + - - ) - }), - )} - {activePoint - ? (() => { - const series = block.series[activePoint.seriesIndex] - const point = block.points[activePoint.pointIndex] - const pointX = x(activePoint.pointIndex) + ) + })} + + {block.series.flatMap((series, seriesIndex) => + block.points.map((point, pointIndex) => { + const pointX = x(pointIndex) const pointY = yPosition(point.values[series.key], range.min, range.max) + const active = activePoint?.pointIndex === pointIndex && activePoint.seriesIndex === seriesIndex + const label = `${series.label}, ${point.x}: ${format(point.values[series.key], valueFormat, instance)}` return ( - <> - - setActivePoint(null)} + onFocus={() => setActivePoint({ pointIndex, seriesIndex })} + onPointerEnter={() => setActivePoint({ pointIndex, seriesIndex })} + onPointerLeave={() => setActivePoint(null)} + role="img" + tabIndex={0} + > + + - + ) - })() - : null} - {block.points.map((point, index) => - index === 0 || index === block.points.length - 1 || block.points.length <= 8 ? ( - - {point.x} - - ) : null, - )} - + }), + )} + {activePoint + ? (() => { + const series = block.series[activePoint.seriesIndex] + const point = block.points[activePoint.pointIndex] + const pointX = x(activePoint.pointIndex) + const pointY = yPosition(point.values[series.key], range.min, range.max) + return ( + <> + + + + ) + })() + : null} + {block.points.map((point, index) => + index === 0 || index === block.points.length - 1 || block.points.length <= 8 ? ( + + {point.x} + + ) : null, + )} + +
    - - - - - - {block.series.map((series) => ( - - ))} - - - - {block.points.map((point) => ( - - - {block.series.map((series) => ( - +
    + View data +
    +
    {block.title}
    {block.x_label || "Period"}{series.label}
    {point.x}{format(point.values[series.key], valueFormat, instance)}
    + + + + + {block.series.map((series) => ( + + ))} + + + + {block.points.map((point) => ( + + + {block.series.map((series) => ( + + ))} + ))} - - ))} - -
    {block.title}
    {block.x_label || "Period"}{series.label}
    {point.x}{format(point.values[series.key], valueFormat, instance)}
    + + +
  • +
    ) } @@ -349,86 +362,93 @@ function BarVisual({ block, descriptionId, instance }: { block: BarChartBlock; d
    {block.title} bar chart. Exact values follow in an accessible table.
    - - {ticks.map((tick) => { - const y = yPosition(tick, range.min, range.max) - return ( - - - - {axisFormat(tick, valueFormat, instance)} - - - ) - })} - {block.bars.map((bar, index) => { - const valueY = yPosition(bar.value, range.min, range.max) - const x = plot.left + index * slot + (slot - barWidth) / 2 - const y = Math.min(valueY, baseline) - const barHeight = Math.max(1, Math.abs(baseline - valueY)) - return ( - setActiveIndex(null)} - onFocus={() => setActiveIndex(index)} - onPointerEnter={() => setActiveIndex(index)} - onPointerLeave={() => setActiveIndex(null)} - role="img" - tabIndex={0} - > - - - {index % labelEvery === 0 ? ( - - {bar.label.length > 12 ? `${bar.label.slice(0, 11)}…` : bar.label} +
    + + {ticks.map((tick) => { + const y = yPosition(tick, range.min, range.max) + return ( + + + + {axisFormat(tick, valueFormat, instance)} - ) : null} - - ) - })} - {activeIndex !== null - ? (() => { - const bar = block.bars[activeIndex] - const valueY = yPosition(bar.value, range.min, range.max) - const pointX = plot.left + activeIndex * slot + slot / 2 - return - })() - : null} - - - - - - - - - - - {block.bars.map((bar) => ( - - - - - ))} - -
    {block.title}
    CategoryValue
    {bar.label}{format(bar.value, valueFormat, instance)}
    + + ) + })} + {block.bars.map((bar, index) => { + const valueY = yPosition(bar.value, range.min, range.max) + const x = plot.left + index * slot + (slot - barWidth) / 2 + const y = Math.min(valueY, baseline) + const barHeight = Math.max(1, Math.abs(baseline - valueY)) + return ( + setActiveIndex(null)} + onFocus={() => setActiveIndex(index)} + onPointerEnter={() => setActiveIndex(index)} + onPointerLeave={() => setActiveIndex(null)} + role="img" + tabIndex={0} + > + + + {index % labelEvery === 0 ? ( + + {bar.label.length > 12 ? `${bar.label.slice(0, 11)}…` : bar.label} + + ) : null} + + ) + })} + {activeIndex !== null + ? (() => { + const bar = block.bars[activeIndex] + const valueY = yPosition(bar.value, range.min, range.max) + const pointX = plot.left + activeIndex * slot + slot / 2 + return + })() + : null} + +
    +
    + View data +
    + + + + + + + + + + {block.bars.map((bar) => ( + + + + + ))} + +
    {block.title}
    CategoryValue
    {bar.label}{format(bar.value, valueFormat, instance)}
    +
    +
    ) } diff --git a/src/index.css b/src/index.css index d60be85..e799905 100644 --- a/src/index.css +++ b/src/index.css @@ -247,6 +247,93 @@ h3 { gap: 0.75rem; } +.metric-tile { + --metric-accent: var(--primary); + background: color-mix(in srgb, var(--metric-accent) 9%, var(--card)); + border-top: 3px solid var(--metric-accent); +} +.metric-tile[data-tone="info"] { + --metric-accent: var(--info); +} +.metric-tile[data-tone="warning"] { + --metric-accent: var(--warning); +} +.metric-tile[data-tone="danger"] { + --metric-accent: var(--destructive); +} +.metric-tile[data-tone="neutral"] { + --metric-accent: var(--muted-foreground); +} +.metric-tile .text-muted-foreground { + color: var(--foreground); +} +.metric-tile:first-child { + background: var(--primary); + color: var(--primary-foreground); +} +.metric-tile:first-child .text-muted-foreground, +.metric-tile:first-child .text-foreground, +.metric-tile:first-child .text-positive-foreground, +.metric-tile:first-child .text-destructive { + color: inherit; +} +.metric-tile:first-child .rounded-full { + background: currentColor; +} +.zaati-block { + container-type: inline-size; +} +.zaati-block[data-kind="line-chart"], +.zaati-block[data-kind="bar-chart"] { + border-top: 3px solid var(--chart-1); +} +.chart-scroll { + overflow-x: auto; + padding-block: 8px; +} +.chart-cartesian { + display: block; + width: 100%; + min-width: 480px; + height: auto; +} +@container (min-width: 480px) { + .chart-donut { + grid-template-columns: minmax(190px, 0.8fr) minmax(0, 1.2fr); + align-items: center; + } +} +.chart-values { + margin-top: 12px; + border-top: 1px solid var(--border); + font-size: 0.75rem; +} +.chart-values summary { + padding: 12px 4px; + cursor: pointer; + color: var(--muted-foreground); +} +.chart-values summary:hover { + color: var(--foreground); +} +.chart-values table { + width: 100%; + text-align: left; + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} +.chart-values th, +.chart-values td { + padding: 8px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.chart-values caption { + text-align: left; + padding: 8px; + font-weight: 600; +} + .palette-dot { display: block; width: 1.15rem; @@ -282,12 +369,10 @@ h3 { @keyframes zaati-chart-reveal { from { - opacity: 0; - transform: scaleX(0.08); + clip-path: inset(0 100% 0 0); } to { - opacity: 1; - transform: scaleX(1); + clip-path: inset(0 0 0 0); } } @@ -334,7 +419,6 @@ h3 { .zaati-block:hover { border-color: color-mix(in srgb, var(--primary) 24%, var(--border)); box-shadow: 0 8px 24px color-mix(in srgb, var(--foreground) 7%, transparent); - transform: translateY(-2px); } } @@ -357,7 +441,6 @@ h3 { .chart-data-target:hover .chart-bar, .chart-data-target:focus .chart-bar { filter: brightness(1.06); - transform: translateY(-2px) scaleY(1.015); } .chart-point { diff --git a/tests/examples.test.mjs b/tests/examples.test.mjs new file mode 100644 index 0000000..846676e --- /dev/null +++ b/tests/examples.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict" +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { spawnSync } from "node:child_process" +import { tmpdir } from "node:os" +import path from "node:path" +import test from "node:test" + +test("showcase build excludes private data and local identity even when both exist", async () => { + const fixture = await mkdtemp(path.join(tmpdir(), "zaati-showcase-")) + try { + for (const directory of ["config", "examples/snapshots", "data/examples", "schemas", "prompts", "docs"]) + await cp(directory, path.join(fixture, directory), { recursive: true }) + const config = JSON.parse(await readFile("config/instance.example.json", "utf8")) + config.name = "PRIVATE_IDENTITY_SENTINEL" + await writeFile(path.join(fixture, "config/instance.local.json"), JSON.stringify(config)) + await mkdir(path.join(fixture, "private-input")) + await writeFile(path.join(fixture, "private-input/secret.json"), "PRIVATE_SNAPSHOT_SENTINEL") + const result = spawnSync(process.execPath, [path.resolve("scripts/build-data-index.mjs")], { + cwd: fixture, + env: { ...process.env, ZAATI_EXAMPLES: "true", ZAATI_DATA_DIR: "private-input" }, + encoding: "utf8", + }) + assert.equal(result.status, 0, result.stderr) + const output = await readFile(path.join(fixture, "public/data/dashboard-data.json"), "utf8") + assert.doesNotMatch(output, /PRIVATE_IDENTITY_SENTINEL|PRIVATE_SNAPSHOT_SENTINEL/) + const dashboard = JSON.parse(output) + assert.equal(dashboard.demoMode, true) + assert.equal(dashboard.syntheticData, true) + assert.deepEqual(dashboard.sources.map((s) => s.definition.id).sort(), ["money:pulse", "overview:daily", "work:focus"]) + } finally { + await rm(fixture, { recursive: true, force: true }) + } +})