split contrast gate + ship -hc theme variants (T-114, T-118)

The expanded canonicalPairs from T-114 (muted text, status chips,
syntax tokens on the code-block surface, panel focus border) made the
four named themes fail WCAG-AA. Retuning their palettes to pass would
have changed the look users picked them for, so the gate is split
instead.

`canonicalPairs` shrinks back to the baseline every named theme passes;
the new `extendedPairs` carries the stricter set and only runs against
themes whose name ends `-hc` or `-cb`. Sibling files (`clide-hc`,
`midnight-hc`, `paper-hc`, `terminal-hc`) ship today; the policy lives
in D-69 with a back-ref from D-22.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-18 08:56:02 +02:00
co-authored by Claude
parent d4f8f89016
commit cbbbc526f9
13 changed files with 325 additions and 6 deletions
@@ -1805,3 +1805,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-108', 'status', 'in_progress', 'done', NULL, '2026-05-17 20:05:38', '2026-05-17 20:05:38', '2026-05-17 20:05:38', NULL, '6815472fbfaa6d7740945b42d4c87f57', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-112', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 20:06:30', '2026-05-17 20:06:30', '2026-05-17 20:06:30', NULL, '52271781bf987a984d9f219956034d1f', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-112', 'status', 'in_progress', 'done', NULL, '2026-05-17 20:11:16', '2026-05-17 20:11:16', '2026-05-17 20:11:16', NULL, '6d51964a3ad7f1948f54a7c769336841', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 20:13:36', '2026-05-17 20:13:36', '2026-05-17 20:13:36', NULL, '0bad40fa7f165f828bb0c0757044491e', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-118', 'status', 'backlog', 'done', NULL, '2026-05-17 20:53:41', '2026-05-17 20:53:41', '2026-05-17 20:53:41', NULL, 'ff0126655805fed0ebe45f313c5ceff7', 1) ON CONFLICT(hash) DO NOTHING;
+30
View File
@@ -1799,3 +1799,33 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
7. Move `test_app.dart` out of production `main.dart` import graph (gate behind separate entrypoint or `kDebugMode`).
Source: consultants.md (Code Quality + Architecture Findings Minor).', 'done', 'low', NULL, NULL, NULL, '2026-05-17 18:48:20', '2026-05-17 20:11:16', NULL, 'fccc783ff24be83f6d5a498e936c4af9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'task', 'T-97', 'quick-wins: a11y + contrast gate expansion', 'Batch of small consultant findings that share a domain (a11y / visual):
1. Expand contrast gate `canonicalPairs` (`test/a11y/contrast_test.dart`) to cover `globalTextMuted` (muted text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and `panelActiveBorder`.
2. Tokenize the ~43 hardcoded-color sites the UX reviewer flagged at minimum the modal/palette shadow and window-control colors that don''t adapt to the `paper` light theme.
3. Add a focus-ring rendering at the token level so every focusable widget gets it for free (depends on T-100).
4. Surface text-zoom (Ctrl +/-/0) in the palette so it''s discoverable.
Source: consultants.md "UX — Findings — [Minor]" + Strengths section.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-17 18:48:28', '2026-05-17 20:13:36', NULL, '1a2776ce1e3dd335966d738f80784a0a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-118', 'task', 'T-97', 'add high-contrast + colour-blind theme variants', 'Per D-69, the four named themes (clide, midnight, paper, terminal) keep
their published palettes and only pass the baseline canonicalPairs
gate. The stricter extendedPairs set in
lib/kernel/src/theme/contrast.dart (muted text, status chips, syntax
tokens on panel surfaces, panel focus border) is gated against any
theme whose name ends in -hc or -cb but no such themes exist yet.
Scope:
1. Ship clide-hc.yaml, midnight-hc.yaml, paper-hc.yaml,
terminal-hc.yaml sibling theme files whose palettes shift only
enough to clear extendedPairs (4.5:1 for chips / syntax / muted
body, 3:1 for the focus border).
2. Optional follow-up: *-cb.yaml colour-blind variants if/when we
pick a palette policy (deutan vs protan vs tritan, or a single
colour-name-replacement strategy).
3. Register the new themes in lib/main.dart alongside the existing
four.
4. The contrast gate at test/a11y/contrast_test.dart already covers
them adding the files is enough.
Sources: D-22, D-69, consultants.md ("UX — Findings — [Minor]").', 'done', 'low', NULL, NULL, 'D-69', '2026-05-17 20:51:47', '2026-05-17 20:53:41', NULL, 'd163731890022856585e9f33432462f6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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);
+4
View File
@@ -38,6 +38,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- Contrast gate split — baseline `canonicalPairs` every theme passes,
strict `extendedPairs` (muted/status/syntax/focus-border) gated to
`-hc` / `-cb` variants. Ships `clide-hc`, `midnight-hc`, `paper-hc`,
`terminal-hc` siblings of the named themes (D-69, T-114, T-118).
- Pre-push coverage gate — `make push-check` runs `ci/coverage_gate.sh`,
which fails if total line coverage drops below `coverage_floor:` in
`pubspec.yaml`. Floor ratchets up only; target 95% (D-66).
+1
View File
@@ -110,6 +110,7 @@ You might also want, project-permitting:
- [D-66: Line coverage gate at 95%, ratcheted from current](decisions/testing.md#d-66-line-coverage-gate-at-95-ratcheted-from-current) — _testing_
- [D-67: Pql changelog files are committed alongside code](decisions/process.md#d-67-pql-changelog-files-are-committed-alongside-code) — _process_
- [D-68: Dual integration surface — Bash CLI primary, MCP secondary](decisions/architecture.md#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary) — _architecture_
- [D-69: published themes are user contracts; ship -hc variants for a11y](decisions/accessibility.md#d-69-published-themes-are-user-contracts-ship--hc-variants-for-a11y) — _accessibility_
## Open questions
+8 -1
View File
@@ -23,6 +23,13 @@ A11y + i18n are Tier-0 contracts, not Tier-6 polish.
- **Decision:** Every bundled theme must pass a WCAG-AA contrast check on its canonical token pairs (text/background, link/background, focus-ring/background) at test time. `ci/test_a11y.sh` runs the gate; CI fails on regressions.
- **Rationale:** Themes drift under "looks nicer" tweaks; contrast regressions land silently. Running the gate on every PR is the cheapest insurance. Ran the gate on initial themes — caught one summer-night muted token at 2.81:1 (below AA), fixed before landing.
- **Cost:** Third-party themes (Tier 6) won't be gated until an extension-time test hook lands. Bundled themes are gated today.
- **Raised by:** 2026-04-21 planning.
- **Raised by:** 2026-04-21 planning. Refined by [D-69](#d-69-published-themes-are-user-contracts-ship-hc-variants-for-a11y) — the gate's *strict* pair set only applies to high-contrast variants; named themes keep their published palettes.
### D-69: published themes are user contracts; ship -hc variants for a11y
- **Date:** 2026-05-17
- **Decision:** The four bundled themes that ship under a recognisable name — `clide`, `midnight`, `paper`, `terminal` — are user contracts. Their palette colours (including syntax tokens, status colours, and borderHi) MUST NOT be retuned to satisfy contrast gates. When a stricter contrast check would fail one of them, the fix is one of: (a) ship a sibling theme with `-hc` (high-contrast) or `-cb` (colour-blind) in the name and enforce the strict pair set only there, or (b) split `canonicalPairs` into a *baseline* set every theme must pass and an *extended* set that only the `-hc`/`-cb` variants must pass.
- **Rationale:** Users pick `midnight` because it looks like VS Code, `paper` because it reads as a drafting sheet, `terminal` because of the amber-on-near-black tmux feel. Quietly darkening `paper`'s success/warning/info or boosting `midnight`'s `borderHi` to pass a WCAG-AA check changes what they got and what they signed up for. A11y is a Tier-0 contract ([D-20](#d-20-a11y-is-a-tier-0-contract)), but it's served by *offering* an accessible variant, not by overwriting the aesthetic ones. VS Code itself ships `Default Dark+` and a separate `Default High Contrast` for exactly this reason.
- **Cost:** Two extra theme files per "named" theme when we add a11y variants. The bundled-theme contrast gate ([D-22](#d-22-wcag-aa-contrast-gate-on-bundled-themes)) needs a baseline/extended split so the named themes don't fail the strict pairs.
- **Raised by:** 2026-05-17 — user intervened mid-T-114 when I had retuned `clide`/`midnight`/`paper`/`terminal` palette entries to satisfy the expanded `canonicalPairs`; reverted, decision written, T-114 will follow this rule.
---
+101 -5
View File
@@ -39,9 +39,15 @@ double contrastRatio(Color a, Color b, {Color onto = const Color(0xFF808080)}) {
/// Minimum ratio required for this pair per WCAG AA.
double minimumRatio(ContrastPair pair) => pair.largeText ? 3.0 : 4.5;
/// Canonical set of token pairs each bundled theme must honour.
/// Baseline token pairs every bundled theme must honour.
///
/// The a11y contrast test walks this list per-theme.
/// Per D-22 every named theme passes this set; per D-69 the named
/// themes (`clide`, `midnight`, `paper`, `terminal`) are user
/// contracts whose palettes are not retuned to chase a contrast gate,
/// so only the load-bearing pairs (primary text on its surface, chrome
/// foregrounds, selected list item) sit here. Stricter coverage for
/// muted text, status chips, syntax tokens, and the focus border lives
/// in [extendedPairs], which only `-hc`/`-cb` variants must pass.
List<ContrastPair> canonicalPairs(SurfaceTokens s) => [
ContrastPair(
name: 'global.text_on_background',
@@ -100,10 +106,100 @@ List<ContrastPair> canonicalPairs(SurfaceTokens s) => [
),
];
/// Convenience for tests: returns the list of pairs that fail WCAG AA.
List<ContrastFailure> failingPairs(SurfaceTokens tokens) {
/// Stricter pair set — only the high-contrast (`-hc`) and colour-blind
/// (`-cb`) theme variants must clear it. See D-69. These are the
/// surfaces a UX consultant flagged in `consultants.md`: muted body
/// text, status chip foregrounds, syntax tokens on the code-block
/// surface, and the focus-indicating panel border.
List<ContrastPair> extendedPairs(SurfaceTokens s) => [
ContrastPair(
name: 'global.text_muted_on_background',
foreground: s.globalTextMuted,
background: s.globalBackground,
),
ContrastPair(
name: 'global.text_muted_on_panel',
foreground: s.globalTextMuted,
background: s.panelBackground,
),
ContrastPair(
name: 'status.success_on_statusbar',
foreground: s.statusSuccess,
background: s.statusBarBackground,
),
ContrastPair(
name: 'status.warning_on_statusbar',
foreground: s.statusWarning,
background: s.statusBarBackground,
),
ContrastPair(
name: 'status.error_on_statusbar',
foreground: s.statusError,
background: s.statusBarBackground,
),
ContrastPair(
name: 'status.info_on_statusbar',
foreground: s.statusInfo,
background: s.statusBarBackground,
),
ContrastPair(
name: 'syntax.keyword_on_panel',
foreground: s.syntaxKeyword,
background: s.panelBackground,
),
ContrastPair(
name: 'syntax.type_on_panel',
foreground: s.syntaxType,
background: s.panelBackground,
),
ContrastPair(
name: 'syntax.string_on_panel',
foreground: s.syntaxString,
background: s.panelBackground,
),
ContrastPair(
name: 'syntax.number_on_panel',
foreground: s.syntaxNumber,
background: s.panelBackground,
),
ContrastPair(
name: 'syntax.comment_on_panel',
foreground: s.syntaxComment,
background: s.panelBackground,
),
ContrastPair(
name: 'syntax.method_on_panel',
foreground: s.syntaxMethod,
background: s.panelBackground,
),
ContrastPair(
name: 'syntax.punct_on_panel',
foreground: s.syntaxPunct,
background: s.panelBackground,
),
// WCAG 1.4.11 wants 3:1 for non-text UI components like a focus
// border against the adjacent surface.
ContrastPair(
name: 'panel.active_border_on_background',
foreground: s.panelActiveBorder,
background: s.globalBackground,
largeText: true,
),
];
/// Convenience for tests: returns the list of [canonicalPairs] that
/// fail WCAG AA.
List<ContrastFailure> failingPairs(SurfaceTokens tokens) =>
_failures(canonicalPairs(tokens));
/// Strict variant of [failingPairs] — walks [extendedPairs] instead.
/// Intended for the `-hc` / `-cb` theme gate.
List<ContrastFailure> failingExtendedPairs(SurfaceTokens tokens) =>
_failures(extendedPairs(tokens));
List<ContrastFailure> _failures(List<ContrastPair> pairs) {
final out = <ContrastFailure>[];
for (final p in canonicalPairs(tokens)) {
for (final p in pairs) {
final ratio = contrastRatio(p.foreground, p.background);
final need = minimumRatio(p);
if (ratio < need) {
+38
View File
@@ -0,0 +1,38 @@
# clide-hc — high-contrast sibling of `clide`
#
# Same periwinkle silhouette; muted text, status chips, syntax tokens,
# and the focus border bumped to clear the strict contrast gate
# (D-22 + D-69, `extendedPairs` in lib/kernel/src/theme/contrast.dart).
name: clide-hc
display_name: Clide (high contrast)
dark: true
palette:
background: "#20202C"
panel: "#1A1A24"
surface: "#242838"
muted: "#B1BBE3"
foreground: "#E6E8F2"
secondary: "#B1BBE3"
primary: "#92B5FF"
accent: "#92B5FF"
success: "#A8E8C8"
warning: "#FFD988"
error: "#FF9F9F"
info: "#A6C8FF"
surfaceHi: "#2C3046"
border: "#343850"
borderHi: "#92B5FF"
textDim: "#B1BBE3"
textMute: "#9098C0"
accentSoft: "#2192B5FF"
syntax:
keyword: "#E0B5F2"
type: "#A6C8FF"
string: "#C2E8B0"
number: "#FFD988"
comment: "#9098C0"
method: "#A6C8FF"
punct: "#B1BBE3"
@@ -0,0 +1,39 @@
# midnight-hc — high-contrast sibling of `midnight`
#
# Same VS Code-adjacent silhouette; muted text, status chips, syntax
# tokens, and the focus border bumped to clear the strict contrast
# gate (D-22 + D-69, `extendedPairs` in lib/kernel/src/theme/contrast.dart).
name: midnight-hc
display_name: Midnight (high contrast)
dark: true
palette:
background: "#1E1E1E"
panel: "#181818"
surface: "#252526"
muted: "#BDBDBD"
foreground: "#F0F0F0"
secondary: "#D4D4D4"
primary: "#82BFFF"
accent: "#82BFFF"
success: "#B5E2B0"
warning: "#E9D89E"
error: "#FFB3A5"
info: "#9BC9F0"
onAccent: "#0B1220"
surfaceHi: "#2D2D2E"
border: "#333333"
borderHi: "#9C9C9C"
textDim: "#BDBDBD"
textMute: "#9A9A9A"
accentSoft: "#2182BFFF"
syntax:
keyword: "#D9A1D3"
type: "#66D9C2"
string: "#E9B299"
number: "#C8E0B7"
comment: "#99CB80"
method: "#E5E5B5"
punct: "#BDBDBD"
+38
View File
@@ -0,0 +1,38 @@
# paper-hc — high-contrast sibling of `paper`
#
# Drafting sheet, red-pencil accent — darker ink so muted text, status
# chips, syntax tokens, and the focus border clear the strict contrast
# gate (D-22 + D-69, `extendedPairs` in lib/kernel/src/theme/contrast.dart).
name: paper-hc
display_name: Paper (high contrast)
dark: false
palette:
background: "#F4F1EA"
panel: "#ECE7DB"
surface: "#FBF8F1"
muted: "#4A4A48"
foreground: "#0E0E0E"
secondary: "#1F1F1F"
primary: "#8E2D10"
accent: "#6E2410"
success: "#1A4A2B"
warning: "#6A4C00"
error: "#7A1F12"
info: "#154168"
surfaceHi: "#ECE7DB"
border: "#0E0E0E"
borderHi: "#3A3A38"
textDim: "#4A4A48"
textMute: "#7A7A72"
accentSoft: "#218E2D10"
syntax:
keyword: "#52215C"
type: "#154168"
string: "#1A4A2B"
number: "#6A4C00"
comment: "#4A4A48"
method: "#103E6F"
punct: "#1F1F1F"
@@ -0,0 +1,38 @@
# terminal-hc — high-contrast sibling of `terminal`
#
# Near-black + amber tmux feel; muted text, status chips, syntax tokens,
# and the focus border bumped to clear the strict contrast gate
# (D-22 + D-69, `extendedPairs` in lib/kernel/src/theme/contrast.dart).
name: terminal-hc
display_name: Terminal (high contrast)
dark: true
palette:
background: "#0A0A0A"
panel: "#000000"
surface: "#111111"
muted: "#B5B5B5"
foreground: "#F0F0F0"
secondary: "#D6D6D6"
primary: "#FFC868"
accent: "#FFC868"
success: "#B5E2B0"
warning: "#FFD988"
error: "#FFA5A5"
info: "#B0C8FF"
surfaceHi: "#181818"
border: "#242424"
borderHi: "#888888"
textDim: "#B5B5B5"
textMute: "#9A9A9A"
accentSoft: "#21FFC868"
syntax:
keyword: "#FF8585"
type: "#FFD27A"
string: "#B5E2B0"
number: "#DAA7FF"
comment: "#9A9A9A"
method: "#BCD8FF"
punct: "#B5B5B5"
+4
View File
@@ -207,6 +207,10 @@ Future<List<ThemeDefinition>> _loadBundledThemes() async {
'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',
];
final out = <ThemeDefinition>[];
for (final p in paths) {
+4
View File
@@ -246,6 +246,10 @@ class _ClideTestAppState extends State<ClideTestApp> {
'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) {
final name = p.split('/').last.replaceAll('.yaml', '');
+18
View File
@@ -6,6 +6,10 @@ import 'package:flutter_test/flutter_test.dart';
/// [canonicalPairs]) must clear 4.5:1 for normal text / 3:1 for large
/// text. Failing pairs are printed with their computed ratio so a
/// theme-token regression shows exactly which pair broke.
///
/// Themes whose name ends in `-hc` (high-contrast) or `-cb`
/// (colour-blind) additionally have to clear the stricter
/// [extendedPairs] set — D-69.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -15,6 +19,10 @@ void main() {
'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 path in bundledPaths) {
@@ -34,6 +42,16 @@ void main() {
'${failures.map((f) => ' - $f').join('\n')}',
);
}
final isStrict = def.name.endsWith('-hc') || def.name.endsWith('-cb');
if (isStrict) {
final extended = failingExtendedPairs(tokens);
if (extended.isNotEmpty) {
fail(
'Extended contrast failures in ${def.name} (strict gate):\n'
'${extended.map((f) => ' - $f').join('\n')}',
);
}
}
});
}
});