diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 9b7833c8..c529ba30 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -5951,3 +5951,7 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'status', 'backlog', 'in_progress', NULL, '2026-06-22 10:20:50', '2026-06-22 10:20:50.137', '2026-06-22 10:20:50.137', NULL, '438dbb6a04a7858c26c827dfe236bf5e', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'status', 'in_progress', 'in_progress', NULL, '2026-06-22 10:21:01', '2026-06-22 10:21:01.539', '2026-06-22 10:21:01.539', NULL, 'f6c39e32ded6c1a0a23248c93daed0af', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'status', 'in_progress', 'done', NULL, '2026-06-22 15:48:43', '2026-06-22 15:48:43.008', '2026-06-22 15:48:43.008', NULL, '2750ec604cb4c00ba21be9a69592bfeb', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDE5A3965GNRFS5WPZW878', 'status', 'backlog', 'in_progress', NULL, '2026-06-23 19:50:10', '2026-06-23 19:50:10.059', '2026-06-23 19:50:10.059', NULL, '7b2baa79a8dd71fd5642319ef87c29d1', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC46SEHH8NQY481VMGK66R', 'status', 'backlog', 'in_progress', NULL, '2026-06-23 19:50:10', '2026-06-23 19:50:10.066', '2026-06-23 19:50:10.066', NULL, 'a03e02614d5e3c2c7ffc795d84487a9e', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC46SEHH8NQY481VMGK66R', 'status', 'in_progress', 'done', NULL, '2026-06-24 13:28:43', '2026-06-24 13:28:43.038', '2026-06-24 13:28:43.038', NULL, '53c03d3229add40fdbcb41eed62cb42b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFKFYBZFKBX0WV574QJEH2BC', 'status', 'backlog', 'done', NULL, '2026-06-24 13:28:43', '2026-06-24 13:28:43.041', '2026-06-24 13:28:43.041', NULL, '016e6d0c845e076bb60fe3cb0c34b4c9', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 45fb8ab0..415cc723 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7457,3 +7457,24 @@ In the main column (the center "workspace" slot), the editor renders as a **top ## Tests Existing coverage (`test/builtin/editor/editor_extension_test.dart`, `test/builtin/default_layout/widget_test.dart`, `test/app_test.dart`) asserts the `editorOpen` flag toggles but does **not** assert the rendered split actually collapses with no leftover region. Add a widget test that opens the editor top split, closes the last panel, and asserts the workspace renders only the primary pane (no empty split / drag handle remaining).', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 14:15:25.011', '2026-06-22 15:48:43.008', NULL, '7f47fde921ee006e517d13f6b21d3fa2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDE5A3965GNRFS5WPZW878', 'story', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Close the release loop: back-tag 2.2.0–2.3.3, fix ci/release.sh, add tagging to the release ritual, widen the fast-path regex', 'No git tag since v2.1.0 despite five CHANGELOG releases; ci/release.sh exits 64 and still references the dissolved sidecar; the pre-push fast path''s safety argument cites "release CI on tagged versions" that does not exist; and the fast-path regex skips ALL tests for pushes touching test/, ci/, or the hook itself. + +Work items (separable commits): +1. Back-tag v2.2.0 through v2.3.3 at the release-cut commits (find them via the CHANGELOG version-bump commits). +2. Rewrite ci/release.sh for the single-process architecture or delete it and fold release steps into the Makefile — either way, no stub that exits 64. +3. Add tagging to the release ritual in .claude/skills/git-commit/SKILL.md (version bump + changelog move + tag in one documented step). +4. Widen the pre-push fast-path regex so changes under test/, ci/, and the hook itself run the full gate. + +This is also the blocking prerequisite the T-47 (self-update) refinement identified. Acceptance: git tag lists every released version; release.sh (or its replacement) runs end-to-end; fast-path regex covered by a hook test if feasible.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 22:03:00', '2026-06-23 19:50:10.059', NULL, '0f6f0c7e31506554a7e8cfa80e95713b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC46SEHH8NQY481VMGK66R', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'A11y/i18n/theme gates hand-enumerate their subjects and have fallen behind lib/', 'The three a11y gate tests hand-enumerate their subjects and have measurably drifted from lib/: the contrast gate checks fewer themes than lib/main.dart:443-454 actually loads (catppuccin is silently unvalidated — the same theme list also drifted between main.dart and the testmode harness), and the i18n gate checks 4 of 8 namespaces. The gates stay green while covering less — worst kind of drift. + +Fix (pattern: hand-enumerated lists drift; export the truth): make lib/ export one canonical const each for bundled themes, a11y gate subjects, and i18n namespaces; the gates and the testmode harness iterate those exports instead of their own lists. Add a meta-assertion where feasible (e.g. namespace list derived from the assets dir at test time) so a new theme/namespace cannot ship unvalidated. + +Acceptance: gates fail if a newly added theme/namespace is not covered; catppuccin contrast-checked; all 8 i18n namespaces checked; testmode harness consumes the same exported theme list.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:57:17', '2026-06-23 19:50:10.066', NULL, 'a33855731911444c4595567f3004191d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFKFYBZFKBX0WV574QJEH2BC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ship Summer Night theme + high-contrast sibling', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-24 13:09:16.923', '2026-06-24 13:09:16.923', NULL, '705001cc1fdcd0d09030df8b5a5c5e0e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC46SEHH8NQY481VMGK66R', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'A11y/i18n/theme gates hand-enumerate their subjects and have fallen behind lib/', 'The three a11y gate tests hand-enumerate their subjects and have measurably drifted from lib/: the contrast gate checks fewer themes than lib/main.dart:443-454 actually loads (catppuccin is silently unvalidated — the same theme list also drifted between main.dart and the testmode harness), and the i18n gate checks 4 of 8 namespaces. The gates stay green while covering less — worst kind of drift. + +Fix (pattern: hand-enumerated lists drift; export the truth): make lib/ export one canonical const each for bundled themes, a11y gate subjects, and i18n namespaces; the gates and the testmode harness iterate those exports instead of their own lists. Add a meta-assertion where feasible (e.g. namespace list derived from the assets dir at test time) so a new theme/namespace cannot ship unvalidated. + +Acceptance: gates fail if a newly added theme/namespace is not covered; catppuccin contrast-checked; all 8 i18n namespaces checked; testmode harness consumes the same exported theme list.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:57:17', '2026-06-24 13:28:43.038', NULL, '034c9261ad30a2da8b2e2144aa0a763a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFKFYBZFKBX0WV574QJEH2BC', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ship Summer Night theme + high-contrast sibling', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-24 13:09:16.923', '2026-06-24 13:28:43.041', NULL, '74542be3691f98a24e00961136927873', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c77ce0..5cb529f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,19 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Summer Night theme + high-contrast sibling.** The legacy v1.2 palette is + fleshed out to full token + syntax coverage and ships alongside a + contrast-hardened `summer-night-hc`, both selectable in Settings → Appearance. + (T-478) + ### Changed +- **Bundled themes and Tier-0 i18n namespaces resolve from one canonical list + each.** The app, testmode harness, and contrast/i18n gates iterate the shared + lists instead of drifting copies; a new theme or catalog is validated + automatically, and the i18n gate now checks every catalog for en/nl parity. + (T-371) + ### Fixed - **Default window opens larger (1600×900) on Linux.** 720p was short enough diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index 3741fc27..b8de4fe0 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -45,6 +45,7 @@ export 'src/focus.dart'; export 'src/i18n/catalog_loader.dart'; export 'src/i18n/fallback_chain.dart'; export 'src/i18n/i18n.dart'; +export 'src/i18n/tier0_namespaces.dart'; export 'src/net.dart'; export 'src/notify.dart'; export 'src/os.dart'; @@ -63,6 +64,7 @@ export 'src/panels/layout_preset.dart'; export 'src/panels/registry.dart'; export 'src/panels/slot_id.dart'; export 'src/panels/view_pane_snapshot.dart'; +export 'src/theme/bundled_themes.dart'; export 'src/theme/contrast.dart'; export 'src/theme/controller.dart'; export 'src/theme/loader.dart'; diff --git a/lib/kernel/src/i18n/tier0_namespaces.dart b/lib/kernel/src/i18n/tier0_namespaces.dart new file mode 100644 index 00000000..b760b72f --- /dev/null +++ b/lib/kernel/src/i18n/tier0_namespaces.dart @@ -0,0 +1,25 @@ +/// Canonical list of Tier-0 i18n namespaces preloaded at boot — the single +/// source of truth (T-371). +/// +/// `lib/main.dart` preloads exactly these (framework chrome under `core`, plus +/// the catalogs of the built-ins that activate at Tier 0); the i18n coverage +/// gate validates every one of them. The other ~17 catalogs that ship belong +/// to built-ins that activate lazily in later tiers — their catalogs load on +/// activation, and the gate validates them through the assets-dir sweep rather +/// than this preload list. +library; + +/// Namespaces whose catalogs are loaded at boot, before any extension that +/// owns them has activated. `core` holds framework chrome that lives outside +/// any extension (lib/widgets, lib/kernel, shared reader chrome — T-469). +const List kTier0Namespaces = [ + 'core', + 'builtin.default-layout', + 'builtin.welcome', + 'builtin.ipc-status', + 'builtin.theme-picker', + 'builtin.terminal', + 'builtin.files', + 'builtin.claude', + 'builtin.editor', +]; diff --git a/lib/kernel/src/theme/bundled_themes.dart b/lib/kernel/src/theme/bundled_themes.dart new file mode 100644 index 00000000..035c7861 --- /dev/null +++ b/lib/kernel/src/theme/bundled_themes.dart @@ -0,0 +1,38 @@ +/// Canonical list of the bundled theme asset paths — the single source of +/// truth for which themes ship (T-371). +/// +/// Three consumers used to hand-maintain their own copy and drifted apart +/// (main.dart loaded 10, the testmode harness loaded 8 — catppuccin was +/// silently unvalidated, and the WCAG contrast gate had a third copy): +/// +/// - `lib/main.dart` `_loadBundledThemes()` — what the running app loads. +/// - `lib/test_app.dart` — the testmode platform-integration harness. +/// - `test/a11y/contrast_test.dart` — the WCAG-AA contrast gate. +/// +/// They now all iterate this list, so adding a theme here loads it, validates +/// it, and ships it everywhere at once. The contrast gate additionally asserts +/// every `.yaml` under [kThemesDir] is in this list, so a new theme cannot sit +/// on disk (or ship) unvalidated. +library; + +/// Directory holding the theme YAML sources, relative to the repo root. +const String kThemesDir = 'lib/kernel/src/theme/themes'; + +/// Theme YAML asset paths bundled into the app, in display order: the base +/// themes, then their `-hc` high-contrast siblings, then third-party / ported +/// palettes paired with their own `-hc` siblings. Every base theme has a +/// structurally identical `-hc` sibling that clears the strict contrast gate. +const List kBundledThemePaths = [ + '$kThemesDir/clide.yaml', + '$kThemesDir/midnight.yaml', + '$kThemesDir/paper.yaml', + '$kThemesDir/terminal.yaml', + '$kThemesDir/clide-hc.yaml', + '$kThemesDir/midnight-hc.yaml', + '$kThemesDir/paper-hc.yaml', + '$kThemesDir/terminal-hc.yaml', + '$kThemesDir/catppuccin-mocha.yaml', + '$kThemesDir/catppuccin-mocha-hc.yaml', + '$kThemesDir/summer-night.yaml', + '$kThemesDir/summer-night-hc.yaml', +]; diff --git a/lib/kernel/src/theme/themes/summer-night-hc.yaml b/lib/kernel/src/theme/themes/summer-night-hc.yaml new file mode 100644 index 00000000..14b4b01a --- /dev/null +++ b/lib/kernel/src/theme/themes/summer-night-hc.yaml @@ -0,0 +1,44 @@ +# summer-night-hc — high-contrast sibling of `summer-night`. +# +# Same cyan/teal/pink silhouette and identical schema; muted text, status +# chips, syntax tokens, and the focus border are brightened to clear the +# strict contrast gate (D-22 + D-69, `extendedPairs` in +# lib/kernel/src/theme/contrast.dart). Backgrounds/surface/foreground are +# unchanged from the base so the two read as the same theme. + +name: summer-night-hc +display_name: Summer Night (high contrast) +dark: true + +palette: + primary: "#5cc8ec" + secondary: "#5fd6cb" + accent: "#ff84a6" + + background: "#21262f" + surface: "#393e48" + panel: "#292e38" + + foreground: "#e2e8f5" + muted: "#c4cad6" + + success: "#3fd6c0" + warning: "#e6b072" + error: "#ff9b99" + info: "#6cc8ec" + + surfaceHi: "#434a57" + border: "#3a414d" + borderHi: "#5cc8ec" + textDim: "#c4cad6" + textMute: "#9aa2b2" + accentSoft: "#21ff84a6" + +syntax: + keyword: "#ffa0c0" # pink + type: "#6cd4f0" # cyan + string: "#9fe0c0" # green + number: "#f0c888" # amber + comment: "#9aa2b2" # muted + method: "#80d0e8" # teal-cyan + punct: "#c4cad6" # dim diff --git a/lib/kernel/src/theme/themes/summer-night.yaml b/lib/kernel/src/theme/themes/summer-night.yaml index 41be6069..363d36d2 100644 --- a/lib/kernel/src/theme/themes/summer-night.yaml +++ b/lib/kernel/src/theme/themes/summer-night.yaml @@ -1,24 +1,27 @@ -# Summer Night — ported from legacy clide v1.2.0. -# Palette-only; the three-tier resolver fills semantic + surface from -# defaults. Override sections land here as the token surface grows. +# Summer Night — cyan/teal/pink on blue-grey (ported from legacy clide v1.2.0). +# +# The 12 original palette colors are kept as the v1.2 designer chose them. The +# expanded keys (surfaceHi, border/borderHi, textDim/textMute, accentSoft) and +# the syntax set follow the clide derivation pattern, re-tinted to this theme's +# identity. Base honours the design and clears the canonical contrast gate; +# the high-contrast sibling (summer-night-hc) clears the strict extended gate. name: summer-night display_name: Summer Night dark: true palette: - # Accents (legacy names: primary/secondary/accent) - primary: "#00a3d2" # cyan - secondary: "#00a9b9" # teal - accent: "#fa5f8b" # pink + # Accents (legacy: primary cyan / secondary teal / accent pink) + primary: "#00a3d2" + secondary: "#00a9b9" + accent: "#fa5f8b" # Backgrounds background: "#21262f" surface: "#393e48" panel: "#292e38" - # Text. `muted` is WCAG-AA-calibrated against `panel` — don't darken - # without re-running the a11y/contrast suite. + # Text. `muted` is WCAG-AA-calibrated against `panel`. foreground: "#e2e8f5" muted: "#a6adbb" @@ -27,3 +30,20 @@ palette: warning: "#d08447" error: "#f06c6f" info: "#00a3d2" + + # Expanded keys (clide pattern, summer-night tint) + surfaceHi: "#434a57" + border: "#3a414d" + borderHi: "#4a5260" + textDim: "#9aa2b2" + textMute: "#727a8a" + accentSoft: "#21fa5f8b" + +syntax: + keyword: "#fa8fb0" # pink + type: "#5ec8e6" # cyan + string: "#7fd3a8" # green + number: "#e0b070" # amber + comment: "#727a8a" # muted + method: "#6fc6e0" # teal-cyan + punct: "#9aa2b2" # dim diff --git a/lib/main.dart b/lib/main.dart index 7ca3dbd2..41ad62b9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -393,7 +393,7 @@ Future main() async { appDir: appDir, bundledThemes: themes, i18nLoader: AssetCatalogLoader(bundle: rootBundle), - preloadNamespaces: _tier0Namespaces, + preloadNamespaces: kTier0Namespaces, // Languages the UI can switch to (Settings → Appearance, T-462). Each needs // an assets/i18n// catalog folder; root_shell applies the persisted // app.locale on boot. @@ -537,39 +537,13 @@ Future _resolveAppDir() async { Future> _loadBundledThemes() async { const loader = ThemeLoader(); - const paths = [ - 'lib/kernel/src/theme/themes/clide.yaml', - 'lib/kernel/src/theme/themes/midnight.yaml', - 'lib/kernel/src/theme/themes/paper.yaml', - 'lib/kernel/src/theme/themes/terminal.yaml', - 'lib/kernel/src/theme/themes/clide-hc.yaml', - 'lib/kernel/src/theme/themes/midnight-hc.yaml', - 'lib/kernel/src/theme/themes/paper-hc.yaml', - 'lib/kernel/src/theme/themes/terminal-hc.yaml', - 'lib/kernel/src/theme/themes/catppuccin-mocha.yaml', - 'lib/kernel/src/theme/themes/catppuccin-mocha-hc.yaml', - ]; final out = []; - for (final p in paths) { + for (final p in kBundledThemePaths) { out.add(await loader.fromAsset(rootBundle, p)); } return out; } -/// Every Tier-0 extension that ships an i18n catalog. Extensions -/// registered but not active (the 17 stubs) don't preload — their -/// catalogs load lazily on activate in later tiers. - -const List _tier0Namespaces = [ - // Framework chrome that lives outside any extension (lib/widgets, lib/kernel, - // shared reader chrome) resolves under the 'core' namespace (T-469). - 'core', - 'builtin.default-layout', - 'builtin.welcome', - 'builtin.ipc-status', - 'builtin.theme-picker', - 'builtin.terminal', - 'builtin.files', - 'builtin.claude', - 'builtin.editor', -]; +// The Tier-0 i18n namespace list now lives in +// lib/kernel/src/i18n/tier0_namespaces.dart as `kTier0Namespaces`, the single +// source of truth shared with the i18n coverage gate (T-371). diff --git a/lib/test_app.dart b/lib/test_app.dart index 12b47baa..a7b4de07 100644 --- a/lib/test_app.dart +++ b/lib/test_app.dart @@ -247,17 +247,7 @@ class _ClideTestAppState extends State { // Theme loading try { const loader = ThemeLoader(); - const paths = [ - 'lib/kernel/src/theme/themes/clide.yaml', - 'lib/kernel/src/theme/themes/midnight.yaml', - 'lib/kernel/src/theme/themes/paper.yaml', - 'lib/kernel/src/theme/themes/terminal.yaml', - 'lib/kernel/src/theme/themes/clide-hc.yaml', - 'lib/kernel/src/theme/themes/midnight-hc.yaml', - 'lib/kernel/src/theme/themes/paper-hc.yaml', - 'lib/kernel/src/theme/themes/terminal-hc.yaml', - ]; - for (final p in paths) { + for (final p in kBundledThemePaths) { final name = p.split('/').last.replaceAll('.yaml', ''); try { final theme = await loader.fromAsset(rootBundle, p); @@ -277,7 +267,7 @@ class _ClideTestAppState extends State { final themes = []; try { const loader = ThemeLoader(); - themes.add(await loader.fromAsset(rootBundle, 'lib/kernel/src/theme/themes/clide.yaml')); + themes.add(await loader.fromAsset(rootBundle, kBundledThemePaths.first)); } catch (_) {} final services = await KernelServices.boot( diff --git a/test/a11y/contrast_test.dart b/test/a11y/contrast_test.dart index 863ff634..9c849e9e 100644 --- a/test/a11y/contrast_test.dart +++ b/test/a11y/contrast_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:clide/kernel/kernel.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_test/flutter_test.dart'; @@ -10,24 +12,15 @@ import 'package:flutter_test/flutter_test.dart'; /// Themes whose name ends in `-hc` (high-contrast) or `-cb` /// (colour-blind) additionally have to clear the stricter /// [extendedPairs] set — D-69. +/// +/// The subject list is [kBundledThemePaths] — the SAME list main.dart loads +/// and the testmode harness validates (T-371). A meta-test asserts every +/// `.yaml` on disk is in that list, so a new theme cannot ship unvalidated. void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('contrast — all bundled themes meet WCAG AA', () { - const bundledPaths = [ - 'lib/kernel/src/theme/themes/clide.yaml', - 'lib/kernel/src/theme/themes/midnight.yaml', - 'lib/kernel/src/theme/themes/paper.yaml', - 'lib/kernel/src/theme/themes/terminal.yaml', - 'lib/kernel/src/theme/themes/clide-hc.yaml', - 'lib/kernel/src/theme/themes/midnight-hc.yaml', - 'lib/kernel/src/theme/themes/paper-hc.yaml', - 'lib/kernel/src/theme/themes/terminal-hc.yaml', - 'lib/kernel/src/theme/themes/catppuccin-mocha.yaml', - 'lib/kernel/src/theme/themes/catppuccin-mocha-hc.yaml', - ]; - - for (final path in bundledPaths) { + for (final path in kBundledThemePaths) { test('theme: $path', () async { final def = await const ThemeLoader().fromAsset(rootBundle, path); const resolver = ThemeResolver(); @@ -57,4 +50,19 @@ void main() { }); } }); + + // Drift guard (T-371): a theme YAML on disk that isn't in kBundledThemePaths + // would never be loaded, shipped, or contrast-checked. Fail loudly so a new + // theme has to be added to the canonical list (which auto-validates it above) + // rather than sit on disk unvalidated. + test('every theme YAML on disk is in kBundledThemePaths', () { + final onDisk = Directory( + kThemesDir, + ).listSync().whereType().map((f) => f.path).where((p) => p.endsWith('.yaml')).map((p) => p.replaceAll(r'\', '/')).toSet(); + final bundled = kBundledThemePaths.toSet(); + final unbundled = onDisk.difference(bundled); + expect(unbundled, isEmpty, reason: 'theme YAML(s) on disk but not in kBundledThemePaths (so unvalidated/unshipped): $unbundled'); + final missing = bundled.difference(onDisk); + expect(missing, isEmpty, reason: 'kBundledThemePaths references missing file(s): $missing'); + }); } diff --git a/test/a11y/i18n_coverage_test.dart b/test/a11y/i18n_coverage_test.dart index ee2561e4..aad59e56 100644 --- a/test/a11y/i18n_coverage_test.dart +++ b/test/a11y/i18n_coverage_test.dart @@ -1,54 +1,61 @@ +import 'dart:io'; import 'dart:ui'; import 'package:clide/kernel/kernel.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_test/flutter_test.dart'; -/// Asserts every bundled i18n catalog is well-formed and every key the -/// Tier-0 built-ins ask for actually resolves. +/// i18n coverage gate. /// -/// The second check is important: in a text-driven i18n system missing -/// keys show the placeholder, so a runtime lookup test wouldn't "fail" -/// on a typo — we have to assert the keys exist up front. +/// The subject list is derived from the shipped catalogs on disk +/// (`assets/i18n/en_us/*.json`), NOT a hand-maintained list — so a newly +/// added catalog is validated automatically and cannot ship unchecked +/// (T-371; the old gate hand-enumerated 4 of the namespaces and silently +/// covered less as the app grew). +/// +/// Per catalog we assert: the en_US catalog loads and is non-empty, and the +/// nl_NL pack is at exact key parity (no missing or extra keys) — so a locale +/// switch never falls back to English for a shipped string, and a stray +/// translation key can't rot unnoticed. void main() { TestWidgetsFlutterBinding.ensureInitialized(); - /// (namespace, key) pairs referenced by Tier-0 built-ins. Extend when - /// new keys land. - const referenced = >{ - 'builtin.welcome': ['title', 'subtitle', 'open-project', 'open-project.hint', 'tab.title'], - 'builtin.ipc-status': ['connected', 'connected.hint', 'disconnected', 'disconnected.hint'], - 'builtin.theme-picker': ['modal.title', 'modal.cancel', 'modal.cancel.hint', 'row.select.hint'], - 'builtin.default-layout': ['command.reset', 'preset.classic'], - }; + const enDir = 'assets/i18n/en_us'; + final namespaces = Directory( + enDir, + ).listSync().whereType().where((f) => f.path.endsWith('.json')).map((f) => f.uri.pathSegments.last.replaceAll('.json', '')).toList()..sort(); - group('i18n coverage (Tier 0)', () { - for (final entry in referenced.entries) { - final ns = entry.key; - test('$ns catalog contains every referenced key', () async { + group('i18n coverage — every shipped catalog', () { + test('the asset dir actually ships catalogs', () { + expect(namespaces, isNotEmpty, reason: 'no catalogs found under $enDir'); + }); + + for (final ns in namespaces) { + test('$ns: en_US loads non-empty, nl_NL at key parity', () async { final loader = AssetCatalogLoader(bundle: rootBundle); - final catalog = await loader.load(ns, const Locale('en', 'US')); - expect(catalog, isNotEmpty, reason: 'catalog for "$ns" failed to load (asset path wrong?)'); - for (final key in entry.value) { - expect(catalog.containsKey(key), isTrue, reason: 'namespace "$ns" catalog is missing key "$key"'); - } + final en = await loader.load(ns, const Locale('en', 'US')); + expect(en, isNotEmpty, reason: 'en_US catalog for "$ns" failed to load (asset path wrong?)'); + final nl = await loader.load(ns, const Locale('nl', 'NL')); + expect(nl, isNotEmpty, reason: 'nl_NL catalog for "$ns" missing or empty'); + + final enKeys = en.keys.toSet(); + final nlKeys = nl.keys.toSet(); + expect(nlKeys.difference(enKeys), isEmpty, reason: 'nl_NL "$ns" has keys absent from en_US: ${nlKeys.difference(enKeys)}'); + expect(enKeys.difference(nlKeys), isEmpty, reason: 'nl_NL "$ns" is missing keys present in en_US: ${enKeys.difference(nlKeys)}'); }); } }); - // The bundled Dutch pack (T-462) must load and cover the same Tier-0 keys, so - // a locale switch never falls back to English for a built-in label. - group('i18n coverage — Dutch pack (nl-NL)', () { - for (final entry in referenced.entries) { - final ns = entry.key; - test('$ns nl_NL catalog covers every referenced key', () async { - final loader = AssetCatalogLoader(bundle: rootBundle); - final catalog = await loader.load(ns, const Locale('nl', 'NL')); - expect(catalog, isNotEmpty, reason: 'nl_NL catalog for "$ns" failed to load'); - for (final key in entry.value) { - expect(catalog.containsKey(key), isTrue, reason: 'nl_NL "$ns" missing key "$key"'); - } - }); - } + // The Tier-0 preload list (loaded at boot, before its owning extension + // activates) must name only catalogs that actually ship — else boot preloads + // a missing namespace. The reverse isn't required: most catalogs load lazily + // on extension activation, not at boot. + group('i18n coverage — Tier-0 preload set is honest', () { + test('every kTier0Namespaces entry has a shipped en_US catalog', () { + final shipped = namespaces.toSet(); + for (final ns in kTier0Namespaces) { + expect(shipped.contains(ns), isTrue, reason: 'kTier0Namespaces names "$ns" but no $enDir/$ns.json ships'); + } + }); }); }