Skip to content

feat(gate): measure the design-first claim, and stop pointing Copilot at Claude paths - #5

Merged
anilcancakir merged 2 commits into
mainfrom
feat/design-token-gate
Aug 5, 2026
Merged

feat(gate): measure the design-first claim, and stop pointing Copilot at Claude paths#5
anilcancakir merged 2 commits into
mainfrom
feat/design-token-gate

Conversation

@anilcancakir

Copy link
Copy Markdown
Contributor

What changed

bin/design-tokens (new) scans lib/**/*.dart for raw Color(0x and Colors.* and exits non-zero with the offending file:line. It runs as a bin/check job (static, so in --fast too) and as a new, non-blocking Design tokens CI job. Separately, bin/sync-instructions now rewrites Claude-only references while staging a rule body.

Why

Mirrored from the uptizm fork, per the mirroring rule in AGENTS.md: a structural change there travels here in the same piece of work. This repository is the boilerplate, so tooling that catches a class of mistake belongs here before it belongs in any fork.

Nothing measured the design-first claim. design:lint validates DESIGN.md's own YAML and never reads the Dart that is supposed to obey it; flutter analyze has no opinion about a hardcoded colour. A component could ignore every semantic token and the gate would stay green.

Upstream: anilcancakir/uptizm#5 (the gate) and anilcancakir/uptizm#7 (the generator rewrite).

Evidence

$ ./bin/check                        $ ./bin/check --fast
  ok  flutter-analyze                  ok  flutter-analyze
  ok  design-tokens                    ok  design-tokens
  ok  backend-pint                     ok  backend-pint
  ok  flutter-test                   check: all green | static only, no tests ran
  ok  backend-test
check: all green | flutter backend

The gate was proven able to fail, then reverted:

$ printf '\nconst Color _qaProbe = Color(0xFF123456);\n' >> lib/ui/components/callout/callout.dart
$ ./bin/design-tokens
design-tokens: raw color literal(s) found outside the allowlist:
  lib/ui/components/callout/callout.dart:64
exit=1

The generator rewrite, before and after, with the source rule untouched:

line before after
design.instructions.md:9 `CLAUDE.md` `.github/copilot-instructions.md`
design.instructions.md:95 (`.claude/agents/component-visual-reviewer.md`) (a Claude Code agent definition; Copilot has no equivalent, so apply those criteria by hand)
design.instructions.md:5 GENERATED from .claude/rules/design.md unchanged, deliberately

git diff .claude/rules/design.md is empty, and bin/sync-instructions --check is clean and stable across repeated runs, so the substitutions are deterministic and CI cannot flap.

  • bin/check green
  • Exercised for real (the checker run against the tree and against a planted violation; the generator run and re-run)

Two details that were paid for downstream, not guessed at here

Comments are stripped before the scan. A component's own docstring routinely says "no raw hex, Color(0xFF...), or Colors.* anywhere", so a naive grep flags the sentence claiming compliance. Three components in the fork did exactly that, and the job was red on arrival until the stripper existed.

Colors. needs a left word boundary. Without one it matches the tail of any identifier ending in Colors, so an ordinary kBrandColors.first reads as a violation. That single false positive put a file on the fork's allowlist that had no real violation, which is a hole rather than an exemption: it would have silently permitted the first genuine one. Color(0x deliberately has no boundary, so MaterialColor(0xFF008560 in a generated theme is still caught.

The allowlist has one entry, and that is the point

lib/config/wind_theme.g.dart, which design:sync generates. That is the measured surface of this tree. A fork therefore starts from a clean list and adds an entry only when it has a real reason to, with the reason written next to the path.

It prefers an exact file to a directory: a directory entry also exempts every file added to it later, under a reason that may not apply to them. lib/config/ was tried and rejected on exactly that ground downstream, where the directory holds fourteen files and only one is generated.

What it does not measure

Stated in the script header rather than left to be discovered. It is regex and not an AST, so Color.fromARGB, Color.fromRGBO and Color.from walk past it (zero occurrences today; the header says to add a rule the day one appears). A // inside a string literal truncates its line before the scan sees it. And a hardcoded pixel value, a colour token written without its dark: pair, and a one-off widget where a registry component already exists are all outside what a regex can see. This is a floor, not the whole of design-first.

Not required yet, on purpose

Design tokens is a new job with a new name, so it reports without blocking until someone adds it to branch protection. That is the right order for a gate that has never been green on main; bin/check is where it actually gates in the meantime.

… at Claude paths

Mirrored from the uptizm fork, where both were measured. This repository is the
boilerplate, so tooling that catches a class of mistake belongs here before it
belongs in any fork of it.

`bin/design-tokens` scans lib/**/*.dart for raw `Color(0x` and `Colors.*` and
exits non-zero with the offending file:line. Nothing measured this: `design:lint`
validates DESIGN.md's own YAML and never reads the Dart that is supposed to obey
it, and `flutter analyze` has no opinion about a hardcoded colour. A component
could ignore every semantic token and the gate would stay green.

Two details in it were paid for downstream rather than guessed at here, and the
script's header says so. Comments are stripped first, because a component's own
docstring routinely says "no raw hex, `Color(0xFF...)`, or `Colors.*` anywhere"
and a naive grep flags the sentence claiming compliance; three components in the
fork did exactly that. And `Colors.` needs a left word boundary or it matches the
tail of any identifier ending in "Colors", so an ordinary `kBrandColors.first`
reads as a violation; that single false positive put a file on the fork's
allowlist that had no real violation, which is a hole rather than an exemption.

The allowlist has one entry, `lib/config/wind_theme.g.dart`, which is generated.
That is the whole surface here, measured. It prefers an exact file to a directory
on purpose: a directory entry also exempts every file added to it later, under a
reason that may not apply to them.

`bin/sync-instructions` now rewrites Claude-only references as it stages a rule
body. `design.md` sent Copilot to `CLAUDE.md` and to a `.claude/agents/`
definition, neither of which it loads. The source rule keeps those paths, because
they are correct for the one tool that reads them, and the generator's own
provenance banner keeps the real source path too: its whole job is to say which
file to edit instead of the generated one.

Evidence: `bin/check` 5/5 green, `--fast` 3/3, `bin/sync-instructions --check`
clean and stable across runs, the source rule unchanged by the rewrite, and the
new job proven able to fail by planting `Color(0xFF123456)` in
lib/ui/components/callout/callout.dart and watching it report
`callout.dart:64`, then reverting.

`Design tokens` is a new CI job with a new name, so it reports without blocking
until someone adds it to branch protection. That is the right order for a gate
that has never been green on main.
Copilot AI lite review requested due to automatic review settings August 5, 2026 04:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces an automated “design tokens” gate to enforce the repo’s design-first constraint (no raw Color(0x...) / Colors.* in lib/**), and improves generated Copilot instruction mirrors by rewriting Claude-only path references in staged rule bodies.

Changes:

  • Added bin/design-tokens to scan lib/**/*.dart for raw color literals (with comment stripping + allowlist support).
  • Wired the new gate into bin/check (including --fast) and added a standalone “Design tokens” CI job.
  • Updated bin/sync-instructions to rewrite Claude-only references inside mirrored rule bodies (without mutating the source .claude rules).

Reviewed changes

Copilot reviewed 3 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
bin/sync-instructions Rewrites Claude-only references while staging rule bodies so Copilot mirrors link to paths Copilot can open.
bin/design-tokens New static scanner enforcing “token-only” color usage in lib/**, with comment stripping and an allowlist.
bin/check Runs bin/design-tokens as part of the flutter scope (and in --fast).
.github/workflows/ci.yml Adds a standalone “Design tokens” job to report raw color violations separately from Flutter analyze/test.
.github/instructions/design.instructions.md Updates generated instruction text to reference Copilot-appropriate paths.
.design-token-allowlist Introduces an allowlist (currently only lib/config/wind_theme.g.dart) with required per-entry rationale.

Mirrored from the fork, where a code review caught it. The comment stripper looked
for `/* */` before `//` and knew nothing about string literals, so a `/*` with no
closing `*/` on the same line opened block mode that never closed, every line
after it was emitted empty, and the gate printed its green line having scanned
nothing. That is the worst shape a gate can fail in, because it is
indistinguishable from success.

This tree has the trigger: `lib/config/deeplink.dart:15` is `'paths': ['/*'],`, a
`/*` inside a string, so everything after line 15 of that file was unchecked. The
fork had four such files, two of them the very view files the job exists to
police, and one was tripped by a doc comment naming a route glob.

The stripper now scans each line left to right and acts on whichever of `//` and
`/*` comes first outside a string, tracking `'` and `"` with backslash escapes.
Dart's triple-quoted strings are still not tracked, and that gap is closed by an
invariant rather than more parsing: `flutter analyze` guarantees every file here
compiles, so an unterminated `/*` at end of file proves the stripper mis-parsed.
It says so and exits 3, and the caller rescans the raw source. Over-reporting a
comment costs a minute; under-reporting costs the gate its purpose.

Verified: the gate still reports `callout.dart:64` for a planted `Colors.red` and
is otherwise green, and `bin/check --fast` is 3/3.
@anilcancakir
anilcancakir merged commit 3e49616 into main Aug 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants