diff --git a/config/sources.json b/config/sources.json index 923dcdf..28b48c0 100644 --- a/config/sources.json +++ b/config/sources.json @@ -14,6 +14,7 @@ "cadence": "daily", "freshness_sla_hours": 30, "dashboard_role": "primary", + "presentation": { "preferred_blocks": ["calendar", "list", "notice"] }, "depends_on": [], "target_path": "data/snapshots/agenda/primary/{YYYY}/{MM}/{YYYY-MM-DD}.json", "privacy": { @@ -35,6 +36,7 @@ "cadence": "daily", "freshness_sla_hours": 30, "dashboard_role": "primary", + "presentation": { "preferred_blocks": ["metric-group", "list", "notice"] }, "depends_on": [], "target_path": "data/snapshots/inbox/attention/{YYYY}/{MM}/{YYYY-MM-DD}.json", "privacy": { @@ -56,6 +58,7 @@ "cadence": "weekdays", "freshness_sla_hours": 36, "dashboard_role": "primary", + "presentation": { "preferred_blocks": ["metric-group", "table", "list", "progress", "notice"] }, "depends_on": [], "target_path": "data/snapshots/work/focus/{YYYY}/{MM}/{YYYY-MM-DD}.json", "privacy": { @@ -77,6 +80,7 @@ "cadence": "daily", "freshness_sla_hours": 36, "dashboard_role": "primary", + "presentation": { "preferred_blocks": ["metric-group", "line-chart", "bar-chart", "table", "progress", "notice"] }, "depends_on": [], "target_path": "data/snapshots/money/pulse/{YYYY}/{MM}/{YYYY-MM-DD}.json", "privacy": { @@ -98,6 +102,7 @@ "cadence": "daily", "freshness_sla_hours": 24, "dashboard_role": "primary", + "presentation": { "preferred_blocks": ["list", "timeline", "text", "notice"] }, "depends_on": [], "target_path": "data/snapshots/news/briefing/{YYYY}/{MM}/{YYYY-MM-DD}.json", "privacy": { @@ -119,6 +124,7 @@ "cadence": "daily", "freshness_sla_hours": 30, "dashboard_role": "primary", + "presentation": { "preferred_blocks": ["metric-group", "line-chart", "calendar", "list", "progress", "notice"] }, "depends_on": ["agenda:primary", "inbox:attention", "work:focus", "money:pulse", "news:briefing"], "target_path": "data/snapshots/overview/daily/{YYYY}/{MM}/{YYYY-MM-DD}.json", "privacy": { @@ -140,6 +146,7 @@ "cadence": "weekly", "freshness_sla_hours": 192, "dashboard_role": "primary", + "presentation": { "preferred_blocks": ["metric-group", "line-chart", "list", "progress", "timeline", "notice"] }, "depends_on": ["overview:daily", "agenda:primary", "inbox:attention", "work:focus", "money:pulse", "news:briefing"], "target_path": "data/snapshots/review/weekly/{YYYY}/{MM}/{YYYY-MM-DD}.json", "privacy": { diff --git a/data/examples/money/pulse/2026-08-24.json b/data/examples/money/pulse/2026-08-24.json index a128147..0748c8e 100644 --- a/data/examples/money/pulse/2026-08-24.json +++ b/data/examples/money/pulse/2026-08-24.json @@ -81,6 +81,19 @@ "body": "The latest movement remains provisional because one approved source is stale. Missing data is not converted to zero.", "tone": "warning", "span": "full" + }, + { + "id": "allocation-comparison", + "kind": "bar-chart", + "title": "Synthetic allocation comparison", + "description": "Illustrative categories used to verify the complete renderer contract.", + "span": "full", + "value_format": "percent", + "bars": [ + { "label": "Reserve", "value": 35, "tone": "positive" }, + { "label": "Long-term goals", "value": 45, "tone": "info" }, + { "label": "Near-term plans", "value": 20, "tone": "neutral" } + ] } ] } diff --git a/data/examples/news/briefing/2026-08-24.json b/data/examples/news/briefing/2026-08-24.json index 78b9f49..d9682a0 100644 --- a/data/examples/news/briefing/2026-08-24.json +++ b/data/examples/news/briefing/2026-08-24.json @@ -99,6 +99,13 @@ "tone": "warning" } ] + }, + { + "id": "editor-note", + "kind": "text", + "title": "Reading note", + "body": "This synthetic briefing favors developments with a concrete decision consequence. Topics without new evidence stay out of the way.", + "span": "full" } ] } diff --git a/docs/prompt-studio.md b/docs/prompt-studio.md index 8c5bfdd..f8cd5fe 100644 --- a/docs/prompt-studio.md +++ b/docs/prompt-studio.md @@ -14,7 +14,7 @@ npm run prompt:create 2. Enter the public code fork and private data repository. 3. Review `.permissions.md`, run the matching command in `.data-repository.md`, then copy `.scheduled-task.md` into your LLM and approve only the listed connections. -The normal wizard chooses registered sources, dependencies, safe blocks, and sensible tool labels for you. Repository names are the only technical concepts in the default path. Use a JSON profile when you need per-source control. +The normal wizard chooses registered sources, dependencies, safe blocks, and sensible tool labels for you. Registered-source visualization guidance comes from `config/sources.json`, so generated prompts cannot drift from the maintained domain contract. Repository names are the only technical concepts in the default path. Use a JSON profile when you need per-source requirements and tool control. The profile and prompts are ignored by Git. Their directory uses mode `0700` and each file uses mode `0600`. They may still reveal repository names and workflow intent, so treat them as private configuration. @@ -41,7 +41,7 @@ The CLI rejects a shared code and data repository unless `allow_same_repository` - a registered `domain:source` ID - decision-oriented `requirements` - the exact approved `tools` available to the scheduled LLM -- one or more `preferred_blocks` from the audited UI contract +- one or more `preferred_blocks` from the audited UI contract (used for a new source; registered sources use the registry's authoritative list) The preferred block list is a safe menu, not a forced layout. The LLM should choose a line chart only for an ordered trend, a table for exact repeated fields, a calendar for timed events, and no visualization when plain text is clearer. diff --git a/docs/theming.md b/docs/theming.md index 2f539f2..3f3fa94 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -22,7 +22,7 @@ Zaati OS includes `sage`, `ocean`, `plum`, and `sand`. The interface also suppor ## Custom palette -Set `preset` to `custom` and provide allowlisted six-digit hex tokens. Snapshot producers never control these values. +Set `preset` to `custom` and provide complete light and dark token sets. Validation rejects text pairs below WCAG AA contrast and essential borders or chart colors below 3:1. Snapshot producers never control these values. ```json { @@ -31,18 +31,38 @@ Set `preset` to `custom` and provide allowlisted six-digit hex tokens. Snapshot "density": "compact", "radius": "0.7rem", "custom_tokens": { - "primary": "#315D8A", - "primary_foreground": "#FFFFFF", - "accent": "#E3ECF5", - "accent_foreground": "#1A3E62", - "background": "#F7F9FB", - "foreground": "#152332", - "card": "#FFFFFF", - "border": "#D9E1E8", - "sidebar": "#EEF3F7", - "chart_1": "#315D8A", - "chart_2": "#39705C", - "chart_3": "#9B6338" + "light": { + "primary": "#315D8A", + "primary_foreground": "#FFFFFF", + "accent": "#E3ECF5", + "accent_foreground": "#1A3E62", + "background": "#F7F9FB", + "foreground": "#152332", + "card": "#FFFFFF", + "card_foreground": "#152332", + "border": "#687580", + "sidebar": "#EEF3F7", + "sidebar_foreground": "#152332", + "chart_1": "#315D8A", + "chart_2": "#39705C", + "chart_3": "#9B6338" + }, + "dark": { + "primary": "#89B9E8", + "primary_foreground": "#102033", + "accent": "#24384C", + "accent_foreground": "#E7F2FC", + "background": "#101820", + "foreground": "#EDF4FA", + "card": "#17222D", + "card_foreground": "#EDF4FA", + "border": "#74818D", + "sidebar": "#121C25", + "sidebar_foreground": "#EDF4FA", + "chart_1": "#89B9E8", + "chart_2": "#72C59F", + "chart_3": "#E0A873" + } } } ``` diff --git a/package-lock.json b/package-lock.json index f833c85..17d5700 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "zaati-os", "version": "0.1.1", "dependencies": { + "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-tabs": "1.1.21", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.31.0", @@ -1257,6 +1259,71 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1272,6 +1339,372 @@ } } }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -1290,6 +1723,119 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", @@ -2150,7 +2696,7 @@ "version": "19.0.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz", "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2470,6 +3016,18 @@ } } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/axe-core": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", @@ -2677,6 +3235,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.413", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz", @@ -3072,6 +3636,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3830,6 +4403,75 @@ "react": "^19.2.8" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4049,9 +4691,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -4172,6 +4812,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", diff --git a/package.json b/package.json index b10405e..71b1700 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "deploy": "npm run check && npm run deployment:configure && wrangler deploy --config .wrangler.generated.jsonc" }, "dependencies": { + "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-tabs": "1.1.21", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.31.0", diff --git a/schemas/instance.schema.json b/schemas/instance.schema.json index 97516eb..0aead48 100644 --- a/schemas/instance.schema.json +++ b/schemas/instance.schema.json @@ -22,7 +22,11 @@ "brand_mark": { "type": "string", "minLength": 1, "maxLength": 3 }, "tagline": { "type": "string", "minLength": 1, "maxLength": 100 }, "timezone": { "type": "string", "minLength": 1, "maxLength": 80 }, - "locale": { "type": "string", "pattern": "^[a-z]{2,3}(?:-[A-Z]{2})?$" }, + "locale": { + "type": "string", + "maxLength": 35, + "pattern": "^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*$" + }, "currency": { "type": "string", "pattern": "^[A-Z]{3}$" }, "week_starts_on": { "enum": ["monday", "sunday"] }, "enabled_sources": { @@ -45,21 +49,28 @@ "type": "object", "additionalProperties": false, "properties": { - "primary": { "$ref": "#/$defs/color" }, - "primary_foreground": { "$ref": "#/$defs/color" }, - "accent": { "$ref": "#/$defs/color" }, - "accent_foreground": { "$ref": "#/$defs/color" }, - "background": { "$ref": "#/$defs/color" }, - "foreground": { "$ref": "#/$defs/color" }, - "card": { "$ref": "#/$defs/color" }, - "border": { "$ref": "#/$defs/color" }, - "sidebar": { "$ref": "#/$defs/color" }, - "chart_1": { "$ref": "#/$defs/color" }, - "chart_2": { "$ref": "#/$defs/color" }, - "chart_3": { "$ref": "#/$defs/color" } + "light": { "$ref": "#/$defs/customTokenSet" }, + "dark": { "$ref": "#/$defs/customTokenSet" } } } - } + }, + "allOf": [ + { + "if": { "properties": { "preset": { "const": "custom" } }, "required": ["preset"] }, + "then": { + "properties": { + "custom_tokens": { + "type": "object", + "required": ["light", "dark"], + "properties": { + "light": { "$ref": "#/$defs/customTokenSet" }, + "dark": { "$ref": "#/$defs/customTokenSet" } + } + } + } + } + } + ] }, "storage": { "type": "object", @@ -71,6 +82,42 @@ } }, "$defs": { - "color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" } + "color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" }, + "customTokenSet": { + "type": "object", + "additionalProperties": false, + "required": [ + "primary", + "primary_foreground", + "accent", + "accent_foreground", + "background", + "foreground", + "card", + "card_foreground", + "border", + "sidebar", + "sidebar_foreground", + "chart_1", + "chart_2", + "chart_3" + ], + "properties": { + "primary": { "$ref": "#/$defs/color" }, + "primary_foreground": { "$ref": "#/$defs/color" }, + "accent": { "$ref": "#/$defs/color" }, + "accent_foreground": { "$ref": "#/$defs/color" }, + "background": { "$ref": "#/$defs/color" }, + "foreground": { "$ref": "#/$defs/color" }, + "card": { "$ref": "#/$defs/color" }, + "card_foreground": { "$ref": "#/$defs/color" }, + "border": { "$ref": "#/$defs/color" }, + "sidebar": { "$ref": "#/$defs/color" }, + "sidebar_foreground": { "$ref": "#/$defs/color" }, + "chart_1": { "$ref": "#/$defs/color" }, + "chart_2": { "$ref": "#/$defs/color" }, + "chart_3": { "$ref": "#/$defs/color" } + } + } } } diff --git a/schemas/source-registry.schema.json b/schemas/source-registry.schema.json index 5841bb3..afe5992 100644 --- a/schemas/source-registry.schema.json +++ b/schemas/source-registry.schema.json @@ -31,6 +31,7 @@ "cadence", "freshness_sla_hours", "dashboard_role", + "presentation", "depends_on", "target_path", "privacy" @@ -47,6 +48,21 @@ "cadence": { "enum": ["hourly", "daily", "weekdays", "weekly", "monthly", "manual", "event"] }, "freshness_sla_hours": { "type": "integer", "minimum": 1, "maximum": 8760 }, "dashboard_role": { "enum": ["primary", "supporting"] }, + "presentation": { + "type": "object", + "additionalProperties": false, + "required": ["preferred_blocks"], + "properties": { + "preferred_blocks": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["metric-group", "list", "line-chart", "bar-chart", "calendar", "table", "progress", "notice", "timeline", "text"] + } + } + } + }, "depends_on": { "type": "array", "uniqueItems": true, diff --git a/scripts/check-accessibility.mjs b/scripts/check-accessibility.mjs index 3ec79cd..1e4a54a 100644 --- a/scripts/check-accessibility.mjs +++ b/scripts/check-accessibility.mjs @@ -151,47 +151,58 @@ try { const client = cdp(page.webSocketDebuggerUrl) await client.send("Runtime.enable") await client.send("Page.enable") + const dashboard = await waitForJson(`${appUrl}/data/dashboard-data.json`) + const views = ["start", ...dashboard.sources.map((source) => source.definition.id)] + const viewUrl = (view) => `${appUrl}?view=${encodeURIComponent(view)}&at=${encodeURIComponent(dashboard.generatedAt)}` + const viewports = [320, 390, 768, 1024, 1440] + for (const width of viewports) { + await client.send("Emulation.setDeviceMetricsOverride", { + width, + height: width < 768 ? 844 : 1000, + deviceScaleFactor: 1, + mobile: width < 768, + }) + for (const mode of ["light", "dark"]) { + await client.send("Runtime.evaluate", { expression: `localStorage.setItem("zaati-theme", "${mode}")` }) + for (const view of views) { + await client.send("Page.navigate", { url: viewUrl(view) }) + await audit(client, `${width}px ${mode} ${view}`) + } + } + } + + 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") }) + await waitForApp(client) + await client.send("Runtime.evaluate", { expression: `document.querySelector('button[aria-label="Open navigation"]')?.click()` }) + await audit(client, "Mobile navigation open") + await client.send("Emulation.setDeviceMetricsOverride", { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false }) - await client.send("Page.reload", { ignoreCache: true }) - await audit(client, "Desktop light tutorial") + await client.send("Page.navigate", { url: viewUrl("overview:daily") }) + await waitForApp(client) + await client.send("Runtime.evaluate", { expression: `document.querySelector('button[aria-label="Open theme studio"]')?.click()` }) + await audit(client, "Desktop theme studio open") + + await client.send("Page.navigate", { url: viewUrl("start") }) + await audit(client, "Desktop tutorial screenshot") await capture(client, "onboarding-light.png") - await client.send("Runtime.evaluate", { - expression: - "Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('Explore the demo'))?.click()", - }) - await new Promise((resolve) => setTimeout(resolve, 100)) - await audit(client, "Desktop light dashboard") + await client.send("Page.navigate", { url: viewUrl("overview:daily") }) + await audit(client, "Desktop dashboard screenshot") await capture(client, "dashboard-light.png") - await client.send("Runtime.evaluate", { - expression: "Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.trim() === 'Start here')?.click()", - }) - await new Promise((resolve) => setTimeout(resolve, 100)) - await client.send("Runtime.evaluate", { expression: "document.querySelector('button[aria-label=\"Use dark mode\"]')?.click()" }) - await new Promise((resolve) => setTimeout(resolve, 100)) - await audit(client, "Desktop dark tutorial") + await client.send("Runtime.evaluate", { expression: `localStorage.setItem("zaati-theme", "dark")` }) + await client.send("Page.navigate", { url: viewUrl("start") }) + await audit(client, "Desktop dark tutorial screenshot") await capture(client, "onboarding-dark.png") - await client.send("Runtime.evaluate", { - expression: - "Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('Explore the demo'))?.click()", - }) - await new Promise((resolve) => setTimeout(resolve, 100)) - await audit(client, "Desktop dark dashboard") + await client.send("Page.navigate", { url: viewUrl("overview:daily") }) + await audit(client, "Desktop dark dashboard screenshot") await capture(client, "dashboard-dark.png") await client.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }) - await client.send("Page.reload", { ignoreCache: true }) - await waitForApp(client) - await client.send("Runtime.evaluate", { - expression: "Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.trim() === 'Start here')?.click()", - }) - await new Promise((resolve) => setTimeout(resolve, 100)) - await audit(client, "Mobile tutorial") + await client.send("Page.navigate", { url: viewUrl("start") }) + await audit(client, "Mobile tutorial screenshot") await capture(client, "onboarding-mobile.png") - await client.send("Runtime.evaluate", { - expression: - "Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('Explore the demo'))?.click()", - }) - await new Promise((resolve) => setTimeout(resolve, 100)) - await audit(client, "Mobile dashboard") + await client.send("Page.navigate", { url: viewUrl("overview:daily") }) + await audit(client, "Mobile dashboard screenshot") await capture(client, "dashboard-mobile.png") client.close() } finally { diff --git a/scripts/create-prompt.mjs b/scripts/create-prompt.mjs index dd21543..4d209fa 100644 --- a/scripts/create-prompt.mjs +++ b/scripts/create-prompt.mjs @@ -45,16 +45,6 @@ const providerTools = { custom: ["User-approved connector", "GitHub"], } -const defaultBlocks = { - "agenda:primary": ["calendar", "list", "notice"], - "inbox:attention": ["list", "notice"], - "work:focus": ["list", "progress", "notice"], - "money:pulse": ["metric-group", "line-chart", "table", "notice"], - "news:briefing": ["list", "notice"], - "overview:daily": ["metric-group", "list", "calendar", "notice"], - "review:weekly": ["metric-group", "line-chart", "list", "notice"], -} - async function interactiveProfile(contracts) { if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive mode needs a terminal. Use --config for automation.") const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) @@ -108,7 +98,7 @@ async function interactiveProfile(contracts) { id, requirements: [registration.description, outcome], tools: providerTools[provider], - preferred_blocks: defaultBlocks[id] || ["list", "notice"], + preferred_blocks: registration.presentation.preferred_blocks, } }) return { diff --git a/scripts/lib/bundle-contract.mjs b/scripts/lib/bundle-contract.mjs index 4df1e77..6ec1d05 100644 --- a/scripts/lib/bundle-contract.mjs +++ b/scripts/lib/bundle-contract.mjs @@ -181,7 +181,7 @@ export async function persistBundle( throw error } for (const item of writes) if (item.backedUp) await rm(item.backup, { force: true }) - return writes.map((item) => item.target) + return writes.map((item) => path.join(requestedRoot, path.relative(resolvedRoot, item.target))) } finally { for (const item of writes) { await rm(item.temporary, { force: true }).catch(() => {}) diff --git a/scripts/lib/prompt-studio.mjs b/scripts/lib/prompt-studio.mjs index 6ef3620..1f2a263 100644 --- a/scripts/lib/prompt-studio.mjs +++ b/scripts/lib/prompt-studio.mjs @@ -82,13 +82,25 @@ export function validatePromptProfile(profile, contracts) { if (!registered.has(dependency) && !selected) throw new Error(`Source ${source.id} depends on unknown source ${dependency}.`) } } - return normalized + return { + ...normalized, + sources: normalized.sources.map((source) => { + const registration = registered.get(source.id) + return registration ? { ...source, preferred_blocks: registration.presentation.preferred_blocks } : source + }), + } } function selectedBlockSummary(uiSchema, kinds) { return kinds.map((kind) => ({ kind, required_fields: uiSchema.$defs[kind].required, extra_fields_rejected: true })) } +function selectedBlockSummaryText(uiSchema, kinds) { + return selectedBlockSummary(uiSchema, kinds) + .map((block) => JSON.stringify(block)) + .join("\n") +} + function sourceRegistration(source, contracts) { const registered = contracts.registry.sources.find((item) => item.id === source.id) if (registered) return registered @@ -105,6 +117,7 @@ function sourceRegistration(source, contracts) { cadence: source.registration.cadence, freshness_sla_hours: source.registration.freshness_sla_hours, dashboard_role: source.registration.dashboard_role, + presentation: { preferred_blocks: source.preferred_blocks }, depends_on: source.registration.depends_on || [], target_path: `data/snapshots/${domain}/${name}/{YYYY}/{MM}/{YYYY-MM-DD}.json`, privacy: { @@ -221,7 +234,7 @@ Choose the smallest useful set from only the preferred blocks in the source inte Selected block summary. Read schemas/ui-blocks.schema.json for the exact nested fields and limits: \`\`\`json -${json(selectedBlockSummary(contracts.uiSchema, selectedKinds))} +${selectedBlockSummaryText(contracts.uiSchema, selectedKinds)} \`\`\` ### Validate, retry, publish diff --git a/scripts/lib/snapshot-policy.mjs b/scripts/lib/snapshot-policy.mjs index 9435c15..89fa8bc 100644 --- a/scripts/lib/snapshot-policy.mjs +++ b/scripts/lib/snapshot-policy.mjs @@ -88,6 +88,25 @@ export function validateSnapshotPolicy(snapshot, registration, { allowSynthetic } const blockIds = snapshot.data?.presentation?.blocks?.map((block) => block.id) || [] if (new Set(blockIds).size !== blockIds.length) errors.push("#/data/presentation/blocks: block IDs must be unique") + for (const [blockIndex, block] of (snapshot.data?.presentation?.blocks || []).entries()) { + const blockPath = `#/data/presentation/blocks/${blockIndex}` + if (block.kind === "line-chart") { + const seriesKeys = block.series.map((series) => series.key) + if (new Set(seriesKeys).size !== seriesKeys.length) errors.push(`${blockPath}/series: series keys must be unique`) + const xLabels = block.points.map((point) => point.x) + if (new Set(xLabels).size !== xLabels.length) errors.push(`${blockPath}/points: x labels must be unique`) + for (const [pointIndex, point] of block.points.entries()) { + const valueKeys = Object.keys(point.values).sort() + const expectedKeys = [...seriesKeys].sort() + if (valueKeys.length !== expectedKeys.length || valueKeys.some((key, index) => key !== expectedKeys[index])) + errors.push(`${blockPath}/points/${pointIndex}/values: keys must exactly match the declared series keys`) + } + } + if (block.kind === "table") { + const columnKeys = block.columns.map((column) => column.key) + if (new Set(columnKeys).size !== columnKeys.length) errors.push(`${blockPath}/columns: column keys must be unique`) + } + } walk(snapshot, (key, value, trail) => { if (unsafeKey.test(key)) errors.push(`#/${trail.join("/")}: secret-shaped keys are forbidden`) if (typeof value === "string" && unsafeText.test(value)) diff --git a/scripts/lib/theme-contrast.mjs b/scripts/lib/theme-contrast.mjs new file mode 100644 index 0000000..9e67e4d --- /dev/null +++ b/scripts/lib/theme-contrast.mjs @@ -0,0 +1,37 @@ +function contrastRatio(first, second) { + const luminance = (hex) => { + const channels = hex + .slice(1) + .match(/.{2}/g) + .map((value) => Number.parseInt(value, 16) / 255) + .map((value) => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)) + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] + } + const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a) + return (lighter + 0.05) / (darker + 0.05) +} + +export function validateCustomTheme(instance, file = "instance") { + if (instance.theme?.preset !== "custom") return [] + const issues = [] + for (const mode of ["light", "dark"]) { + const tokens = instance.theme.custom_tokens?.[mode] + if (!tokens) continue + const checks = [ + ["foreground", "background", 4.5], + ["card_foreground", "card", 4.5], + ["primary_foreground", "primary", 4.5], + ["accent_foreground", "accent", 4.5], + ["sidebar_foreground", "sidebar", 4.5], + ["border", "background", 3], + ["chart_1", "background", 3], + ["chart_2", "background", 3], + ["chart_3", "background", 3], + ] + for (const [foreground, background, minimum] of checks) { + if (contrastRatio(tokens[foreground], tokens[background]) < minimum) + issues.push(`${file}#/theme/custom_tokens/${mode}: ${foreground} against ${background} must have at least ${minimum}:1 contrast`) + } + } + return issues +} diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs index 2fe8198..c325402 100644 --- a/scripts/validate-data.mjs +++ b/scripts/validate-data.mjs @@ -5,6 +5,7 @@ import Ajv2020 from "ajv/dist/2020.js" import addFormats from "ajv-formats" import { decryptSnapshotEnvelope, loadSnapshotKey } from "./lib/snapshot-crypto.mjs" import { validateSnapshotPolicy } from "./lib/snapshot-policy.mjs" +import { validateCustomTheme } from "./lib/theme-contrast.mjs" const root = process.cwd() const errors = [] @@ -126,6 +127,7 @@ const instancePath = await access(path.join(root, "config/instance.local.json")) const instance = await readJson(instancePath) const validateInstance = ajv.getSchema("https://zaati-os.dev/schemas/instance.schema.json") if (!validateInstance(instance)) errors.push(...formatAjvErrors(instancePath, validateInstance.errors)) +else errors.push(...validateCustomTheme(instance, instancePath)) for (const sourceId of instance.enabled_sources || []) if (!byId.has(sourceId)) errors.push(`${instancePath}: unknown enabled source ${sourceId}`) diff --git a/src/App.tsx b/src/App.tsx index eb48bf0..127f06d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { import { BlockRenderer } from "@/components/BlockRenderer" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils" import { snapshotFreshness, type FreshnessState } from "@/lib/freshness" @@ -61,6 +62,19 @@ function initialMode(data: DashboardData): ThemeMode { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } +function initialView(data: DashboardData, fallback: string) { + const requested = new URL(window.location.href).searchParams.get("view") + if (requested === "start" && data.demoMode) return START_ID + if (data.sources.some((item) => item.definition.id === requested)) return requested as string + return data.demoMode ? START_ID : fallback +} + +function initialNow(data: DashboardData) { + const injected = data.demoMode ? new URL(window.location.href).searchParams.get("at") : null + const parsed = injected ? Date.parse(injected) : Number.NaN + return Number.isFinite(parsed) ? parsed : Date.now() +} + export function App() { const [data, setData] = useState(null) const [error, setError] = useState(null) @@ -86,7 +100,7 @@ export function App() { function DashboardApp({ data }: { data: DashboardData }) { const overviewId = data.sources.find((item) => item.definition.id === "overview:daily")?.definition.id || data.sources[0]?.definition.id || "" - const [selectedId, setSelectedId] = useState(data.demoMode ? START_ID : overviewId) + const [selectedId, setSelectedId] = useState(() => initialView(data, overviewId)) const [mode, setMode] = useState(() => initialMode(data)) const [palette, setPalette] = useState(() => localStorage.getItem("zaati-palette") || data.instance.theme.preset) const [density, setDensity] = useState(() => (localStorage.getItem("zaati-density") as Density) || data.instance.theme.density) @@ -100,7 +114,7 @@ function DashboardApp({ data }: { data: DashboardData }) { const [settingsOpen, setSettingsOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) const [sidebarCompact, setSidebarCompact] = useState(false) - const [now, setNow] = useState(() => Date.parse(data.generatedAt)) + const [now, setNow] = useState(() => initialNow(data)) const [historySnapshotId, setHistorySnapshotId] = useState("") const selected = data.sources.find((item) => item.definition.id === selectedId) const history = data.historyBySource[selectedId] || [] @@ -125,21 +139,32 @@ function DashboardApp({ data }: { data: DashboardData }) { background: "--background", foreground: "--foreground", card: "--card", + card_foreground: "--card-foreground", border: "--border", sidebar: "--sidebar", + sidebar_foreground: "--sidebar-foreground", chart_1: "--chart-1", chart_2: "--chart-2", chart_3: "--chart-3", } as const for (const token of Object.values(tokenMap)) root.style.removeProperty(token) if (palette === "custom") { - for (const [key, value] of Object.entries(data.instance.theme.custom_tokens)) { + for (const [key, value] of Object.entries(data.instance.theme.custom_tokens[mode] || {})) { if (key in tokenMap && /^#[0-9a-f]{6}$/i.test(value)) root.style.setProperty(tokenMap[key as keyof typeof tokenMap], value) } } document.title = data.instance.brand_name - document.documentElement.lang = data.instance.locale.split("-")[0] - localStorage.setItem("zaati-theme", mode) + document.documentElement.lang = data.instance.locale + try { + const locale = new Intl.Locale(data.instance.locale) as Intl.Locale & { + getTextInfo?: () => { direction: "ltr" | "rtl" } + textInfo?: { direction: "ltr" | "rtl" } + } + document.documentElement.dir = locale.getTextInfo?.().direction || locale.textInfo?.direction || "ltr" + } catch { + document.documentElement.dir = "ltr" + } + document.querySelector('meta[name="theme-color"]')?.setAttribute("content", mode === "dark" ? "#111512" : "#f6f7f4") localStorage.setItem("zaati-palette", palette) localStorage.setItem("zaati-density", density) localStorage.setItem("zaati-font", fontFamily) @@ -147,6 +172,14 @@ function DashboardApp({ data }: { data: DashboardData }) { localStorage.setItem("zaati-radius", radius) }, [data.instance, density, fontFamily, headingStyle, mode, palette, radius]) + useEffect(() => { + if (localStorage.getItem("zaati-theme") || data.instance.theme.default_mode !== "system") return + const media = window.matchMedia("(prefers-color-scheme: dark)") + const sync = () => setMode(media.matches ? "dark" : "light") + media.addEventListener("change", sync) + return () => media.removeEventListener("change", sync) + }, [data.instance.theme.default_mode, mode]) + useEffect(() => { const close = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -158,6 +191,12 @@ function DashboardApp({ data }: { data: DashboardData }) { return () => document.removeEventListener("keydown", close) }, []) + useEffect(() => { + const onPopState = () => setSelectedId(initialView(data, overviewId)) + window.addEventListener("popstate", onPopState) + return () => window.removeEventListener("popstate", onPopState) + }, [data, overviewId]) + useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 60_000) return () => window.clearInterval(timer) @@ -167,6 +206,13 @@ function DashboardApp({ data }: { data: DashboardData }) { setSelectedId(id) setHistorySnapshotId("") setMobileOpen(false) + const url = new URL(window.location.href) + url.searchParams.set("view", id === START_ID ? "start" : id) + window.history.pushState({}, "", url) + } + const chooseMode = (value: ThemeMode) => { + localStorage.setItem("zaati-theme", value) + setMode(value) } return (
@@ -208,8 +254,11 @@ function DashboardApp({ data }: { data: DashboardData }) {
{data.demoMode ? ( - - Synthetic demo + + + Synthetic demo ) : ( @@ -218,42 +267,37 @@ function DashboardApp({ data }: { data: DashboardData }) { )} -
- + + + + {settingsOpen ? ( setSettingsOpen(false)} onDensity={setDensity} onFont={setFontFamily} onHeading={setHeadingStyle} - onMode={setMode} + onMode={chooseMode} onPalette={setPalette} onRadius={setRadius} palette={palette} radius={radius} /> ) : null} -
+
@@ -300,100 +344,130 @@ function Sidebar({ }) { return ( <> - {mobileOpen ? ( - -
- -
- + ) : ( +
+ {data.instance.brand_mark} +
+ )} +
+

{data.instance.brand_name}

+

Private by default

- + +
+ +
+ +
) } @@ -428,6 +502,12 @@ function DashboardPage({ ) const freshness = snapshotFreshness(snapshot, now) + const layout = snapshot.data.presentation.layout + const blockGrid = { + dashboard: "lg:grid-cols-3", + focus: "mx-auto max-w-5xl lg:grid-cols-2", + timeline: "mx-auto max-w-3xl lg:grid-cols-1", + }[layout] return (
@@ -516,9 +596,9 @@ function DashboardPage({
) : null} -
- {snapshot.data.presentation.blocks.map((block) => ( - +
+ {snapshot.data.presentation.blocks.map((block, index) => ( + ))}