From 281fb2a8698d00ae8f30dbdcda0adfba22d9bb34 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 29 Jun 2026 13:10:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(icon):=20Phosphor=20glyph=20card=20?= =?UTF-8?q?=E2=80=94=20hero=20+=20real-UI-size=20strip=20(T-313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon card renders each glyph at a hero size plus a continuous sample strip (10–48px) so legibility is judged at the sizes the app uses, with the entry's optional label + description and a per-entry or card-level color. Wired end to end: icon.show publishes on the `icon` bus, the Claude extension injects an IconMessage, and conversation_view paints the card (display-only, D-78). en+nl catalogs. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + assets/i18n/en_us/builtin.claude.json | 1 + assets/i18n/nl_nl/builtin.claude.json | 1 + lib/builtin/claude/src/activity_cluster.dart | 1 + lib/builtin/claude/src/conversation_view.dart | 15 ++++ lib/builtin/claude/src/extension.dart | 40 ++++++++- lib/builtin/claude/src/icon_card.dart | 89 +++++++++++++++++++ lib/builtin/claude/src/transcript_reader.dart | 25 ++++++ lib/main.dart | 17 ++++ .../claude/conversation_view_test.dart | 13 +++ 10 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 lib/builtin/claude/src/icon_card.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b9851b..4a75a1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Phosphor glyph cards.** `clide icon show gear folder` (or `--file` entries + with label/description/color) renders glyphs in the conversation at a hero + size plus a real-UI-size strip (10–48), for previewing and comparing icons. + Resolves by name or 0xNNNN codepoint; honest error on an unknown glyph. (T-313) - **Annotated image cards.** `clide image show --file meta.json` attaches a title/label and a longer description to an image card (alongside the existing one-line caption); the bare `image show --caption` form is unchanged. diff --git a/assets/i18n/en_us/builtin.claude.json b/assets/i18n/en_us/builtin.claude.json index ee3d6077..f2f7659a 100644 --- a/assets/i18n/en_us/builtin.claude.json +++ b/assets/i18n/en_us/builtin.claude.json @@ -47,6 +47,7 @@ "conversation.label.thinking": { "translation": "thinking" }, "conversation.label.agentThinking": { "translation": "agent thinking" }, "conversation.label.image": { "translation": "image" }, + "conversation.label.icon": { "translation": "icons" }, "conversation.label.drawing": { "translation": "drawing" }, "conversation.draw.viewSource": { "translation": "view d2 source" }, "conversation.label.agentRun": { "translation": "agent run" }, diff --git a/assets/i18n/nl_nl/builtin.claude.json b/assets/i18n/nl_nl/builtin.claude.json index 8ad51fc7..fd7295d2 100644 --- a/assets/i18n/nl_nl/builtin.claude.json +++ b/assets/i18n/nl_nl/builtin.claude.json @@ -47,6 +47,7 @@ "conversation.label.thinking": { "translation": "nadenken" }, "conversation.label.agentThinking": { "translation": "agent denkt na" }, "conversation.label.image": { "translation": "afbeelding" }, + "conversation.label.icon": { "translation": "iconen" }, "conversation.label.drawing": { "translation": "tekening" }, "conversation.draw.viewSource": { "translation": "d2-bron tonen" }, "conversation.label.agentRun": { "translation": "agent-uitvoering" }, diff --git a/lib/builtin/claude/src/activity_cluster.dart b/lib/builtin/claude/src/activity_cluster.dart index 58b71545..9456d9c3 100644 --- a/lib/builtin/claude/src/activity_cluster.dart +++ b/lib/builtin/claude/src/activity_cluster.dart @@ -163,6 +163,7 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map too case AssistantTextMessage(): case ImageMessage(): case DrawingMessage(): + case IconMessage(): return false; // Thinking folds at L2+, first-class at L1. case AssistantThinkingMessage(): diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 5690efb1..693beac5 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -19,6 +19,7 @@ import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabe import 'package:clide/builtin/claude/src/conversation_card.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/file_tail_follower.dart'; +import 'package:clide/builtin/claude/src/icon_card.dart'; import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; @@ -705,6 +706,7 @@ class _ConversationTurn extends StatelessWidget { ToolResultMessage() => _toolResult(context, i), ImageMessage() => _image(context, i), DrawingMessage() => _drawing(context, i), + IconMessage() => _icon(context, i), }; } @@ -735,6 +737,17 @@ class _ConversationTurn extends StatelessWidget { ); } + /// A driven-in Phosphor glyph card (T-313): each glyph at a hero size plus a + /// real-UI-size strip, with optional label/description/color. Display-only + /// per D-78 — selection happens in the interaction zone, not on the card. + Widget _icon(BuildContext context, IconMessage m) { + return ConversationCard( + accent: tokens.globalTextMuted, + label: ClideSettings.i18n.string(context, 'conversation.label.icon', namespace: 'builtin.claude', placeholder: 'icons'), + body: IconGlyphCard(entries: m.entries, defaultColor: m.color), + ); + } + /// A driven-in image card (T-249): the image rendered inline, clide-owned /// (Flutter's [Image.file], no third-party viewer), display-only per D-78. /// Bounded so a large image scales down to the pane width and never pushes @@ -1319,5 +1332,7 @@ String _summarizeActivity(BuildContext context, ConversationItem item) { return '${label('conversation.label.image', 'image')} $path'; case DrawingMessage(label: final cardLabel): return '${label('conversation.label.drawing', 'drawing')}${cardLabel != null ? ' $cardLabel' : ''}'; + case IconMessage(:final entries): + return '${label('conversation.label.icon', 'icons')} ${entries.map((e) => e.name).join(', ')}'; } } diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index ea0b3900..8cb5283e 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -19,10 +19,11 @@ import 'package:clide/builtin/claude/src/session_index.dart'; import 'package:clide/builtin/claude/src/stream_json_session.dart' show kEffortLevels, kFallbackModels, kPermissionModes; import 'package:clide/builtin/claude/src/session_storage.dart'; import 'package:clide/builtin/claude/src/ticket_pick_up.dart'; -import 'package:clide/builtin/claude/src/transcript_reader.dart' show DrawingMessage, ImageMessage; +import 'package:clide/builtin/claude/src/transcript_reader.dart' show DrawingMessage, IconEntry, IconMessage, ImageMessage; import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel; import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel; import 'package:clide/src/daemon/draw_commands.dart' show drawShowChannel; +import 'package:clide/src/daemon/icon_commands.dart' show iconShowChannel; import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel; import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane; import 'package:clide/builtin/claude/src/team_panel_host.dart'; @@ -530,6 +531,10 @@ class ClaudeExtension extends ClideExtension { // conversation the user is looking at. _subs.add(ctx.messages.subscribe(channel: drawShowChannel).listen(_onDrawShow)); + // `clide icon show ` (T-313): the dispatcher resolves the glyphs and + // publishes an 'icon' message; we inject the glyph card. + _subs.add(ctx.messages.subscribe(channel: iconShowChannel).listen(_onIconShow)); + // A sidebar "pick up" click (T-327) publishes the full ticket; inject it // into the active conversation as a user turn so Claude starts working it. _subs.add(ctx.messages.subscribe(publisher: 'builtin.tickets', channel: 'pick-up').listen(_onTicketPickUp)); @@ -647,6 +652,39 @@ class ClaudeExtension extends ClideExtension { ); } + /// Inject an [IconMessage] from a published `icon` bus message (T-313). + void _onIconShow(Message m) { + final raw = m.data['entries']; + if (raw is! List || raw.isEmpty) return; + final entries = []; + for (final item in raw) { + if (item is! Map) continue; + final cp = item['codepoint']; + if (cp is! int) continue; + entries.add( + IconEntry( + codepoint: cp, + name: item['name'] as String? ?? '', + label: item['label'] as String?, + description: item['description'] as String?, + color: item['color'] as String?, + ), + ); + } + if (entries.isEmpty) return; + final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull; + if (target == null) return; + target.conversation.inject( + IconMessage( + uuid: 'icon-${DateTime.now().microsecondsSinceEpoch}', + timestamp: DateTime.now(), + isSidechain: false, + entries: entries, + color: m.data['color'] as String?, + ), + ); + } + /// Inject a [DrawingMessage] from a published `draw` bus message (T-318). /// Dropped silently if no live conversation is available — the CLI already /// reported success at publish time, and a missing pane is transient. diff --git a/lib/builtin/claude/src/icon_card.dart b/lib/builtin/claude/src/icon_card.dart new file mode 100644 index 00000000..1ed783a4 --- /dev/null +++ b/lib/builtin/claude/src/icon_card.dart @@ -0,0 +1,89 @@ +/// The Phosphor glyph card (T-313) — display-only per D-78. +/// +/// Each entry shows a HERO glyph (legible detail) plus a continuous sample strip +/// at the real UI sizes (10–48), so a reviewer judges how the glyph reads where +/// the app actually uses it; the optional per-entry label + description turn the +/// card into a labelled offer the interaction zone can mirror as a choice list. +/// A per-entry or card-level `color` (hex or CSS name) tints the glyph — content +/// color, not a clide token (the glyph is for whatever project we're on); it +/// falls back to the card foreground. +library; + +import 'package:clide/builtin/claude/src/transcript_reader.dart' show IconEntry; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/src/svg/svg_color.dart' show parseSvgColor; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class IconGlyphCard extends StatelessWidget { + const IconGlyphCard({super.key, required this.entries, this.defaultColor}); + + final List entries; + + /// Card-level default glyph color (hex / CSS name), applied to entries without + /// their own. + final String? defaultColor; + + /// One continuous sample strip, smallest → largest (T-313, finalized set). + static const _sizes = [10, 11, 12, 13, 14, 15, 18, 20, 24, 32, 48]; + static const _hero = 52.0; + + @override + Widget build(BuildContext context) { + final tokens = ClideSettings.theme.of(context).surface; + final cardColor = _parse(defaultColor) ?? tokens.globalForeground; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < entries.length; i++) ...[if (i > 0) const SizedBox(height: 18), _entry(tokens, entries[i], cardColor)], + ], + ); + } + + Widget _entry(SurfaceTokens tokens, IconEntry e, Color cardColor) { + final color = _parse(e.color) ?? cardColor; + final painter = PhosphorIconPainter(e.codepoint); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (e.label != null && e.label!.isNotEmpty) ClideText(e.label!, fontSize: clideFontMeta, fontWeight: FontWeight.w600, color: tokens.globalForeground), + if (e.description != null && e.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: ClideText(e.description!, fontSize: clideFontCaption, color: tokens.globalTextMuted), + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClideIcon(painter, size: _hero, color: color), + const SizedBox(width: 20), + Expanded( + child: Wrap( + spacing: 14, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.end, + children: [for (final s in _sizes) _sample(tokens, painter, color, s)], + ), + ), + ], + ), + ], + ); + } + + Widget _sample(SurfaceTokens tokens, PhosphorIconPainter painter, Color color, double size) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClideIcon(painter, size: size, color: color), + const SizedBox(height: 2), + ClideText('${size.toInt()}', fontSize: clideFontBadge, color: tokens.globalTextMuted), + ], + ); + + Color? _parse(String? raw) { + if (raw == null) return null; + final argb = parseSvgColor(raw); + return argb == null ? null : Color(argb); + } +} diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index ac7b8b5e..a0efe252 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -232,6 +232,31 @@ final class DrawingMessage extends ConversationItem { String toString() => 'DrawingMessage(${label ?? ''})'; } +/// One glyph entry on an [IconMessage] (T-313): a resolved Phosphor [codepoint] +/// (its [name] kept for copy/debug), with optional per-entry [label], +/// [description], and [color] (hex or CSS name, parsed at render). +final class IconEntry { + const IconEntry({required this.codepoint, required this.name, this.label, this.description, this.color}); + + final int codepoint; + final String name; + final String? label, description, color; +} + +/// A locally-injected Phosphor glyph card (T-313). Driven by `clide icon show` +/// (D-6 parity), display-only per D-78. Renders each [entries] glyph at a hero +/// size plus a sample strip of real UI sizes, with its optional label + +/// description; [color] is the card-level default glyph color. +final class IconMessage extends ConversationItem { + const IconMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.entries, this.color}); + + final List entries; + final String? color; + + @override + String toString() => 'IconMessage(${entries.length} glyph${entries.length == 1 ? '' : 's'})'; +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/lib/main.dart b/lib/main.dart index 20adc0b2..93a6c131 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -45,6 +45,7 @@ import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler; import 'package:clide/src/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/git_commands.dart'; +import 'package:clide/src/daemon/icon_commands.dart'; import 'package:clide/src/daemon/image_commands.dart'; import 'package:clide/src/daemon/project_commands.dart'; import 'package:clide/src/daemon/instance_command.dart'; @@ -61,6 +62,7 @@ import 'package:clide/src/git/client.dart'; import 'package:clide/src/cli/argv_dispatch.dart'; import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath; import 'package:clide/src/env/supporter_binaries.dart'; +import 'package:clide/widgets/src/icons/phosphor_glyphs.g.dart' show kPhosphorGlyphs; import 'package:clide/src/ipc/envelope.dart'; import 'package:clide/src/ipc/mcp_server.dart'; import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath, logDirectory; @@ -377,6 +379,21 @@ Future main() async { } }, ); + // `clide icon show ` — drive a Phosphor glyph card into the Claude + // conversation (T-313). Resolves names via the bundled glyph table. + registerIconCommands( + dispatcher, + () => kernelMessages?.publish, + resolve: (name) => kPhosphorGlyphs[name], + readFile: (path) async { + final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path'); + try { + return file.existsSync() ? await file.readAsString() : null; + } catch (_) { + return null; + } + }, + ); // `clide draw --file ` — drive a drawing card into the Claude // conversation (T-318, drive-half of D-6). Reads the JSON doc relative to // workRoot, lowers it to SVG via the template registry (primitive svg now; diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index e20d324d..1f480ff5 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -38,6 +38,8 @@ ToolResultMessage _result(String content, {bool isError = false}) => ImageMessage _image(String path, {String? caption, String? label, String? description}) => ImageMessage(uuid: 'i', timestamp: _t, isSidechain: false, path: path, caption: caption, label: label, description: description); +IconMessage _iconMsg(List entries, {String? color}) => IconMessage(uuid: 'ic', timestamp: _t, isSidechain: false, entries: entries, color: color); + class _MockClipboard { Map _data = {'text': null}; Future handleMethodCall(MethodCall call) async { @@ -480,6 +482,17 @@ void main() { expect(find.text('before'), findsOneWidget); }); + testWidgets('an icon card renders the glyph label, description, and size strip (T-313)', (tester) async { + await pumpWith(tester, [ + _iconMsg([const IconEntry(codepoint: 0xe2a4, name: 'gear', label: 'Settings', description: 'global scope')]), + ]); + expect(find.text('icons'), findsOneWidget); // card label + expect(find.text('Settings'), findsOneWidget); // entry label + expect(find.text('global scope'), findsOneWidget); + expect(find.text('10'), findsOneWidget); // smallest strip sample + expect(find.text('48'), findsOneWidget); // largest strip sample + }); + testWidgets('inject() drives a new image card into a live view (T-249)', (tester) async { final c = await pumpWith(tester, [_user('hi')]); expect(find.text('image'), findsNothing);