M4-05: Icons in the design system¶
Status: ready | Depends on: M4-04 | Milestone: M4
Goal¶
After this ticket every semantic icon of design/tokens/icon.tokens.json renders in Flutter from packages/design_system: the Phosphor glyphs of the pinned @phosphor-icons/core version, integrated without the dormant phosphor_flutter package, either as an icon font with only the used glyphs or as compiled vector assets (vector_graphics_compiler), chosen after a measured comparison of size and sharpness (docs/design/icons.md "Integration without an outdated package"). A widget DsIcon draws a DsGlyph from DsIcons at the token sizes, applies the spin rule (trigger.working, run.running, state.reconnecting only in the full motion context) and always carries a semantics label or is excluded from semantics when decorative. A golden shows all semantic icons in both weights they are used in, the asset size is measured and documented, and the gate fails when a mapped glyph is missing from the assets or an asset is not current. Why: components (M4-07), the terminal view and the visualizations must ask for trigger.working, never for circle-notch (icons.md "Rules"), and the ban on emoji and outdated packages must hold in code.
Read first¶
docs/PLAN.md: section 3; section 10 rows M4-05 and M4-07.docs/design/icons.md(all: choice, integration, rules, mapping, space icons),docs/design/README.md("Ban list": emoji as icons; "Process for a change": new icon),docs/design/components.md(common rules: never color alone, semantics labels),docs/design/spacing-radii-elevation-motion.md(motion rules).design/tokens/icon.tokens.json(sizes,ds.iconsmapping,space-icons),design/build/tokens.resolved.json(icon mapping),design/build/dart/design_tokens.g.dart(DsGlyph,DsIconWeight,DsIcons,DsIconSize).tools/design-tokens/README.md("vendor/",tokens.py vendor),tools/design-tokens/vendor/MANIFEST.json,tools/design-tokens/vendor/phosphor/(the used SVGs and the MIT license).docs/spec/15-client-architecture.md20 (Golden),docs/spec/01-product.md8 (rows "Color", "Motion", "Screen reader").docs/quality/licenses.md,NOTICE.- Briefs:
docs/briefs/M4-04-design-system-theme.md(dsGoldenMatrix,DsThemeScope, fonts manifest pattern). docs/DECISIONS.md: D-W-100, D-W-114; section 10.
This ticket touches no API operation, no table, no action, no event and no configuration key.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Interface texts come frompackages/l10n, never as literals in widgets. - Sizes only from
icon.size.*(DsIconSize); spin duration frommotion.duration.*; no new configuration key. - Specification first:
docs/design/icons.mdrecords the chosen integration and the measured sizes in the same change.
Product requirements implemented here:
- Icons only through semantic names (
DsIcons); the glyph and its weight come from the token mapping; swapping a glyph is a change inicon.tokens.jsononly (icons.md "Rules"). - Weights
regular,bold,fillas mapped;fillfor active and reached final states (icons.md). - Spinning only for
trigger.working,run.runningandstate.reconnecting, only in thefullmotion context (icons.md,DsGlyph.spins). - Never an icon alone:
DsIconrequires either a label or an explicitdecorative: true(components.md, 01 8). - No emoji as an icon, no
phosphor_flutterdependency, Phosphor under MIT with its license text bundled (icons.md).
Scope¶
Create or change in packages/design_system/:
- The icon assets: either
assets/icons/DsIcons.ttfpluslib/src/generated/ds_icon_codepoints.g.dart(glyph name and weight to code point), orassets/icons/<weight>/<glyph>.vecpluslib/src/generated/ds_icon_assets.g.dart(Design note 1);assets/icons/LICENSE(Phosphor MIT text);assets/icons/MANIFEST.json(package version, source SHA-256 per SVG, asset SHA-256). lib/src/icons/ds_icon.dart(DsIcon), exported fromlib/design_system.dart.- Test files:
test/icons_complete_test.dart,test/ds_icon_test.dart,test/goldens/icons_test.dartand the golden files.
Change in tools/design-tokens/:
tokens.pygains a subcommandiconsthat builds the assets fromvendor/phosphor/into the package and is part ofbuild;checkcompares them (Design note 2);tokens.py vendorcopies thefillvariant too where a glyph is used in it (Design note 3);tests/test_icons.py(new).README.mdof the tool.
Change elsewhere:
packages/design_system/pubspec.yaml(assets or font family;vector_graphicsas a dependency if vectors are chosen),README.md;docs/design/icons.md("Integration" with the choice, the comparison table and the asset size);NOTICE(Phosphor attribution).docs/DECISIONS.mdsection 10: rowsD-M4-05-<n>; rootCHANGELOG.mdunder[Unreleased].
Out of scope¶
- Using icons in components (M4-07) and in the apps.
- The server's check of
spaces.iconagainstspace-icons(a server ticket of M1). - New glyphs beyond the mapping; the
duotoneandthinweights.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M4-05-design-icons; one writer per worktree. - Conventional Commits with the package or component as scope (for example
feat(client): ...,feat(sync-engine): ...,build(gates): ...,docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never publish a Dart package (publish_to: nonein everypubspec.yaml). - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1with port 0 and make no network call; fakes replace the server unless a section below names a container. Temporary directories only viamktemp -d(DartDirectory.systemTemp.createTemp, pytesttmp_path), removed at the end. Stop only processes you started, by PID. - Never read or print secrets. No network calls except package downloads for pinned tools and pub packages and what the gates of
make checkdo themselves. - The Flutter SDK at the version pinned in the root
pubspec.yamlmust be onPATH(and Go and Docker where a section names them); without them the gates fail by design (R-QA-02), and the ticket stops withVerdict: BLOCKED <tool> missinginstead of skipping. - Never lower a threshold, disable a gate, delete a test, raise
repo.max_file_kbor extendquality/licenses/allowed.txt; never exclude hand-written code from a lint, format, coverage or doc gate. - The product name appears only at the places of 02 "Code layout" ("Places that carry the product name"); the app shows
branding.product_namefrom the instance, never a literal. - No permission logic in the client (15 1 principle 1): controls follow server responses, snapshots and events, never an own derivation from grants.
- If the core contract (chapters 00, 03, 04, 05) or
docs/api/lacks something this ticket needs, stop withVerdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M4 by D-W-114). Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - Network only to fetch the pinned
@phosphor-icons/corepackage once (npm packof the version invendor/MANIFEST.json) whenvendor/phosphor/lacks a needed SVG. - The asset build must run without a network and with tools already pinned: the Python standard library for the font (a TrueType writer built on
struct, nofontToolsunless it is already in the lockedtools/environment), orvector_graphics_compilerrun throughdart runfrom the workspace lock for vectors.
Design notes¶
-
Font or vectors: measured choice. Build both candidates once in a temporary folder from the same SVGs: (a) a TrueType icon font with only the used glyphs and weights (one font file with code points in the Private Use Area from
U+E000, assigned in sorted order of<weight>/<glyph>so the mapping is stable), (b)vector_graphics_compileroutput per glyph and weight. Measure: total bytes, and sharpness as the pixel difference between a 16, 20 and 24 logical pixel render at device pixel ratios 1 and 2 and the SVG rendered byflutter_svgin a test (only for the measurement;flutter_svgis not a dependency of the result). Choose the candidate with clearly better sharpness at 16 px; on a tie the smaller one. Record the table (bytes, mean and maximum pixel difference per size) in icons.md and the choice as a ruling. Only the chosen candidate is committed. -
Generated, checked, current.
tokens.py iconsreads the mapping from the resolved model, builds the chosen assets and the Dart lookup file (DsIconData lookup(DsGlyph glyph)returning either anIconDatawith the font family and code point or the asset path), and writesMANIFEST.json.tokens.py checkrebuilds into memory and compares every asset and the lookup file byte for byte; the build must be deterministic (fixed timestamps in the font tables, sorted order). A mapped glyph without an SVG invendor/phosphor/failsbuildandchecknaming the semantic name, glyph and weight. Ruling for docs/DECISIONS.md: the icon assets are generated by the token tool, because icons.md namesicon.tokens.jsonas their single source. -
Weights. The golden must show every semantic icon in its mapped weight and in
fill(the active form, icons.md "Weights"), so the assets contain both for every mapped glyph;boldglyphs (small control glyphs) are shown inboldandfill.vendorcopies the SVGs ofassets/<weight>/<glyph>-<weight>.svgof the pinned package for every needed pair and updatesMANIFEST.jsonwith their SHA-256 (the tool's provenance gate keeps holding them). -
DsIcon.DsIcon(DsGlyph glyph, {DsIconSizeRole size = DsIconSizeRole.md, Color? color, String? label, bool decorative = false, bool filled = false}): asserts in debug and throwsArgumentErrorin release when neitherlabelnordecorativeis given; color defaults tocontext.dsColors.textPrimary(generated name forcolor.text.primary);filledselects thefillasset; a glyph withspinsrotates continuously withmotion.duration.pulseonly whencontext.dsMotionis thefullcontext, otherwise it stands still (and anAnimationControlleris never created inreduced). Semantics:labelas the semantics label withimage: true, orExcludeSemanticswhen decorative. Ruling: the spin period usesmotion.duration.pulsebecause no dedicated token exists; adding one would change the token files of M0-01's gate. -
Rulings for docs/DECISIONS.md (
D-M4-05-<n>): font or vectors with the measurements (note 1); assets generated by the token tool (note 2); both weights in the assets (note 3); the spin period (note 4).
Tests (write them first)¶
packages/design_system/test/:
icons_complete_test.dart: every member ofDsIcons(enumerated through the generated list of the lookup file) resolves to an asset or code point in its mapped weight and infill; every entry ofspace-iconsresolves; the lookup file names no glyph that the mapping lacks.ds_icon_test.dart: label required (missing label and not decorative throws); a decorative icon is excluded from semantics; a labeled one has the label;trigger.workingspins infull(the rotation changes between two pumps) and stands still inreduced; a glyph withoutspinsnever rotates; sizessm,md,lgequalicon.size.*.goldens/icons_test.dart: one grid of all semantic icons with their semantic names as captions (captions are test strings), each in its mapped weight and infill, in light and dark, aticon.size.md, as CI golden and platform golden on macOS.
tools/design-tokens/tests/test_icons.py: test_build_is_deterministic; test_missing_svg_fails_naming_glyph; test_check_fails_on_changed_asset; test_codepoints_stable_order (font) or test_asset_paths_stable (vectors); test_manifest_hashes_match.
Commands that must pass, from the repository root:
uv run tools/design-tokens/tokens.py build(no diff on a second run),make tokenscd packages/design_system && flutter analyze --fatal-infos && flutter test --coveragemake lint,make fmt-check,make coverage,make licensesmake check
Acceptance criteria¶
- Golden of all semantic icons in both weights, light and dark.
- Asset size measured and documented in icons.md with the font-versus-vector comparison and the choice.
-
make tokensfails on a missing glyph or a stale asset; the build is deterministic. -
DsIconrequires a label ordecorative, spins only the three glyphs and only infullmotion. - No
phosphor_flutter, no emoji; Phosphor MIT text bundled and named inNOTICE;make licensesgreen. - Rulings recorded.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comments and docstrings on every public symbol; comments explain why.
packages/design_system/README.md(icons section),tools/design-tokens/README.md,docs/design/icons.md,NOTICEupdated; rulings of Design note 5 indocs/DECISIONS.mdsection 10.- Root
CHANGELOG.mdentry under 'Unreleased'. - An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- The comparison table (bytes and pixel differences) and the choice; the
@phosphor-icons/coreversion; the total asset size. - What is not proven (for example a platform test that ran only in CI, or a check done by hand).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100, D-W-114), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.