Phosphor icons: resolve by name via a generated map (T-314)

Replace the 49 hand-maintained named consts with one generated
label→codepoint map (phosphor_glyphs.g.dart, 1512 glyphs from the glyph
table via tool/gen_phosphor_glyphs.dart). Feature code now references
glyphs by their exact kebab-case name — PhosphorIcons.byName('folder') —
with no raw codepoints; this also lets a Lua extension name an icon
without crossing the FFI boundary with a codepoint.

byName is total: an unknown name degrades to the `placeholder` box so the
bug is visible (it's a real error), while phosphor_glyphs_test asserts
every byName('...') literal in lib/ resolves — recovering the typo check a
const gave. Migrated the 89 call sites. Adds EmptyIconPainter for an
intentional blank that still reserves the icon box; ClideFilterBox gains
showIcon to keep the slot aligned when blank.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 12:46:51 +02:00
co-authored by Claude Opus 4.8
parent b8d75230e8
commit 3833a41a9e
38 changed files with 1779 additions and 163 deletions
+18 -20
View File
@@ -6,32 +6,30 @@ The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
`assets/fonts/phosphor/` (regular, bold, fill weights).
**Glyph reference:** [`phosphor-glyphs.md`](phosphor-glyphs.md) — the full
1512-glyph table (codepoint · kebab name · Pascal name) with an **In clide**
column flagging the ~45 already wired into `PhosphorIcons`. Read/grep this to
pick a glyph; reach for an already-defined one first. (It mirrors
`assets/fonts/phosphor/codepoints.csv`; regenerate with
`python3 .claude/skills/ui-design/scripts/gen-phosphor-glyphs.py` after a font
bump.)
### Adding an icon
Find the codepoint in [`phosphor-glyphs.md`](phosphor-glyphs.md), then add a
`static const` entry to `PhosphorIcons` in
`lib/widgets/src/icons/phosphor.dart`:
```dart
static const arrowClockwise = PhosphorIconPainter(0xe036);
```
Only add icons we actually use — don't bulk-import the full set.
1512-glyph table (codepoint · kebab name · Pascal name). Grep it for the exact
kebab name; **all 1512 are available** — no per-icon wiring (T-314).
### Using an icon
Reference a glyph by its **exact kebab-case name** — no codepoints in feature
code; the label→codepoint table lives only in the generated
`lib/widgets/src/icons/phosphor_glyphs.g.dart`:
```dart
ClideIcon(PhosphorIcons.arrowClockwise, size: 13)
ClideIcon(PhosphorIcons.byName('arrow-clockwise'), size: 13)
```
Or as a `TabContribution` icon field: `icon: PhosphorIcons.lightbulb`.
Or as a `TabContribution` icon field: `icon: PhosphorIcons.byName('lightbulb')`.
- An **unknown name** is a bug — it degrades to the `placeholder` error box so
the mistake is visible. `phosphor_glyphs_test` asserts every `byName('…')`
literal in `lib/` resolves, recovering the typo check a const would give.
- For an **intentional blank** that still reserves the icon's box (alignment),
use `const EmptyIconPainter()`*not* a missing name.
- Regenerate the map after a font bump: `dart run tool/gen_phosphor_glyphs.dart`.
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
**Fill weight:** `family: 'Phosphor-Fill'`.
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
**Fill weight:** `family: 'Phosphor-Fill'`.
@@ -2810,3 +2810,4 @@ The LABEL is the bridge between the two surfaces: Claude attaches a label to eac
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', NULL, '2026-06-10 10:44:06', '2026-06-10 10:44:06', '2026-06-10 10:44:06', NULL, 'a55e979e042abaf7b0758b5dbba183f7', 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 ('06FB23Z52EVGQ4ZDAQ5BVAJ13R', 'status', 'ready', 'done', NULL, '2026-06-10 10:46:25', '2026-06-10 10:46:25', '2026-06-10 10:46:25', NULL, '4f76b8c659b36db90ac957a42c610d6f', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -1715,3 +1715,4 @@ The LABEL is the bridge between the two surfaces: Claude attaches a label to eac
- Each icon renders at multiple sizes: a hero at the existing clideFontWelcomeBanner (52) token plus one sample at each inline font-size token (badge 11 -> body 15), sized off the typography tokens and labelled so the reviewer sees legibility at real UI sizes.
- The card is display-only (no inline selection); selection happens in the interaction zone (convo box) via a Claude-offered choice list whose options reuse the per-icon labels from the card.
- Unknown/invalid icon names fail with a clear user error, not a blank glyph.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-10 10:44:06', NULL, '9a25fe730b81f5192fb4142d66fbff85', 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 ('06FB23Z52EVGQ4ZDAQ5BVAJ13R', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Phosphor icons: single label→codepoint map; reference glyphs by name, not codepoint', 'Replace the 49 hand-maintained named consts in lib/widgets/src/icons/phosphor.dart with ONE generated const Map<String,int> of all 1512 Phosphor glyphs (exact kebab-case upstream names → codepoint). Code references glyphs via PhosphorIcons.byName(''folder'') — no raw 0xNNNN in feature code; codepoints live only in the generated map. Migrate the ~32 call sites (by codepoint, not guessed names). Add a test that scans lib/ for byName(''...'') literals and asserts each exists in the map, recovering the compile-time typo safety the named consts gave. Map is regeneratable from .claude/skills/ui-design/references/phosphor-glyphs.md. Foundation for the glyph preview card (T-313).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:24:29', '2026-06-10 10:46:25', NULL, 'aa95db9de3811c6058836e661dd304e8', 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);
+18 -18
View File
@@ -395,7 +395,7 @@ class _RightHatContent extends StatelessWidget {
return Row(children: [
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
_WinBtn(icon: PhosphorIcons.xMark, onTap: wc.close, tokens: tokens, isClose: true),
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
]);
}
}
@@ -452,7 +452,7 @@ class _ProjectSwitcherButton extends StatelessWidget {
children: [
ClideText(label, fontSize: 12, color: hovered ? tokens.globalForeground : tokens.chromeForeground, fontFamily: clideMonoFamily),
const SizedBox(width: 4),
ClideIcon(PhosphorIcons.caretDown, size: 8, color: tokens.chromeForeground),
ClideIcon(PhosphorIcons.byName('caret-down'), size: 8, color: tokens.chromeForeground),
],
),
);
@@ -591,7 +591,7 @@ class _RecentProjectRow extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
ClideIcon(PhosphorIcons.folder, size: 14, color: tokens.globalTextMuted),
ClideIcon(PhosphorIcons.byName('folder'), size: 14, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -608,7 +608,7 @@ class _RecentProjectRow extends StatelessWidget {
muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
),
ClideText(' · ', muted: true, fontSize: 12),
ClideIcon(PhosphorIcons.gitBranch, size: 10, color: tokens.globalTextMuted),
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
const SizedBox(width: 3),
ClideText(project.branch!, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
],
@@ -920,7 +920,7 @@ class _RevealedTab extends StatelessWidget {
tooltip: 'Close',
builder: (_, hovered, __) => Padding(
padding: const EdgeInsets.all(6),
child: ClideIcon(PhosphorIcons.xMark, size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
),
@@ -1088,20 +1088,20 @@ class _BottomRail extends StatelessWidget {
if (t.icon is ClideIconPainter) return t.icon as ClideIconPainter;
if (slot == Slots.sidebar) {
return switch (t.id) {
'files.tree' => PhosphorIcons.folder,
'git.panel' => PhosphorIcons.gitBranch,
'pql.panel' => PhosphorIcons.magnifyingGlass,
'problems.panel' => PhosphorIcons.warningCircle,
'decisions.panel' => PhosphorIcons.lightbulb,
'tickets.panel' => PhosphorIcons.ticket,
_ => PhosphorIcons.circlesFour,
'files.tree' => PhosphorIcons.byName('folder'),
'git.panel' => PhosphorIcons.byName('git-branch'),
'pql.panel' => PhosphorIcons.byName('magnifying-glass'),
'problems.panel' => PhosphorIcons.byName('warning-circle'),
'decisions.panel' => PhosphorIcons.byName('lightbulb'),
'tickets.panel' => PhosphorIcons.byName('ticket'),
_ => PhosphorIcons.byName('circles-four'),
};
}
return switch (t.id) {
'markdown.viewer' => PhosphorIcons.eye,
'graph.view' => PhosphorIcons.graph,
'pql.backlinks' => PhosphorIcons.link,
_ => PhosphorIcons.circlesFour,
'markdown.viewer' => PhosphorIcons.byName('eye'),
'graph.view' => PhosphorIcons.byName('graph'),
'pql.backlinks' => PhosphorIcons.byName('link'),
_ => PhosphorIcons.byName('circles-four'),
};
}
}
@@ -1136,8 +1136,8 @@ class StatusbarCollapseToggle extends StatelessWidget {
// The chevron points the DIRECTION OF THE ACTION: collapsing tucks the pane
// toward its own edge, expanding brings it back toward the centre.
final icon = _isSidebar
? (collapsed ? PhosphorIcons.caretLineRight : PhosphorIcons.caretLineLeft)
: (collapsed ? PhosphorIcons.caretLineLeft : PhosphorIcons.caretLineRight);
? (collapsed ? PhosphorIcons.byName('caret-line-right') : PhosphorIcons.byName('caret-line-left'))
: (collapsed ? PhosphorIcons.byName('caret-line-left') : PhosphorIcons.byName('caret-line-right'));
final what = _isSidebar ? 'sidebar' : 'context panel';
return SizedBox(
width: 24,
+2 -2
View File
@@ -564,7 +564,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
onTap: () => _removeAttachment(a),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideIcon(PhosphorIcons.xMark, size: 12, color: theme.globalTextMuted),
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: theme.globalTextMuted),
),
),
),
@@ -580,6 +580,6 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
if (a.isImage) {
return ImageThumbnail(path: a.path, size: 44);
}
return ClideIcon(PhosphorIcons.fileText, size: 18, color: theme.globalTextMuted);
return ClideIcon(PhosphorIcons.byName('file-text'), size: 18, color: theme.globalTextMuted);
}
}
@@ -902,14 +902,14 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
children: [
// Show / hide
_IconButton(
painter: isVisible ? PhosphorIcons.eye : PhosphorIcons.eyeSlash,
painter: isVisible ? PhosphorIcons.byName('eye') : PhosphorIcons.byName('eye-slash'),
tooltip: isVisible ? 'Hide pane' : 'Show pane',
color: tokens.globalTextMuted,
onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id),
),
// Mute / unmute
_IconButton(
painter: isMuted ? PhosphorIcons.eyeSlash : PhosphorIcons.eye,
painter: isMuted ? PhosphorIcons.byName('eye-slash') : PhosphorIcons.byName('eye'),
// NOTE: We use eye/eyeSlash as stand-ins until a dedicated speaker
// icon is added to PhosphorIcons (no speaker codepoint yet).
// The semantic tooltip still says mute/unmute so AT users are clear.
@@ -919,21 +919,21 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
),
// Inject message
_IconButton(
painter: PhosphorIcons.chatCircle,
painter: PhosphorIcons.byName('chat-circle'),
tooltip: 'Inject message',
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
onTap: () => widget.onToggleInject(widget.member.name),
),
// Fork session (T-172): branch into a new pane without touching the original.
_IconButton(
painter: PhosphorIcons.gitBranch,
painter: PhosphorIcons.byName('git-branch'),
tooltip: 'Fork session',
color: tokens.globalTextMuted,
onTap: () => widget.onFork(widget.member.name),
),
// Close session
_IconButton(
painter: PhosphorIcons.xMark,
painter: PhosphorIcons.byName('x'),
tooltip: 'Close session',
color: tokens.globalTextMuted,
onTap: () => widget.onClose(widget.member.name),
@@ -958,7 +958,7 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
),
const SizedBox(width: 4),
_IconButton(
painter: PhosphorIcons.xMark,
painter: PhosphorIcons.byName('x'),
tooltip: 'Cancel',
color: tokens.globalTextMuted,
onTap: () => widget.onToggleInject(widget.member.name),
@@ -1186,7 +1186,7 @@ class _TaskRow extends StatelessWidget {
// Reassign: cycle to the next roster member.
if (broker != null && broker!.members.length > 1)
_IconButton(
painter: PhosphorIcons.arrowClockwise,
painter: PhosphorIcons.byName('arrow-clockwise'),
tooltip: 'Reassign task',
color: tokens.globalTextMuted,
onTap: () => _reassign(context),
@@ -288,7 +288,7 @@ class _ConversationCardState extends State<ConversationCard> {
builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(right: 6),
child: ClideIcon(
_collapsed ? PhosphorIcons.caretRight : PhosphorIcons.caretDown,
_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'),
size: 12,
color: tokens.globalTextMuted,
),
@@ -535,7 +535,7 @@ class _ConversationTurn extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(PhosphorIcons.image, size: 16, color: tokens.globalTextMuted),
ClideIcon(PhosphorIcons.byName('image'), size: 16, color: tokens.globalTextMuted),
const SizedBox(width: 8),
Flexible(
child: ClideText('could not load $path', fontSize: clideFontMeta, color: tokens.globalTextMuted, maxLines: 1),
+1 -1
View File
@@ -296,7 +296,7 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.meta',
slot: Slots.sidebar,
title: 'Activity',
icon: PhosphorIcons.robot,
icon: PhosphorIcons.byName('robot'),
priority: 60,
build: (_) => const ClaudeMetaSidebar(),
),
+1 -1
View File
@@ -36,7 +36,7 @@ Widget _placeholder(BuildContext context, double size) {
height: size,
color: t.panelBackground,
alignment: Alignment.center,
child: ClideIcon(PhosphorIcons.image, size: size * 0.45, color: t.globalTextMuted),
child: ClideIcon(PhosphorIcons.byName('image'), size: size * 0.45, color: t.globalTextMuted),
);
}
@@ -19,13 +19,13 @@ import 'package:flutter/widgets.dart';
ClideIconPainter permissionModeIcon(String mode) {
switch (mode) {
case 'acceptEdits':
return PhosphorIcons.pencilSimple;
return PhosphorIcons.byName('pencil-simple');
case 'plan':
return PhosphorIcons.listChecks;
return PhosphorIcons.byName('list-checks');
case 'bypassPermissions':
return PhosphorIcons.shieldWarning;
return PhosphorIcons.byName('shield-warning');
default:
return PhosphorIcons.shieldCheck;
return PhosphorIcons.byName('shield-check');
}
}
@@ -150,7 +150,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
child: ClideIcon(
PhosphorIcons.arrowsOutSimple,
PhosphorIcons.byName('arrows-out-simple'),
size: 10,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
),
@@ -383,7 +383,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
),
child: _interrupt
? Center(
child: ClideIcon(PhosphorIcons.check, size: 9, color: tokens.globalFocus),
child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus),
)
: null,
),
@@ -157,7 +157,7 @@ class _DecisionsViewState extends State<DecisionsView> {
onTap: _refreshing ? null : _refresh,
tooltip: 'Refresh decisions',
builder: (ctx, hovered, _) =>
ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
ClideIcon(PhosphorIcons.byName('arrow-clockwise'), size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
],
+2 -2
View File
@@ -43,14 +43,14 @@ class DecisionsExtension extends ClideExtension {
title: 'Decisions',
titleKey: 'tab.title',
i18nNamespace: id,
icon: PhosphorIcons.lightbulb,
icon: PhosphorIcons.byName('lightbulb'),
build: (_) => const DecisionsView(),
),
TabContribution(
id: 'decisions.detail',
slot: Slots.contextPanel,
title: 'Decision',
icon: PhosphorIcons.lightbulb,
icon: PhosphorIcons.byName('lightbulb'),
build: (_) => const DecisionDetailView(),
),
];
+1 -1
View File
@@ -23,7 +23,7 @@ class FilesExtension extends ClideExtension {
id: 'files.tree',
slot: Slots.sidebar,
title: 'Files',
icon: PhosphorIcons.folder,
icon: PhosphorIcons.byName('folder'),
titleKey: 'tab.title',
i18nNamespace: id,
priority: -100,
+1 -1
View File
@@ -20,7 +20,7 @@ class GitExtension extends ClideExtension {
id: 'git.panel',
slot: Slots.sidebar,
title: 'Git',
icon: PhosphorIcons.gitBranch,
icon: PhosphorIcons.byName('git-branch'),
titleKey: 'tab.title',
i18nNamespace: id,
priority: -80,
+1 -1
View File
@@ -190,7 +190,7 @@ class _BranchPickerState extends State<_BranchPicker> {
onTap: () => widget.onDismiss(),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideIcon(PhosphorIcons.xMark, size: 12, color: tokens.globalTextMuted),
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: tokens.globalTextMuted),
),
),
],
+1 -1
View File
@@ -23,7 +23,7 @@ class MarkdownExtension extends ClideExtension {
id: 'markdown.viewer',
slot: Slots.contextPanel,
title: 'Markdown',
icon: PhosphorIcons.fileText,
icon: PhosphorIcons.byName('file-text'),
build: (_) => const MarkdownViewer(),
),
];
+1 -1
View File
@@ -21,7 +21,7 @@ class PqlExtension extends ClideExtension {
id: 'pql.backlinks',
slot: Slots.contextPanel,
title: 'Links',
icon: PhosphorIcons.link,
icon: PhosphorIcons.byName('link'),
priority: -80,
build: (_) => const BacklinksView(),
),
+1 -1
View File
@@ -23,7 +23,7 @@ class SearchExtension extends ClideExtension {
id: 'search.findInFiles',
slot: Slots.sidebar,
title: 'Search',
icon: PhosphorIcons.magnifyingGlass,
icon: PhosphorIcons.byName('magnifying-glass'),
priority: -90,
build: (_) => const SearchPanelView(),
),
+5 -5
View File
@@ -51,7 +51,7 @@ class ReaderActionBar extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
_ActionButton(
painter: PhosphorIcons.caretLeft,
painter: PhosphorIcons.byName('caret-left'),
tooltip: 'Back',
enabled: canGoBack,
onTap: canGoBack ? onBack : null,
@@ -59,7 +59,7 @@ class ReaderActionBar extends StatelessWidget {
),
const SizedBox(width: 2),
_ActionButton(
painter: PhosphorIcons.caretRight,
painter: PhosphorIcons.byName('caret-right'),
tooltip: 'Forward',
enabled: canGoForward,
onTap: canGoForward ? onForward : null,
@@ -68,7 +68,7 @@ class ReaderActionBar extends StatelessWidget {
if (hasPinned) ...[
const SizedBox(width: 2),
_ActionButton(
painter: PhosphorIcons.arrowUUpLeft,
painter: PhosphorIcons.byName('arrow-u-up-left'),
tooltip: 'Jump to pin',
enabled: true,
onTap: onJumpToPin,
@@ -78,7 +78,7 @@ class ReaderActionBar extends StatelessWidget {
if (onEdit != null) ...[
const SizedBox(width: 4),
_ActionButton(
painter: PhosphorIcons.pencilSimple,
painter: PhosphorIcons.byName('pencil-simple'),
tooltip: 'Edit in editor',
enabled: true,
onTap: onEdit,
@@ -103,7 +103,7 @@ class ReaderPinButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _ActionButton(
painter: PhosphorIcons.pushPin,
painter: PhosphorIcons.byName('push-pin'),
tooltip: pinned ? 'Unpin' : 'Pin',
enabled: onTap != null,
active: pinned,
@@ -61,7 +61,7 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(PhosphorIcons.palette, size: 13, color: tokens.statusBarForeground),
ClideIcon(PhosphorIcons.byName('palette'), size: 13, color: tokens.statusBarForeground),
const SizedBox(width: 6),
// The status bar is all-lowercase; the proper-case name stays
// in the Semantics label for screen readers.
+2 -2
View File
@@ -45,14 +45,14 @@ class TicketsExtension extends ClideExtension {
title: 'Tickets',
titleKey: 'tab.title',
i18nNamespace: id,
icon: PhosphorIcons.ticket,
icon: PhosphorIcons.byName('ticket'),
build: (_) => const TicketsView(),
),
TabContribution(
id: 'tickets.detail',
slot: Slots.contextPanel,
title: 'Ticket',
icon: PhosphorIcons.ticket,
icon: PhosphorIcons.byName('ticket'),
build: (_) => const TicketDetailView(),
),
];
+1 -1
View File
@@ -166,7 +166,7 @@ class _TicketsViewState extends State<TicketsView> {
onTap: _refreshing ? null : _refresh,
tooltip: 'Refresh tickets',
builder: (ctx, hovered, _) =>
ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
ClideIcon(PhosphorIcons.byName('arrow-clockwise'), size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
],
+5 -5
View File
@@ -167,21 +167,21 @@ class _StartColumn extends StatelessWidget {
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
const SizedBox(height: 20),
_ActionRow(
icon: PhosphorIcons.folder,
icon: PhosphorIcons.byName('folder'),
label: 'Open folder…',
shortcut: '⌘O',
tokens: tokens,
onTap: () => _openFolder(context),
),
_ActionRow(
icon: PhosphorIcons.gitBranch,
icon: PhosphorIcons.byName('git-branch'),
label: 'Clone from git…',
shortcut: '⌘G',
tokens: tokens,
onTap: () {},
),
_ActionRow(
icon: PhosphorIcons.chatCircle,
icon: PhosphorIcons.byName('chat-circle'),
label: 'Start a Claude session',
shortcut: '⌘C',
tokens: tokens,
@@ -332,7 +332,7 @@ class _RecentRow extends StatelessWidget {
muted: true, fontSize: clideFontMeta, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
if (project.branch != null) ...[
ClideText(' · ', muted: true, fontSize: clideFontMeta),
ClideIcon(PhosphorIcons.gitBranch, size: 11, color: tokens.globalTextMuted),
ClideIcon(PhosphorIcons.byName('git-branch'), size: 11, color: tokens.globalTextMuted),
const SizedBox(width: 3),
Flexible(
child: ClideText(project.branch!,
@@ -384,7 +384,7 @@ class _StickyToggle extends StatelessWidget {
border: Border.all(color: hovered || sticky ? tokens.panelActiveBorder : tokens.globalBorder),
borderRadius: BorderRadius.circular(3),
),
child: sticky ? ClideIcon(PhosphorIcons.check, size: 12, color: tokens.buttonForeground) : null,
child: sticky ? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.buttonForeground) : null,
),
),
);
+1 -1
View File
@@ -36,7 +36,7 @@ class ClideAccordion extends StatelessWidget {
padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4),
child: Row(
children: [
ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted),
ClideIcon(expanded ? PhosphorIcons.byName('caret-down') : PhosphorIcons.byName('caret-right'), size: 10, color: tokens.globalTextMuted),
const SizedBox(width: 6),
if (leading != null) ...[leading!, const SizedBox(width: 6)],
ClideText('$label · $count',
+1 -1
View File
@@ -94,7 +94,7 @@ class _RightContent extends StatelessWidget {
children: [
_WinButton(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
_WinButton(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
_WinButton(icon: PhosphorIcons.xMark, onTap: wc.close, tokens: tokens, isClose: true),
_WinButton(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
],
);
}
+19 -8
View File
@@ -15,7 +15,8 @@ class ClideFilterBox extends StatefulWidget {
this.hint = 'Filter…',
this.debounce = const Duration(milliseconds: 200),
this.onSubmitted,
this.icon = PhosphorIcons.magnifyingGlass,
this.icon,
this.showIcon = true,
this.address,
});
@@ -24,10 +25,16 @@ class ClideFilterBox extends StatefulWidget {
final Duration debounce;
final ValueChanged<String>? onSubmitted;
/// Leading glyph. Defaults to the search glass; pass null for inputs
/// that aren't searches (e.g. a replace or glob field).
/// Leading glyph. Defaults to the search glass (resolved at build, since
/// [PhosphorIcons.byName] isn't const); pass a custom painter to override it,
/// or set [showIcon] = false for inputs that aren't searches (a replace/glob
/// field).
final ClideIconPainter? icon;
/// Whether to draw a leading glyph. False draws a blank one but keeps the
/// slot's space, so non-search fields stay aligned with search ones.
final bool showIcon;
/// Makes this box addressable from the CLI (D-6 parity, T-270). When set,
/// the box listens on the MessageBus `filter.set` channel for its address
/// — so `clide ui filter <address> <text>` drives it exactly as a UI
@@ -123,10 +130,14 @@ class _ClideFilterBoxState extends State<ClideFilterBox> {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Row(
children: [
if (widget.icon != null) ...[
ClideIcon(widget.icon!, size: 13, color: tokens.globalTextMuted),
const SizedBox(width: 6),
],
// The slot is always reserved (size + gap) so search and non-search
// boxes align; showIcon: false just draws a blank glyph (T-314).
ClideIcon(
widget.showIcon ? (widget.icon ?? PhosphorIcons.byName('magnifying-glass')) : const EmptyIconPainter(),
size: 13,
color: tokens.globalTextMuted,
),
const SizedBox(width: 6),
Expanded(
child: Stack(
alignment: Alignment.centerLeft,
@@ -160,7 +171,7 @@ class _ClideFilterBoxState extends State<ClideFilterBox> {
onTap: _clear,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideIcon(PhosphorIcons.xMark, size: 11, color: tokens.globalTextMuted),
child: ClideIcon(PhosphorIcons.byName('x'), size: 11, color: tokens.globalTextMuted),
),
),
],
+17
View File
@@ -11,6 +11,23 @@ abstract class ClideIconPainter {
void paint(Canvas canvas, Color color);
}
/// Draws nothing, but [ClideIcon] still reserves its `size×size` box — so a
/// caller can keep the icon slot for alignment without showing a glyph. This is
/// the *intentional* empty (e.g. a non-search filter field), distinct from an
/// unknown glyph name, which renders a visible `placeholder` error box (T-314).
class EmptyIconPainter extends ClideIconPainter {
const EmptyIconPainter();
@override
void paint(Canvas canvas, Color color) {}
@override
bool operator ==(Object other) => other is EmptyIconPainter;
@override
int get hashCode => (EmptyIconPainter).hashCode;
}
class ClideIcon extends StatelessWidget {
const ClideIcon(
this.painter, {
+1 -1
View File
@@ -114,7 +114,7 @@ class _ClideLightboxState extends State<ClideLightbox> {
Positioned(
top: 8,
right: 8,
child: _IconChip(icon: PhosphorIcons.xMark, label: 'close', onTap: widget.onDismiss, tokens: tokens),
child: _IconChip(icon: PhosphorIcons.byName('x'), label: 'close', onTap: widget.onDismiss, tokens: tokens),
),
Positioned(
bottom: 8,
+5 -5
View File
@@ -12,13 +12,13 @@ import 'package:flutter/widgets.dart';
({Color color, ClideIconPainter icon}) _styleFor(ToastSeverity s, SurfaceTokens t) {
switch (s) {
case ToastSeverity.success:
return (color: t.statusSuccess, icon: PhosphorIcons.checkCircle);
return (color: t.statusSuccess, icon: PhosphorIcons.byName('check-circle'));
case ToastSeverity.warning:
return (color: t.statusWarning, icon: PhosphorIcons.warningCircle);
return (color: t.statusWarning, icon: PhosphorIcons.byName('warning-circle'));
case ToastSeverity.error:
return (color: t.statusError, icon: PhosphorIcons.warningCircle);
return (color: t.statusError, icon: PhosphorIcons.byName('warning-circle'));
case ToastSeverity.info:
return (color: t.statusInfo, icon: PhosphorIcons.circlesFour);
return (color: t.statusInfo, icon: PhosphorIcons.byName('circles-four'));
}
}
@@ -87,7 +87,7 @@ class _ClideToastState extends State<ClideToast> {
child: ClideTappable(
onTap: widget.onDismiss,
builder: (ctx, hovered, _) => ClideIcon(
PhosphorIcons.xMark,
PhosphorIcons.byName('x'),
size: clideIconStandard,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
),
+19 -50
View File
@@ -1,6 +1,7 @@
import 'dart:ui' as ui;
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/icons/phosphor_glyphs.g.dart';
class PhosphorIconPainter extends ClideIconPainter {
const PhosphorIconPainter(this.codePoint, {this.family = 'Phosphor'});
@@ -27,55 +28,23 @@ class PhosphorIconPainter extends ClideIconPainter {
int get hashCode => Object.hash(codePoint, family);
}
/// Phosphor glyphs, resolved by their exact kebab-case name (e.g. `folder`,
/// `folder-simple`, `git-branch`). The full label→codepoint table is the
/// generated [kPhosphorGlyphs]; raw codepoints live only there, so feature code
/// reads `PhosphorIcons.byName('folder')` rather than `0xe24a` (T-314). A
/// string key also lets a Lua extension name an icon without crossing the FFI
/// boundary with a codepoint.
abstract class PhosphorIcons {
static const folder = PhosphorIconPainter(0xe24a);
static const fileText = PhosphorIconPainter(0xe23a);
static const gitBranch = PhosphorIconPainter(0xe278);
static const gitCommit = PhosphorIconPainter(0xe27a);
static const gitDiff = PhosphorIconPainter(0xe27c);
static const gitPullRequest = PhosphorIconPainter(0xe282);
static const magnifyingGlass = PhosphorIconPainter(0xe30c);
static const terminal = PhosphorIconPainter(0xe47e);
static const terminalWindow = PhosphorIconPainter(0xeae8);
static const code = PhosphorIconPainter(0xe1bc);
static const codeBlock = PhosphorIconPainter(0xeafe);
static const pencilSimple = PhosphorIconPainter(0xe3b4);
static const eye = PhosphorIconPainter(0xe220);
static const eyeSlash = PhosphorIconPainter(0xe224);
static const arrowClockwise = PhosphorIconPainter(0xe036);
static const arrowsOutSimple = PhosphorIconPainter(0xe0a6);
static const arrowsInSimple = PhosphorIconPainter(0xe09e);
static const list = PhosphorIconPainter(0xe2f0);
static const listChecks = PhosphorIconPainter(0xeadc);
static const gear = PhosphorIconPainter(0xe270);
static const puzzlePiece = PhosphorIconPainter(0xe596);
static const keyboard = PhosphorIconPainter(0xe2d8);
static const palette = PhosphorIconPainter(0xe6c8);
static const warning = PhosphorIconPainter(0xe4e0);
static const warningCircle = PhosphorIconPainter(0xe4e2);
static const shieldCheck = PhosphorIconPainter(0xe40c);
static const shieldWarning = PhosphorIconPainter(0xe412);
static const check = PhosphorIconPainter(0xe182);
static const checkCircle = PhosphorIconPainter(0xe184);
static const caretLeft = PhosphorIconPainter(0xe138);
static const caretRight = PhosphorIconPainter(0xe13a);
static const caretDown = PhosphorIconPainter(0xe136);
static const caretUp = PhosphorIconPainter(0xe13c);
// Chevron-with-edge-line: reads as "collapse to / expand from the edge" (T-294).
static const caretLineLeft = PhosphorIconPainter(0xe132);
static const caretLineRight = PhosphorIconPainter(0xe130);
static const graph = PhosphorIconPainter(0xeb58);
static const treeStructure = PhosphorIconPainter(0xe67c);
static const image = PhosphorIconPainter(0xe2ca);
static const link = PhosphorIconPainter(0xe2e2);
static const pushPin = PhosphorIconPainter(0xe3e2);
static const arrowUUpLeft = PhosphorIconPainter(0xe08a);
static const chatCircle = PhosphorIconPainter(0xe168);
static const robot = PhosphorIconPainter(0xe762);
static const ticket = PhosphorIconPainter(0xe490);
static const lightbulb = PhosphorIconPainter(0xe2dc);
static const notepad = PhosphorIconPainter(0xe63e);
static const bookOpen = PhosphorIconPainter(0xe0e6);
static const xMark = PhosphorIconPainter(0xe4f6);
static const circlesFour = PhosphorIconPainter(0xe190);
/// Shown when a name doesn't resolve. An unresolved name is a real error — a
/// wrong or missing label — so the fallback is `placeholder`: the box reads
/// as a render error and surfaces the bug honestly, rather than masking it as
/// an intentional "unknown" the way a `question` mark would.
static const String fallbackName = 'placeholder';
/// Resolve a glyph by its kebab-case [name]. A total function: an unknown
/// name degrades to [fallbackName] so a bad value (a typo, a stale setting, a
/// Lua extension's string) never crashes the UI. Typos in clide's own call
/// sites are caught at CI by `phosphor_glyphs_test`, which asserts every
/// `byName('…')` literal in `lib/` exists in the map.
static PhosphorIconPainter byName(String name) => PhosphorIconPainter(kPhosphorGlyphs[name] ?? kPhosphorGlyphs[fallbackName]!);
}
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -38,17 +38,17 @@ void main() {
// Sidebar open → action is collapse (leftward) → caret-line-left.
await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: false, visible: true)));
await tester.pump();
expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget);
expect(_icon(PhosphorIcons.byName('caret-line-left')), findsOneWidget);
// Sidebar collapsed → action is expand (rightward) → caret-line-right.
await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: true, visible: true)));
await tester.pump();
expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget);
expect(_icon(PhosphorIcons.byName('caret-line-right')), findsOneWidget);
// The context toggle mirrors it: open → collapse rightward, collapsed → expand leftward.
await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: false, visible: true)));
await tester.pump();
expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget);
expect(_icon(PhosphorIcons.byName('caret-line-right')), findsOneWidget);
});
testWidgets('the chevron flips live when the collapsed state changes', (tester) async {
@@ -62,19 +62,19 @@ void main() {
}),
));
await tester.pump();
expect(_icon(PhosphorIcons.caretLineLeft), findsOneWidget);
expect(_icon(PhosphorIcons.byName('caret-line-left')), findsOneWidget);
setOuter(() => collapsed = true);
await tester.pump();
expect(_icon(PhosphorIcons.caretLineRight), findsOneWidget, reason: 'rebuilds and flips on state change');
expect(_icon(PhosphorIcons.caretLineLeft), findsNothing);
expect(_icon(PhosphorIcons.byName('caret-line-right')), findsOneWidget, reason: 'rebuilds and flips on state change');
expect(_icon(PhosphorIcons.byName('caret-line-left')), findsNothing);
});
testWidgets('a hidden pane reserves the cell but shows no chevron', (tester) async {
await tester.pumpWidget(_host(f, const StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: false, visible: false)));
await tester.pump();
expect(_icon(PhosphorIcons.caretLineLeft), findsNothing);
expect(_icon(PhosphorIcons.caretLineRight), findsNothing);
expect(_icon(PhosphorIcons.byName('caret-line-left')), findsNothing);
expect(_icon(PhosphorIcons.byName('caret-line-right')), findsNothing);
});
testWidgets('tapping fires the matching collapse command', (tester) async {
@@ -90,7 +90,7 @@ void main() {
await tester.pump();
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
await tester.tap(_icon(PhosphorIcons.caretLineLeft));
await tester.tap(_icon(PhosphorIcons.byName('caret-line-left')));
await tester.pump();
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isTrue);
@@ -368,7 +368,7 @@ void main() {
expect(find.text('Interrupt'), findsOneWidget);
// The tickbox is implemented as a Container that is empty when unchecked
// and shows a check icon when checked. Verify by checking that the check
// icon (PhosphorIcons.check) is NOT rendered when unchecked.
// icon (PhosphorIcons.byName('check')) is NOT rendered when unchecked.
// We use the Semantics widget's checked property which maps to isChecked.
final interruptFinder = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Interrupt target session');
// Widget exists in tree.
@@ -0,0 +1,47 @@
/// Guards the Phosphor label→codepoint indirection (T-314). The named consts
/// are gone; code resolves glyphs by string via [PhosphorIcons.byName]. A
/// string key loses the compiler's typo check, so this test recovers it: every
/// `byName('…')` string literal in `lib/` must exist in the generated map —
/// across all call sites, executed or not.
library;
import 'dart:io';
import 'package:clide/widgets/src/icons/phosphor_glyphs.g.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('the generated glyph map covers the full Phosphor set', () {
expect(kPhosphorGlyphs.length, greaterThanOrEqualTo(1500));
expect(kPhosphorGlyphs.containsKey('folder'), isTrue);
// The fallback glyph must itself resolve, or byName would throw on a miss.
expect(kPhosphorGlyphs.containsKey(PhosphorIcons.fallbackName), isTrue);
});
test('every byName(\'\') literal in lib/ resolves to a real glyph', () {
final call = RegExp(r"byName\(\s*'([a-z0-9-]+)'\s*\)");
final missing = <String, String>{}; // label -> first file it appears in
for (final entity in Directory('lib').listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
for (final m in call.allMatches(entity.readAsStringSync())) {
final label = m.group(1)!;
if (!kPhosphorGlyphs.containsKey(label)) {
missing.putIfAbsent(label, () => entity.path);
}
}
}
expect(missing, isEmpty, reason: 'unknown Phosphor glyph name(s) in byName(): $missing');
});
test('byName resolves a known name to its codepoint', () {
expect(PhosphorIcons.byName('folder').codePoint, kPhosphorGlyphs['folder']);
expect(PhosphorIcons.byName('caret-line-left').codePoint, kPhosphorGlyphs['caret-line-left']);
});
test('byName degrades an unknown name to the placeholder fallback (never throws)', () {
final fallback = kPhosphorGlyphs[PhosphorIcons.fallbackName];
expect(PhosphorIcons.byName('definitely-not-a-real-glyph').codePoint, fallback);
expect(PhosphorIcons.fallbackName, 'placeholder');
});
}
+9 -7
View File
@@ -296,11 +296,13 @@ void main() {
testWidgets('renders the hint as a placeholder (shown empty, hidden once typed); icon optional', (tester) async {
await tester.pumpWidget(harness(
f,
ClideFilterBox(onChanged: (_) {}, hint: 'Replace', icon: null),
ClideFilterBox(onChanged: (_) {}, hint: 'Replace', showIcon: false),
));
// Placeholder visible while empty; no leading search glyph (icon: null).
// Placeholder visible while empty; the icon slot is kept for alignment but
// draws a blank glyph (showIcon: false → EmptyIconPainter).
expect(find.text('Replace'), findsOneWidget);
expect(find.byType(ClideIcon), findsNothing);
expect(find.byType(ClideIcon), findsOneWidget);
expect(tester.widget<ClideIcon>(find.byType(ClideIcon)).painter, isA<EmptyIconPainter>());
// Typing hides the placeholder.
await tester.enterText(find.byType(EditableText), 'x');
await tester.pump();
@@ -349,9 +351,9 @@ void main() {
await tester.pumpWidget(harness(
f,
ClideIconRail(
items: const [
ClideIconRailItem(id: 'a', icon: PhosphorIcons.folder, tooltip: 'A'),
ClideIconRailItem(id: 'b', icon: PhosphorIcons.gitBranch, tooltip: 'B'),
items: [
ClideIconRailItem(id: 'a', icon: PhosphorIcons.byName('folder'), tooltip: 'A'),
ClideIconRailItem(id: 'b', icon: PhosphorIcons.byName('git-branch'), tooltip: 'B'),
],
activeId: 'a',
onSelect: (id) => selected = id,
@@ -373,7 +375,7 @@ void main() {
height: 30,
child: ClideIconRail(
items: [
for (var i = 0; i < 10; i++) ClideIconRailItem(id: '$i', icon: PhosphorIcons.folder, tooltip: 'Tab $i'),
for (var i = 0; i < 10; i++) ClideIconRailItem(id: '$i', icon: PhosphorIcons.byName('folder'), tooltip: 'Tab $i'),
],
activeId: '0',
onSelect: (_) {},
+48
View File
@@ -0,0 +1,48 @@
// Regenerates lib/widgets/src/icons/phosphor_glyphs.g.dart from the Phosphor
// glyph table. Run from the repo root:
//
// dart run tool/gen_phosphor_glyphs.dart
//
// Source of truth is the glyph table shipped with the ui-design skill — each
// row is `| `0xNNNN` | kebab-name | PascalName | |`. We keep only the codepoint
// + the exact kebab-case upstream name, which is what code references via
// PhosphorIcons.byName('…'). No Flutter imports — plain Dart, runnable with
// `dart run`.
import 'dart:io';
const _src = '.claude/skills/ui-design/references/phosphor-glyphs.md';
const _out = 'lib/widgets/src/icons/phosphor_glyphs.g.dart';
void main() {
final rows = File(_src).readAsLinesSync();
// `| `0xe24a` | folder | Folder | |`
final re = RegExp(r'^\|\s*`(0x[0-9a-fA-F]+)`\s*\|\s*([a-z0-9-]+)\s*\|');
final glyphs = <String, int>{};
for (final line in rows) {
final m = re.firstMatch(line);
if (m == null) continue;
final code = int.parse(m.group(1)!.substring(2), radix: 16);
glyphs[m.group(2)!] = code;
}
if (glyphs.length < 1000) {
stderr.writeln('refusing to write: only ${glyphs.length} glyphs parsed from $_src');
exit(1);
}
final names = glyphs.keys.toList()..sort();
final b = StringBuffer()
..writeln('// GENERATED by tool/gen_phosphor_glyphs.dart — do not edit by hand.')
..writeln('// Source: $_src (${glyphs.length} glyphs).')
..writeln('//')
..writeln('// The single label→codepoint lookup for the bundled Phosphor font. Code')
..writeln('// references glyphs by their exact kebab-case name via PhosphorIcons.byName;')
..writeln('// raw codepoints live only here.')
..writeln('library;')
..writeln()
..writeln('const Map<String, int> kPhosphorGlyphs = {');
for (final n in names) {
b.writeln(" '$n': 0x${glyphs[n]!.toRadixString(16)},");
}
b.writeln('};');
File(_out).writeAsStringSync(b.toString());
stdout.writeln('wrote $_out with ${glyphs.length} glyphs');
}