feat(icon): Phosphor glyph card — hero + real-UI-size strip (T-313)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 13:10:53 +02:00
co-authored by Claude Opus 4.8
parent 9a2b4e9eca
commit 281fb2a869
10 changed files with 205 additions and 1 deletions
+4
View File
@@ -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 (1048), 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 <path> --caption` form is unchanged.
+1
View File
@@ -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" },
+1
View File
@@ -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" },
@@ -163,6 +163,7 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
case AssistantTextMessage():
case ImageMessage():
case DrawingMessage():
case IconMessage():
return false;
// Thinking folds at L2+, first-class at L1.
case AssistantThinkingMessage():
@@ -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(', ')}';
}
}
+39 -1
View File
@@ -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 <name…>` (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 = <IconEntry>[];
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.
+89
View File
@@ -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 (1048), 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<IconEntry> 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 = <double>[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);
}
}
@@ -232,6 +232,31 @@ final class DrawingMessage extends ConversationItem {
String toString() => 'DrawingMessage(${label ?? '<svg>'})';
}
/// 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<IconEntry> entries;
final String? color;
@override
String toString() => 'IconMessage(${entries.length} glyph${entries.length == 1 ? '' : 's'})';
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
+17
View File
@@ -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<void> main() async {
}
},
);
// `clide icon show <name…>` — 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 <doc>` — 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;
@@ -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<IconEntry> entries, {String? color}) => IconMessage(uuid: 'ic', timestamp: _t, isSidechain: false, entries: entries, color: color);
class _MockClipboard {
Map<String, dynamic> _data = {'text': null};
Future<Object?> 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);