diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 3583fcd4..7afc2331 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -5929,3 +5929,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'status', 'backlog', 'done', NULL, '2026-06-17 19:35:22', '2026-06-17 19:35:22.690', '2026-06-17 19:35:22.690', NULL, '580448456994d6acecc88cfbd40a595f', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'status', 'backlog', 'done', NULL, '2026-06-17 19:37:39', '2026-06-17 19:37:39.813', '2026-06-17 19:37:39.813', NULL, 'd59d0df2b693a0616265dd5e39559dc0', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'status', 'backlog', 'done', NULL, '2026-06-17 19:47:02', '2026-06-17 19:47:02.941', '2026-06-17 19:47:02.941', NULL, '28625100d88cacac15cabe97754fcdc5', 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 ('06FDEMY3DQWDNY535PMMSB0CSW', 'status', 'backlog', 'done', NULL, '2026-06-17 20:51:21', '2026-06-17 20:51:21.006', '2026-06-17 20:51:21.006', NULL, '20516cde17cc921f419aa5f12ea49bc1', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 5983c643..ffa0aaec 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7248,3 +7248,5 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat 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 ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate 11 context-less mono-font sites onto ClideSettings.fonts (thread family through helpers)', 'Per D-101. The 11 sites that need a BuildContext threaded from the nearest context-bearing caller (resolve ClideSettings.fonts.monoOf(context) and pass the family in): prompt_card.dart toolEditBody/toolReadLikeBody/toolPathLine (top-level fns); conversation_view.dart _toolResult; welcome_view.dart _tipRow; search_panel_view.dart _base getter; clide_markdown.dart _inlineElementSpan/_recordLinkSpan/_fileLinkSpan (static span chain). Until done, these render the default JetBrains Mono regardless of the mono-font setting.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 18:48:11.649', '2026-06-17 19:35:22.690', NULL, '7dd6dae4197fc95d0bc6becfad4eecbb', 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 ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate i18n consumers (ClideKernel.i18n → ClideSettings.i18n.of) per D-101', 'Per D-101. Migrate i18n reads (ClideKernel.of(context).i18n[.string]) to ClideSettings.i18n.of(context). The facade delegates to the same I18n service, so behaviour is unchanged; mechanical sweep in batches.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:21.490', '2026-06-17 19:37:39.813', NULL, 'fc64918991f0c9133419ee51227e1939', 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 ('06FDDX5GVH3FTCVDEC1QAFACY4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Migrate theme consumers (ClideTheme.of → ClideSettings.theme.of) per D-101', 'Per D-101. Migrate ClideTheme.of(context) consumers (hundreds of sites) to ClideSettings.theme.of(context). The facade already delegates to ClideTheme, so this is a mechanical, no-behavior-change sweep — do it in focused batches with analyze between, ideally via a scripted find/replace + import fixups.', 'done', 'medium', NULL, NULL, NULL, '2026-06-17 19:00:20.828', '2026-06-17 19:47:02.941', NULL, '8ea58b6162c81778a610f46b07585b05', 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 ('06FDEMY3DQWDNY535PMMSB0CSW', 'bug', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Markdown prose ignores the UI-font setting (clideUiFamily const)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 20:44:11.501', '2026-06-17 20:44:11.501', NULL, '5861faa02d7cc34271dc72cf964f63cf', 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 ('06FDEMY3DQWDNY535PMMSB0CSW', 'bug', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Markdown prose ignores the UI-font setting (clideUiFamily const)', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-17 20:44:11.501', '2026-06-17 20:51:21.006', NULL, '6d53da1fcfe6ad27b54a6ea07c1ccc7e', 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index fedc164c..067e7219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- **Markdown prose honours the UI-font setting.** Claude's conversation prose + and inline links render through the markdown engine, which pinned the bundled + UI face and ignored the Appearance UI-font pick; it now follows the setting + live, like the rest of the app. (T-475) + ## [2.7.0] — 2026-06-17 ### Added diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart index 250e4da7..1d8a7086 100644 --- a/lib/widgets/src/clide_markdown.dart +++ b/lib/widgets/src/clide_markdown.dart @@ -35,7 +35,15 @@ typedef FileTapCallback = void Function(String path, int? line); /// inert (the text renders, just not interactive). @immutable class ClideMarkdownHooks { - const ClideMarkdownHooks({this.onRecordTap, this.onImageToken, this.onLinkTap, this.resolveFileRef, this.onOpenFile, this.mono = clideMonoFamily}); + const ClideMarkdownHooks({ + this.onRecordTap, + this.onImageToken, + this.onLinkTap, + this.resolveFileRef, + this.onOpenFile, + this.mono = clideMonoFamily, + this.ui = clideUiFamily, + }); /// Tap a governance/ticket ref (T-281, D-77, …) → open the record (T-279). final RecordTapCallback? onRecordTap; @@ -59,6 +67,11 @@ class ClideMarkdownHooks { /// even though the span builders are context-free statics (T-472). final String mono; + /// The live UI family (Settings → Appearance, T-460), threaded the same way + /// so markdown prose and link spans honour the chosen UI font instead of + /// pinning the bundled default (T-475). + final String ui; + static const none = ClideMarkdownHooks(); } @@ -123,6 +136,7 @@ class ClideMarkdown extends StatelessWidget { resolveFileRef: resolveFileRef, onOpenFile: onOpenFile, mono: ClideSettings.fonts.monoOf(context), + ui: ClideSettings.fonts.uiOf(context), ); final widgets = _buildNodes(nodes, tokens, hooks); return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: widgets); @@ -154,7 +168,7 @@ class ClideMarkdown extends StatelessWidget { Text.rich( TextSpan( style: TextStyle( - fontFamily: clideUiFamily, + fontFamily: hooks.ui, fontFamilyFallback: clideUiFamilyFallback, fontWeight: clideUiDefaultWeight, color: tokens.globalForeground, @@ -344,7 +358,7 @@ class ClideMarkdown extends StatelessWidget { } return TextSpan( style: TextStyle( - fontFamily: clideUiFamily, + fontFamily: hooks.ui, fontFamilyFallback: clideUiFamilyFallback, fontWeight: fontWeight ?? clideUiDefaultWeight, color: tokens.globalForeground, @@ -379,7 +393,7 @@ class ClideMarkdown extends StatelessWidget { // editor (T-300); other inline code renders verbatim. if (hooks.resolveFileRef != null && hooks.onOpenFile != null) { final ref = _codeFileRef(raw, hooks.resolveFileRef!); - if (ref != null) return _fileLinkSpan(raw, ref.$1, ref.$2, tokens, hooks.onOpenFile!, hooks.mono, mono: true); + if (ref != null) return _fileLinkSpan(raw, ref.$1, ref.$2, tokens, hooks.onOpenFile!, hooks.mono, hooks.ui, mono: true); } return TextSpan( text: raw, @@ -401,7 +415,7 @@ class ClideMarkdown extends StatelessWidget { if (hooks.resolveFileRef != null && hooks.onOpenFile != null && href != null) { final (path, line) = _splitFileRef(href); final abs = hooks.resolveFileRef!(path); - if (abs != null) return _fileLinkSpan(text, abs, line, tokens, hooks.onOpenFile!, hooks.mono); + if (abs != null) return _fileLinkSpan(text, abs, line, tokens, hooks.onOpenFile!, hooks.mono, hooks.ui); } return TextSpan( text: text, @@ -482,7 +496,7 @@ class ClideMarkdown extends StatelessWidget { final abs = hooks.resolveFileRef!(m.group(1)!); if (abs == null) continue; final line = m.group(2) == null ? null : int.tryParse(m.group(2)!); - hits.add(_LinkHit(m.start, m.end, _fileLinkSpan(text.substring(m.start, m.end), abs, line, tokens, hooks.onOpenFile!, hooks.mono))); + hits.add(_LinkHit(m.start, m.end, _fileLinkSpan(text.substring(m.start, m.end), abs, line, tokens, hooks.onOpenFile!, hooks.mono, hooks.ui))); } } if (hits.isEmpty) return [TextSpan(text: text)]; @@ -560,7 +574,8 @@ class ClideMarkdown extends StatelessWidget { int? line, SurfaceTokens tokens, FileTapCallback onOpenFile, - String monoFamily, { + String monoFamily, + String uiFamily, { bool mono = false, }) { return WidgetSpan( @@ -575,7 +590,7 @@ class ClideMarkdown extends StatelessWidget { color: tokens.globalFocus, fontSize: mono ? clideFontMono : _fontSize, height: _lineHeight, - fontFamily: mono ? monoFamily : clideUiFamily, + fontFamily: mono ? monoFamily : uiFamily, fontFamilyFallback: mono ? null : clideUiFamilyFallback, decoration: hovered ? TextDecoration.underline : null, decorationColor: tokens.globalFocus, diff --git a/test/widgets/src/clide_markdown_test.dart b/test/widgets/src/clide_markdown_test.dart index c1fc8c65..81c20484 100644 --- a/test/widgets/src/clide_markdown_test.dart +++ b/test/widgets/src/clide_markdown_test.dart @@ -3,6 +3,7 @@ library; import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; @@ -71,4 +72,29 @@ void main() { await tester.pump(); expect(find.textContaining('[image: http://x/y.png]'), findsOneWidget); }); + + // T-475 (UI) / T-472 (mono): the static span builders take no BuildContext, + // so they used to pin the bundled const families and ignore the live font + // settings. They now read the resolved families from the ClideSettingsScope. + testWidgets('prose and inline code honour the live UI + mono families from the scope', (tester) async { + await tester.pumpWidget(harness(f, const ClideSettingsScope(ui: 'TestUiFace', mono: 'TestMonoFace', child: ClideMarkdown('hello `snippet` world')))); + await tester.pump(); + + final families = {}; + void collect(InlineSpan span) { + if (span is TextSpan) { + families.add(span.style?.fontFamily); + for (final child in span.children ?? const []) { + collect(child); + } + } + } + + for (final t in tester.widgetList(find.byType(Text))) { + final span = t.textSpan; + if (span != null) collect(span); + } + expect(families, contains('TestUiFace')); // prose paragraph + expect(families, contains('TestMonoFace')); // inline `code` + }); }