From 33a8a74240c57fe908cc5e3f3fa0a3bd4116491a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 18 Jun 2026 23:26:02 +0200 Subject: [PATCH] refactor(i18n): route framework + shared chrome through a 'core' catalog (T-469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework strings outside any extension — widget primitives (collapser, toast, lightbox, multitab, ex-line, spine, pane chrome), the shared reader chrome, the markdown 'Open in editor' tooltip, and the drag-resize handle a11y labels — now resolve under a new 'core' namespace (preloaded at boot). Settles the T-469 namespace question: framework chrome gets one 'core' catalog. Makes ClideSettings.i18n.string/.interpolated null-safe (ClideKernel.maybeOf): primitives render kernel-less in isolated tests, returning the placeholder — matching the D-101 fallback contract for fonts. The markdown tooltip threads via the ClideMarkdownHooks carrier like mono/ui; drag_resize reads the kernel i18n directly to avoid a kernel→widgets layering inversion. No en_US change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 9 +++ lib/builtin/shared/reader_chrome.dart | 30 ++++++++-- lib/kernel/src/facade.dart | 5 ++ lib/kernel/src/i18n/catalog/core_en_us.json | 27 +++++++++ lib/kernel/src/panels/drag_resize.dart | 64 ++++++++++++++++++--- lib/main.dart | 3 + lib/widgets/src/clide_collapser_card.dart | 34 +++++------ lib/widgets/src/clide_lightbox.dart | 18 +++++- lib/widgets/src/clide_markdown.dart | 24 ++++++-- lib/widgets/src/clide_pane_chrome.dart | 13 ++++- lib/widgets/src/clide_settings.dart | 20 +++++-- lib/widgets/src/clide_spine.dart | 3 +- lib/widgets/src/clide_toast.dart | 2 +- lib/widgets/src/ex_line_overlay.dart | 6 +- lib/widgets/src/multitab_pane.dart | 2 +- 16 files changed, 212 insertions(+), 49 deletions(-) create mode 100644 lib/kernel/src/i18n/catalog/core_en_us.json diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 6aba9620..567fdb79 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -5943,3 +5943,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 ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'status', 'in_progress', 'done', NULL, '2026-06-18 19:19:25', '2026-06-18 19:19:25.269', '2026-06-18 19:19:25.269', NULL, 'f1660567ecfe0c7220c23244206bc4d2', 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 ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'status', 'in_progress', 'done', NULL, '2026-06-18 20:13:50', '2026-06-18 20:13:50.352', '2026-06-18 20:13:50.352', NULL, 'a885fb7b56c053669a8ef5f0e6032feb', 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 ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'status', 'in_progress', 'done', NULL, '2026-06-18 20:16:03', '2026-06-18 20:16:03.747', '2026-06-18 20:16:03.747', NULL, '5e67beaa758d779f1b9212aecb82e050', 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 ('06FDA10DRQE3SED10JAW2CZDCR', 'status', 'backlog', 'done', NULL, '2026-06-18 21:21:27', '2026-06-18 21:21:27.304', '2026-06-18 21:21:27.304', NULL, '0ab235b21c54d46de3e3e4d1529de041', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 2001ccc9..268c9fe5 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7355,3 +7355,12 @@ Several have no catalog file yet — create lib/kernel/src/i18n/catalog/builtin. Can be split into smaller tasks if a single PR is too large; grouped here to keep the long tail in one tracked place. Acceptance: no hardcoded user-facing literals remain in any of these extensions; each has a catalog file with the keys it uses; no orphaned keys left in editor/files/terminal.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:41.092', '2026-06-18 20:16:03.746', NULL, '8212472a6e32ec3000b829a7abee3cb9', 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 ('06FDA10DRQE3SED10JAW2CZDCR', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: route framework-level labels in lib/widgets + lib/kernel (~10 strings)', '~9 hardcoded user-facing strings in lib/widgets/ and ~1 in lib/kernel/ live outside any extension, so they have no obvious i18n namespace. Resolve the namespace question, then migrate. + +Hotspots from the audit: +- lib/widgets/src/ex_line_overlay.dart:163 '':'' , :183 ''Not an editor command'' (these are vim ex-line — may belong to builtin.vim''s namespace rather than a generic one) +- lib/widgets/src/multitab_pane.dart:358 ''+'' (likely an icon glyph — exempt if so) + +Open question to settle in this task: do framework widgets get a ''core''/''kernel'' namespace, or does each caller pass its owning extension''s namespace down? Pick one and note it (small D- or Q- record if it sets precedent). Single-char icon glyphs (''+'', '':'') are exempt — note any left. + +Acceptance: real user-facing sentences in lib/widgets + lib/kernel resolve through i18n under a documented namespace; glyph exemptions noted.', 'done', 'low', NULL, NULL, 'D-21', '2026-06-17 09:57:53.221', '2026-06-18 21:21:27.304', NULL, '9817d717fa77916d07547d9674d14fcb', 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/lib/builtin/shared/reader_chrome.dart b/lib/builtin/shared/reader_chrome.dart index 9c3c909f..9a5e7ab3 100644 --- a/lib/builtin/shared/reader_chrome.dart +++ b/lib/builtin/shared/reader_chrome.dart @@ -50,22 +50,40 @@ class ReaderActionBar extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - _ActionButton(painter: PhosphorIcons.byName('caret-left'), tooltip: 'Back', enabled: canGoBack, onTap: canGoBack ? onBack : null, tokens: tokens), + _ActionButton( + painter: PhosphorIcons.byName('caret-left'), + tooltip: ClideSettings.i18n.string(context, 'reader.back', namespace: 'core', placeholder: 'Back'), + enabled: canGoBack, + onTap: canGoBack ? onBack : null, + tokens: tokens, + ), const SizedBox(width: 2), _ActionButton( painter: PhosphorIcons.byName('caret-right'), - tooltip: 'Forward', + tooltip: ClideSettings.i18n.string(context, 'reader.forward', namespace: 'core', placeholder: 'Forward'), enabled: canGoForward, onTap: canGoForward ? onForward : null, tokens: tokens, ), if (hasPinned) ...[ const SizedBox(width: 2), - _ActionButton(painter: PhosphorIcons.byName('arrow-u-up-left'), tooltip: 'Jump to pin', enabled: true, onTap: onJumpToPin, tokens: tokens), + _ActionButton( + painter: PhosphorIcons.byName('arrow-u-up-left'), + tooltip: ClideSettings.i18n.string(context, 'reader.jumpToPin', namespace: 'core', placeholder: 'Jump to pin'), + enabled: true, + onTap: onJumpToPin, + tokens: tokens, + ), ], if (onEdit != null) ...[ const SizedBox(width: 4), - _ActionButton(painter: PhosphorIcons.byName('pencil-simple'), tooltip: 'Edit in editor', enabled: true, onTap: onEdit, tokens: tokens), + _ActionButton( + painter: PhosphorIcons.byName('pencil-simple'), + tooltip: ClideSettings.i18n.string(context, 'reader.edit', namespace: 'core', placeholder: 'Edit in editor'), + enabled: true, + onTap: onEdit, + tokens: tokens, + ), ], ], ); @@ -86,7 +104,9 @@ class ReaderPinButton extends StatelessWidget { Widget build(BuildContext context) { return _ActionButton( painter: PhosphorIcons.byName('push-pin'), - tooltip: pinned ? 'Unpin' : 'Pin', + tooltip: pinned + ? ClideSettings.i18n.string(context, 'reader.unpin', namespace: 'core', placeholder: 'Unpin') + : ClideSettings.i18n.string(context, 'reader.pin', namespace: 'core', placeholder: 'Pin'), enabled: onTap != null, active: pinned, onTap: onTap, diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 9a6debf5..d6862b79 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -345,6 +345,11 @@ class ClideKernel extends InheritedWidget { return w.services; } + /// Like [of] but returns null instead of throwing when there is no kernel in + /// scope — lets widget-facing facades (e.g. [ClideSettings.i18n]) degrade + /// gracefully so a primitive renders in an isolated test without a kernel. + static KernelServices? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType()?.services; + @override bool updateShouldNotify(ClideKernel oldWidget) => services != oldWidget.services; } diff --git a/lib/kernel/src/i18n/catalog/core_en_us.json b/lib/kernel/src/i18n/catalog/core_en_us.json new file mode 100644 index 00000000..dc4e4b74 --- /dev/null +++ b/lib/kernel/src/i18n/catalog/core_en_us.json @@ -0,0 +1,27 @@ +{ + "collapser.expand": { "translation": "Expand" }, + "collapser.collapse": { "translation": "Collapse" }, + "collapser.expanded": { "translation": "expanded" }, + "collapser.collapsed": { "translation": "collapsed" }, + "toast.dismiss": { "translation": "Dismiss notification" }, + "lightbox.close": { "translation": "close" }, + "lightbox.hint": { "translation": "scroll to zoom · double-click to reset · Esc to close" }, + "tab.new": { "translation": "New tab" }, + "exline.notCommand": { "translation": "Not an editor command" }, + "spine.expandSuffix": { "translation": "click to expand" }, + "pane.close": { "translation": "Close pane" }, + "pane.header": { "translation": "pane header: {title}" }, + "reader.back": { "translation": "Back" }, + "reader.forward": { "translation": "Forward" }, + "reader.jumpToPin": { "translation": "Jump to pin" }, + "reader.edit": { "translation": "Edit in editor" }, + "reader.pin": { "translation": "Pin" }, + "reader.unpin": { "translation": "Unpin" }, + "link.openInEditor": { "translation": "Open in editor" }, + "resize.axis.width": { "translation": "width" }, + "resize.axis.height": { "translation": "height" }, + "resize.sidebar": { "translation": "Sidebar {axis}" }, + "resize.contextPanel": { "translation": "Context panel {axis}" }, + "resize.slot": { "translation": "{slot} {axis}" }, + "resize.pixels": { "translation": "{n} pixels" } +} diff --git a/lib/kernel/src/panels/drag_resize.dart b/lib/kernel/src/panels/drag_resize.dart index a83d6b5a..4d4b5928 100644 --- a/lib/kernel/src/panels/drag_resize.dart +++ b/lib/kernel/src/panels/drag_resize.dart @@ -1,3 +1,5 @@ +import 'package:clide/kernel/src/facade.dart' show ClideKernel; +import 'package:clide/kernel/src/i18n/i18n.dart' show I18nReplacer; import 'package:clide/kernel/src/panels/arrangement.dart'; import 'package:clide/kernel/src/panels/slot_id.dart'; import 'package:clide/kernel/src/theme/controller.dart'; @@ -43,10 +45,10 @@ class _DragResizeHandleState extends State { return Semantics( container: true, slider: true, - label: _semanticLabel(), - value: size == null ? null : '${size.round()} pixels', - increasedValue: size == null ? null : '${(size + DragResizeHandle.stepFine).round()} pixels', - decreasedValue: size == null ? null : '${(size - DragResizeHandle.stepFine).round()} pixels', + label: _semanticLabel(context), + value: size == null ? null : _pixels(context, size.round()), + increasedValue: size == null ? null : _pixels(context, (size + DragResizeHandle.stepFine).round()), + decreasedValue: size == null ? null : _pixels(context, (size - DragResizeHandle.stepFine).round()), onIncrease: () => _bump(DragResizeHandle.stepFine), onDecrease: () => _bump(-DragResizeHandle.stepFine), child: FocusableActionDetector( @@ -87,11 +89,55 @@ class _DragResizeHandleState extends State { ); } - String _semanticLabel() { - final axis = widget.axis == Axis.horizontal ? 'width' : 'height'; - if (widget.slot == Slots.sidebar) return 'Sidebar $axis'; - if (widget.slot == Slots.contextPanel) return 'Context panel $axis'; - return '${widget.slot.value} $axis'; + /// The slider's accessible name (D-21, `core` namespace). With no kernel in + /// scope (isolated tests) the i18n lookups fall back to the English + /// placeholders, mirroring the widget-facing facade's degrade behaviour. + String _semanticLabel(BuildContext context) { + final i18n = ClideKernel.maybeOf(context)?.i18n; + final axis = widget.axis == Axis.horizontal + ? (i18n?.string('resize.axis.width', namespace: 'core', placeholder: 'width') ?? 'width') + : (i18n?.string('resize.axis.height', namespace: 'core', placeholder: 'height') ?? 'height'); + if (widget.slot == Slots.sidebar) { + return i18n?.interpolated( + 'resize.sidebar', + namespace: 'core', + placeholder: 'Sidebar {axis}', + replacers: [I18nReplacer(from: '{axis}', replace: axis)], + ) ?? + 'Sidebar $axis'; + } + if (widget.slot == Slots.contextPanel) { + return i18n?.interpolated( + 'resize.contextPanel', + namespace: 'core', + placeholder: 'Context panel {axis}', + replacers: [I18nReplacer(from: '{axis}', replace: axis)], + ) ?? + 'Context panel $axis'; + } + return i18n?.interpolated( + 'resize.slot', + namespace: 'core', + placeholder: '{slot} {axis}', + replacers: [ + I18nReplacer(from: '{slot}', replace: widget.slot.value), + I18nReplacer(from: '{axis}', replace: axis), + ], + ) ?? + '${widget.slot.value} $axis'; + } + + /// `'{n} pixels'` via the `core` catalog (falls back to English with no + /// kernel in scope). + String _pixels(BuildContext context, int n) { + final i18n = ClideKernel.maybeOf(context)?.i18n; + return i18n?.interpolated( + 'resize.pixels', + namespace: 'core', + placeholder: '{n} pixels', + replacers: [I18nReplacer(from: '{n}', replace: '$n')], + ) ?? + '$n pixels'; } Map _shortcuts() { diff --git a/lib/main.dart b/lib/main.dart index b93e92d7..340548f3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -557,6 +557,9 @@ Future> _loadBundledThemes() async { /// catalogs load lazily on activate in later tiers. const List _tier0Namespaces = [ + // Framework chrome that lives outside any extension (lib/widgets, lib/kernel, + // shared reader chrome) resolves under the 'core' namespace (T-469). + 'core', 'builtin.default-layout', 'builtin.welcome', 'builtin.ipc-status', diff --git a/lib/widgets/src/clide_collapser_card.dart b/lib/widgets/src/clide_collapser_card.dart index 037924e1..8aed839c 100644 --- a/lib/widgets/src/clide_collapser_card.dart +++ b/lib/widgets/src/clide_collapser_card.dart @@ -90,7 +90,7 @@ class _ClideCollapserCardState extends State { final tokens = ClideSettings.theme.of(context).surface; return Padding( padding: const EdgeInsets.only(bottom: kClideCardGap), - child: _expanded ? _expandedFrame(tokens) : _tickerRow(tokens), + child: _expanded ? _expandedFrame(context, tokens) : _tickerRow(context, tokens), ); } @@ -98,15 +98,12 @@ class _ClideCollapserCardState extends State { /// wrapping the whole card excluded every expanded child from the a11y /// tree, so a screen-reader user could expand a run and hear nothing /// inside it (T-370). Collapsed, the header summary IS the whole card. - Widget _headerSemantics({required Widget child}) { + Widget _headerSemantics(BuildContext context, {required Widget child}) { final semanticCount = widget.counter == null ? '' : ', ${widget.counter}'; - return Semantics( - button: true, - expanded: _expanded, - label: '${widget.label}$semanticCount, ${_expanded ? 'expanded' : 'collapsed'}', - excludeSemantics: true, - child: child, - ); + final stateWord = _expanded + ? ClideSettings.i18n.string(context, 'collapser.expanded', namespace: 'core', placeholder: 'expanded') + : ClideSettings.i18n.string(context, 'collapser.collapsed', namespace: 'core', placeholder: 'collapsed'); + return Semantics(button: true, expanded: _expanded, label: '${widget.label}$semanticCount, $stateWord', excludeSemantics: true, child: child); } /// The header row content, shared by the collapsed ticker and the expanded @@ -154,11 +151,12 @@ class _ClideCollapserCardState extends State { } /// Collapsed: the ticker row IS the toggle, focusable for keyboard/AT. - Widget _tickerRow(SurfaceTokens tokens) => _headerSemantics( + Widget _tickerRow(BuildContext context, SurfaceTokens tokens) => _headerSemantics( + context, child: ClideTappable( focusNode: _controlFocus, onTap: _toggle, - tooltip: 'Expand', + tooltip: ClideSettings.i18n.string(context, 'collapser.expand', namespace: 'core', placeholder: 'Expand'), builder: (context, hovered, focused) => Container( padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV), decoration: BoxDecoration( @@ -174,7 +172,7 @@ class _ClideCollapserCardState extends State { /// Expanded: a framed inner canvas wrapping the item cards. The frame /// BACKGROUND is a gesture target behind the items that only fires for hits /// the items don't consume. - Widget _expandedFrame(SurfaceTokens tokens) => DecoratedBox( + Widget _expandedFrame(BuildContext context, SurfaceTokens tokens) => DecoratedBox( decoration: BoxDecoration( border: Border.all(color: widget.color ?? tokens.panelBorder), borderRadius: BorderRadius.circular(kClideCardRadius), @@ -187,14 +185,18 @@ class _ClideCollapserCardState extends State { Positioned.fill( child: ExcludeFocus( child: ExcludeSemantics( - child: ClideTappable(onTap: _toggle, tooltip: 'Collapse', builder: (_, _, _) => const SizedBox.expand()), + child: ClideTappable( + onTap: _toggle, + tooltip: ClideSettings.i18n.string(context, 'collapser.collapse', namespace: 'core', placeholder: 'Collapse'), + builder: (_, _, _) => const SizedBox.expand(), + ), ), ), ), Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _headerSemantics(child: _headerRow(tokens)), + _headerSemantics(context, child: _headerRow(context, tokens)), // Even padding around the inner item canvas (T-305): the sides + // top match, and each inner item carries a matching bottom margin // (so the last item's margin is the bottom inset and items in a @@ -222,10 +224,10 @@ class _ClideCollapserCardState extends State { /// The explicit, focusable collapse control in the expanded header. A /// background tap alone is not keyboard/AT reachable, so this keeps the /// control on the Tab path and Enter/Space-activatable (D-78). - Widget _headerRow(SurfaceTokens tokens) => ClideTappable( + Widget _headerRow(BuildContext context, SurfaceTokens tokens) => ClideTappable( focusNode: _controlFocus, onTap: _toggle, - tooltip: 'Collapse', + tooltip: ClideSettings.i18n.string(context, 'collapser.collapse', namespace: 'core', placeholder: 'Collapse'), builder: (context, hovered, focused) => Container( padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV), decoration: BoxDecoration( diff --git a/lib/widgets/src/clide_lightbox.dart b/lib/widgets/src/clide_lightbox.dart index 78b62b7c..b31baeb8 100644 --- a/lib/widgets/src/clide_lightbox.dart +++ b/lib/widgets/src/clide_lightbox.dart @@ -145,7 +145,12 @@ class _ClideLightboxState extends State { Positioned( top: 8, right: 8, - child: _IconChip(icon: PhosphorIcons.byName('x'), label: 'close', onTap: widget.onDismiss, tokens: tokens), + child: _IconChip( + icon: PhosphorIcons.byName('x'), + label: ClideSettings.i18n.string(context, 'lightbox.close', namespace: 'core', placeholder: 'close'), + onTap: widget.onDismiss, + tokens: tokens, + ), ), Positioned( bottom: 8, @@ -156,7 +161,16 @@ class _ClideLightboxState extends State { decoration: BoxDecoration(color: tokens.panelHeader, borderRadius: BorderRadius.circular(4)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - child: ClideText('scroll to zoom · double-click to reset · Esc to close', fontSize: clideFontMeta, color: tokens.globalTextMuted), + child: ClideText( + ClideSettings.i18n.string( + context, + 'lightbox.hint', + namespace: 'core', + placeholder: 'scroll to zoom · double-click to reset · Esc to close', + ), + fontSize: clideFontMeta, + color: tokens.globalTextMuted, + ), ), ), ), diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart index 1d8a7086..bda31c82 100644 --- a/lib/widgets/src/clide_markdown.dart +++ b/lib/widgets/src/clide_markdown.dart @@ -43,6 +43,7 @@ class ClideMarkdownHooks { this.onOpenFile, this.mono = clideMonoFamily, this.ui = clideUiFamily, + this.openInEditor = 'Open in editor', }); /// Tap a governance/ticket ref (T-281, D-77, …) → open the record (T-279). @@ -72,6 +73,11 @@ class ClideMarkdownHooks { /// pinning the bundled default (T-475). final String ui; + /// The resolved 'Open in editor' tooltip for file-ref link spans, threaded + /// the same way as [mono]/[ui] so the context-free static span builders can + /// use the i18n catalog string (D-21, `core` namespace) instead of a literal. + final String openInEditor; + static const none = ClideMarkdownHooks(); } @@ -137,6 +143,7 @@ class ClideMarkdown extends StatelessWidget { onOpenFile: onOpenFile, mono: ClideSettings.fonts.monoOf(context), ui: ClideSettings.fonts.uiOf(context), + openInEditor: ClideSettings.i18n.string(context, 'link.openInEditor', namespace: 'core', placeholder: 'Open in editor'), ); final widgets = _buildNodes(nodes, tokens, hooks); return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: widgets); @@ -393,7 +400,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, hooks.ui, mono: true); + if (ref != null) return _fileLinkSpan(raw, ref.$1, ref.$2, tokens, hooks.onOpenFile!, hooks.mono, hooks.ui, hooks.openInEditor, mono: true); } return TextSpan( text: raw, @@ -415,7 +422,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, hooks.ui); + if (abs != null) return _fileLinkSpan(text, abs, line, tokens, hooks.onOpenFile!, hooks.mono, hooks.ui, hooks.openInEditor); } return TextSpan( text: text, @@ -496,7 +503,13 @@ 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, hooks.ui))); + hits.add( + _LinkHit( + m.start, + m.end, + _fileLinkSpan(text.substring(m.start, m.end), abs, line, tokens, hooks.onOpenFile!, hooks.mono, hooks.ui, hooks.openInEditor), + ), + ); } } if (hits.isEmpty) return [TextSpan(text: text)]; @@ -575,7 +588,8 @@ class ClideMarkdown extends StatelessWidget { SurfaceTokens tokens, FileTapCallback onOpenFile, String monoFamily, - String uiFamily, { + String uiFamily, + String openInEditor, { bool mono = false, }) { return WidgetSpan( @@ -583,7 +597,7 @@ class ClideMarkdown extends StatelessWidget { baseline: TextBaseline.alphabetic, child: ClideTappable( onTap: () => onOpenFile(absPath, line), - tooltip: 'Open in editor', + tooltip: openInEditor, builder: (_, hovered, _) => Text( display, style: TextStyle( diff --git a/lib/widgets/src/clide_pane_chrome.dart b/lib/widgets/src/clide_pane_chrome.dart index 3105a3a6..b4fbf273 100644 --- a/lib/widgets/src/clide_pane_chrome.dart +++ b/lib/widgets/src/clide_pane_chrome.dart @@ -60,13 +60,14 @@ class _CloseButton extends StatelessWidget { @override Widget build(BuildContext context) { final tokens = ClideSettings.theme.of(context).surface; + final closeLabel = ClideSettings.i18n.string(context, 'pane.close', namespace: 'core', placeholder: 'Close pane'); return Semantics( button: true, - label: 'Close pane', + label: closeLabel, onTap: onPressed, child: ClideTappable( onTap: onPressed, - tooltip: 'Close pane', + tooltip: closeLabel, builder: (context, hovered, _) => Container( width: 20, height: 20, @@ -94,7 +95,13 @@ class _Header extends StatelessWidget { return Semantics( container: true, explicitChildNodes: true, - label: 'pane header: $title', + label: ClideSettings.i18n.interpolated( + context, + 'pane.header', + namespace: 'core', + placeholder: 'pane header: {title}', + replacers: [I18nReplacer(from: '{title}', replace: title)], + ), child: ColoredBox( color: tokens.panelHeader, child: Padding( diff --git a/lib/widgets/src/clide_settings.dart b/lib/widgets/src/clide_settings.dart index 4af510f7..09679b7a 100644 --- a/lib/widgets/src/clide_settings.dart +++ b/lib/widgets/src/clide_settings.dart @@ -52,13 +52,23 @@ class _I18n { /// Resolve a catalog string through the live i18n service (D-21) — the /// uniform widget-facing lookup (T-462). [placeholder] is the inline English - /// fallback rendered until/unless the catalog covers the key. - String string(BuildContext context, String key, {required String namespace, String? placeholder}) => - of(context).string(key, namespace: namespace, placeholder: placeholder); + /// fallback. With no kernel in scope (isolated primitive tests) it returns + /// the placeholder, so a widget never needs one to render. + String string(BuildContext context, String key, {required String namespace, String? placeholder}) { + final i = ClideKernel.maybeOf(context)?.i18n; + return i == null ? (placeholder ?? key) : i.string(key, namespace: namespace, placeholder: placeholder); + } /// [string] with `replaceAll` interpolation per replacer (templated labels). - String interpolated(BuildContext context, String key, {required String namespace, String? placeholder, List replacers = const []}) => - of(context).interpolated(key, namespace: namespace, placeholder: placeholder, replacers: replacers); + String interpolated(BuildContext context, String key, {required String namespace, String? placeholder, List replacers = const []}) { + final i = ClideKernel.maybeOf(context)?.i18n; + if (i != null) return i.interpolated(key, namespace: namespace, placeholder: placeholder, replacers: replacers); + var out = placeholder ?? key; + for (final r in replacers) { + out = out.replaceAll(r.from, r.replace); + } + return out; + } } /// Root-provided InheritedWidget carrying the live font families (D-101). The diff --git a/lib/widgets/src/clide_spine.dart b/lib/widgets/src/clide_spine.dart index 26f6b31a..81d96ffc 100644 --- a/lib/widgets/src/clide_spine.dart +++ b/lib/widgets/src/clide_spine.dart @@ -19,9 +19,10 @@ class ClideSpine extends StatelessWidget { final tokens = ClideSettings.theme.of(context).surface; final borderSide = BorderSide(color: tokens.dividerColor); + final expandSuffix = ClideSettings.i18n.string(context, 'spine.expandSuffix', namespace: 'core', placeholder: 'click to expand'); return Semantics( button: true, - label: '$label — click to expand', + label: '$label — $expandSuffix', child: ClideTappable( onTap: onExpand, builder: (context, hovered, _) => Container( diff --git a/lib/widgets/src/clide_toast.dart b/lib/widgets/src/clide_toast.dart index 3ce4ccf0..d47ee09c 100644 --- a/lib/widgets/src/clide_toast.dart +++ b/lib/widgets/src/clide_toast.dart @@ -86,7 +86,7 @@ class _ClideToastState extends State { const SizedBox(width: clideGapStandard), Semantics( button: true, - label: 'Dismiss notification', + label: ClideSettings.i18n.string(context, 'toast.dismiss', namespace: 'core', placeholder: 'Dismiss notification'), child: ClideTappable( onTap: widget.onDismiss, builder: (ctx, hovered, _) => diff --git a/lib/widgets/src/ex_line_overlay.dart b/lib/widgets/src/ex_line_overlay.dart index 0a1c4f0f..ed7ecce3 100644 --- a/lib/widgets/src/ex_line_overlay.dart +++ b/lib/widgets/src/ex_line_overlay.dart @@ -181,7 +181,11 @@ class _ExLineOverlayState extends State { if (_rejected) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: ClideText('Not an editor command', fontSize: clideFontCaption, color: tokens.statusError), + child: ClideText( + ClideSettings.i18n.string(context, 'exline.notCommand', namespace: 'core', placeholder: 'Not an editor command'), + fontSize: clideFontCaption, + color: tokens.statusError, + ), ), ], ), diff --git a/lib/widgets/src/multitab_pane.dart b/lib/widgets/src/multitab_pane.dart index df044995..35aa87aa 100644 --- a/lib/widgets/src/multitab_pane.dart +++ b/lib/widgets/src/multitab_pane.dart @@ -346,7 +346,7 @@ class _AddButton extends StatelessWidget { final tokens = ClideSettings.theme.of(context).surface; return Semantics( button: true, - label: 'New tab', + label: ClideSettings.i18n.string(context, 'tab.new', namespace: 'core', placeholder: 'New tab'), excludeSemantics: true, child: ClideTappable( onTap: onTap,