feat(theme): canonical theme/namespace lists + ship Summer Night (T-371, T-478)

The bundled-theme list and the Tier-0 i18n namespace list were each
hand-maintained in 3+ places that had drifted: the testmode harness loaded
8 themes (catppuccin silently unvalidated) while the app and contrast gate
loaded 10, and the i18n gate checked 4 of the shipped namespaces. Export
one canonical const each — kBundledThemePaths and kTier0Namespaces — and
have the app, the testmode harness, and the a11y gates iterate them.

Drift-proof meta-assertions:
- contrast gate fails if any theme YAML on disk is absent from
  kBundledThemePaths (so a theme can't sit unvalidated).
- i18n gate derives its subjects from assets/i18n/en_us/ at test time and
  asserts en/nl key parity for every shipped catalog (26, was 4), plus that
  every kTier0Namespaces entry has a shipped catalog.

Surfaced summer-night.yaml: a legacy-ported palette on disk, unbundled and
never contrast-checked. Per the maintainer's call, ship it: flesh the
palette to full token + syntax coverage (honouring the v1.2 colours, clide
derivation pattern for the new keys) and add a structurally identical
summer-night-hc sibling that clears the strict extended gate. Both pass.

No licenses.yaml change: summer-night is clide's own (ported from legacy
clide v1.2 under legacy/, MIT); the only third-party palette, Catppuccin,
is already acknowledged; no fonts added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 15:31:16 +02:00
co-authored by Claude Opus 4.8
parent 1e49f3e1d9
commit b7db22924d
12 changed files with 246 additions and 102 deletions
+22 -14
View File
@@ -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<File>().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');
});
}
+43 -36
View File
@@ -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 = <String, List<String>>{
'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<File>().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');
}
});
});
}