refactor(i18n): route framework + shared chrome through a 'core' catalog (T-469)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 ('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 ('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 ('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;
|
||||||
|
|||||||
@@ -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.
|
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);
|
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);
|
||||||
|
|||||||
@@ -50,22 +50,40 @@ class ReaderActionBar extends StatelessWidget {
|
|||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
const SizedBox(width: 2),
|
||||||
_ActionButton(
|
_ActionButton(
|
||||||
painter: PhosphorIcons.byName('caret-right'),
|
painter: PhosphorIcons.byName('caret-right'),
|
||||||
tooltip: 'Forward',
|
tooltip: ClideSettings.i18n.string(context, 'reader.forward', namespace: 'core', placeholder: 'Forward'),
|
||||||
enabled: canGoForward,
|
enabled: canGoForward,
|
||||||
onTap: canGoForward ? onForward : null,
|
onTap: canGoForward ? onForward : null,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
),
|
),
|
||||||
if (hasPinned) ...[
|
if (hasPinned) ...[
|
||||||
const SizedBox(width: 2),
|
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) ...[
|
if (onEdit != null) ...[
|
||||||
const SizedBox(width: 4),
|
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) {
|
Widget build(BuildContext context) {
|
||||||
return _ActionButton(
|
return _ActionButton(
|
||||||
painter: PhosphorIcons.byName('push-pin'),
|
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,
|
enabled: onTap != null,
|
||||||
active: pinned,
|
active: pinned,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
|||||||
@@ -345,6 +345,11 @@ class ClideKernel extends InheritedWidget {
|
|||||||
return w.services;
|
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<ClideKernel>()?.services;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool updateShouldNotify(ClideKernel oldWidget) => services != oldWidget.services;
|
bool updateShouldNotify(ClideKernel oldWidget) => services != oldWidget.services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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/arrangement.dart';
|
||||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||||
import 'package:clide/kernel/src/theme/controller.dart';
|
import 'package:clide/kernel/src/theme/controller.dart';
|
||||||
@@ -43,10 +45,10 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
|||||||
return Semantics(
|
return Semantics(
|
||||||
container: true,
|
container: true,
|
||||||
slider: true,
|
slider: true,
|
||||||
label: _semanticLabel(),
|
label: _semanticLabel(context),
|
||||||
value: size == null ? null : '${size.round()} pixels',
|
value: size == null ? null : _pixels(context, size.round()),
|
||||||
increasedValue: size == null ? null : '${(size + DragResizeHandle.stepFine).round()} pixels',
|
increasedValue: size == null ? null : _pixels(context, (size + DragResizeHandle.stepFine).round()),
|
||||||
decreasedValue: size == null ? null : '${(size - DragResizeHandle.stepFine).round()} pixels',
|
decreasedValue: size == null ? null : _pixels(context, (size - DragResizeHandle.stepFine).round()),
|
||||||
onIncrease: () => _bump(DragResizeHandle.stepFine),
|
onIncrease: () => _bump(DragResizeHandle.stepFine),
|
||||||
onDecrease: () => _bump(-DragResizeHandle.stepFine),
|
onDecrease: () => _bump(-DragResizeHandle.stepFine),
|
||||||
child: FocusableActionDetector(
|
child: FocusableActionDetector(
|
||||||
@@ -87,11 +89,55 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _semanticLabel() {
|
/// The slider's accessible name (D-21, `core` namespace). With no kernel in
|
||||||
final axis = widget.axis == Axis.horizontal ? 'width' : 'height';
|
/// scope (isolated tests) the i18n lookups fall back to the English
|
||||||
if (widget.slot == Slots.sidebar) return 'Sidebar $axis';
|
/// placeholders, mirroring the widget-facing facade's degrade behaviour.
|
||||||
if (widget.slot == Slots.contextPanel) return 'Context panel $axis';
|
String _semanticLabel(BuildContext context) {
|
||||||
return '${widget.slot.value} $axis';
|
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<ShortcutActivator, Intent> _shortcuts() {
|
Map<ShortcutActivator, Intent> _shortcuts() {
|
||||||
|
|||||||
@@ -557,6 +557,9 @@ Future<List<ThemeDefinition>> _loadBundledThemes() async {
|
|||||||
/// catalogs load lazily on activate in later tiers.
|
/// catalogs load lazily on activate in later tiers.
|
||||||
|
|
||||||
const List<String> _tier0Namespaces = [
|
const List<String> _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.default-layout',
|
||||||
'builtin.welcome',
|
'builtin.welcome',
|
||||||
'builtin.ipc-status',
|
'builtin.ipc-status',
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
|||||||
final tokens = ClideSettings.theme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: kClideCardGap),
|
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<ClideCollapserCard> {
|
|||||||
/// wrapping the whole card excluded every expanded child from the a11y
|
/// wrapping the whole card excluded every expanded child from the a11y
|
||||||
/// tree, so a screen-reader user could expand a run and hear nothing
|
/// 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.
|
/// 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}';
|
final semanticCount = widget.counter == null ? '' : ', ${widget.counter}';
|
||||||
return Semantics(
|
final stateWord = _expanded
|
||||||
button: true,
|
? ClideSettings.i18n.string(context, 'collapser.expanded', namespace: 'core', placeholder: 'expanded')
|
||||||
expanded: _expanded,
|
: ClideSettings.i18n.string(context, 'collapser.collapsed', namespace: 'core', placeholder: 'collapsed');
|
||||||
label: '${widget.label}$semanticCount, ${_expanded ? 'expanded' : 'collapsed'}',
|
return Semantics(button: true, expanded: _expanded, label: '${widget.label}$semanticCount, $stateWord', excludeSemantics: true, child: child);
|
||||||
excludeSemantics: true,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The header row content, shared by the collapsed ticker and the expanded
|
/// The header row content, shared by the collapsed ticker and the expanded
|
||||||
@@ -154,11 +151,12 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Collapsed: the ticker row IS the toggle, focusable for keyboard/AT.
|
/// 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(
|
child: ClideTappable(
|
||||||
focusNode: _controlFocus,
|
focusNode: _controlFocus,
|
||||||
onTap: _toggle,
|
onTap: _toggle,
|
||||||
tooltip: 'Expand',
|
tooltip: ClideSettings.i18n.string(context, 'collapser.expand', namespace: 'core', placeholder: 'Expand'),
|
||||||
builder: (context, hovered, focused) => Container(
|
builder: (context, hovered, focused) => Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -174,7 +172,7 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
|||||||
/// Expanded: a framed inner canvas wrapping the item cards. The frame
|
/// Expanded: a framed inner canvas wrapping the item cards. The frame
|
||||||
/// BACKGROUND is a gesture target behind the items that only fires for hits
|
/// BACKGROUND is a gesture target behind the items that only fires for hits
|
||||||
/// the items don't consume.
|
/// the items don't consume.
|
||||||
Widget _expandedFrame(SurfaceTokens tokens) => DecoratedBox(
|
Widget _expandedFrame(BuildContext context, SurfaceTokens tokens) => DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: widget.color ?? tokens.panelBorder),
|
border: Border.all(color: widget.color ?? tokens.panelBorder),
|
||||||
borderRadius: BorderRadius.circular(kClideCardRadius),
|
borderRadius: BorderRadius.circular(kClideCardRadius),
|
||||||
@@ -187,14 +185,18 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
|||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: ExcludeFocus(
|
child: ExcludeFocus(
|
||||||
child: ExcludeSemantics(
|
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(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_headerSemantics(child: _headerRow(tokens)),
|
_headerSemantics(context, child: _headerRow(context, tokens)),
|
||||||
// Even padding around the inner item canvas (T-305): the sides +
|
// Even padding around the inner item canvas (T-305): the sides +
|
||||||
// top match, and each inner item carries a matching bottom margin
|
// 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
|
// (so the last item's margin is the bottom inset and items in a
|
||||||
@@ -222,10 +224,10 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
|||||||
/// The explicit, focusable collapse control in the expanded header. A
|
/// The explicit, focusable collapse control in the expanded header. A
|
||||||
/// background tap alone is not keyboard/AT reachable, so this keeps the
|
/// background tap alone is not keyboard/AT reachable, so this keeps the
|
||||||
/// control on the Tab path and Enter/Space-activatable (D-78).
|
/// 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,
|
focusNode: _controlFocus,
|
||||||
onTap: _toggle,
|
onTap: _toggle,
|
||||||
tooltip: 'Collapse',
|
tooltip: ClideSettings.i18n.string(context, 'collapser.collapse', namespace: 'core', placeholder: 'Collapse'),
|
||||||
builder: (context, hovered, focused) => Container(
|
builder: (context, hovered, focused) => Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -145,7 +145,12 @@ class _ClideLightboxState extends State<ClideLightbox> {
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 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(
|
Positioned(
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
@@ -156,7 +161,16 @@ class _ClideLightboxState extends State<ClideLightbox> {
|
|||||||
decoration: BoxDecoration(color: tokens.panelHeader, borderRadius: BorderRadius.circular(4)),
|
decoration: BoxDecoration(color: tokens.panelHeader, borderRadius: BorderRadius.circular(4)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class ClideMarkdownHooks {
|
|||||||
this.onOpenFile,
|
this.onOpenFile,
|
||||||
this.mono = clideMonoFamily,
|
this.mono = clideMonoFamily,
|
||||||
this.ui = clideUiFamily,
|
this.ui = clideUiFamily,
|
||||||
|
this.openInEditor = 'Open in editor',
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Tap a governance/ticket ref (T-281, D-77, …) → open the record (T-279).
|
/// 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).
|
/// pinning the bundled default (T-475).
|
||||||
final String ui;
|
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();
|
static const none = ClideMarkdownHooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +143,7 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
onOpenFile: onOpenFile,
|
onOpenFile: onOpenFile,
|
||||||
mono: ClideSettings.fonts.monoOf(context),
|
mono: ClideSettings.fonts.monoOf(context),
|
||||||
ui: ClideSettings.fonts.uiOf(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);
|
final widgets = _buildNodes(nodes, tokens, hooks);
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: widgets);
|
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.
|
// editor (T-300); other inline code renders verbatim.
|
||||||
if (hooks.resolveFileRef != null && hooks.onOpenFile != null) {
|
if (hooks.resolveFileRef != null && hooks.onOpenFile != null) {
|
||||||
final ref = _codeFileRef(raw, hooks.resolveFileRef!);
|
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(
|
return TextSpan(
|
||||||
text: raw,
|
text: raw,
|
||||||
@@ -415,7 +422,7 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
if (hooks.resolveFileRef != null && hooks.onOpenFile != null && href != null) {
|
if (hooks.resolveFileRef != null && hooks.onOpenFile != null && href != null) {
|
||||||
final (path, line) = _splitFileRef(href);
|
final (path, line) = _splitFileRef(href);
|
||||||
final abs = hooks.resolveFileRef!(path);
|
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(
|
return TextSpan(
|
||||||
text: text,
|
text: text,
|
||||||
@@ -496,7 +503,13 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
final abs = hooks.resolveFileRef!(m.group(1)!);
|
final abs = hooks.resolveFileRef!(m.group(1)!);
|
||||||
if (abs == null) continue;
|
if (abs == null) continue;
|
||||||
final line = m.group(2) == null ? null : int.tryParse(m.group(2)!);
|
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)];
|
if (hits.isEmpty) return [TextSpan(text: text)];
|
||||||
@@ -575,7 +588,8 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
SurfaceTokens tokens,
|
SurfaceTokens tokens,
|
||||||
FileTapCallback onOpenFile,
|
FileTapCallback onOpenFile,
|
||||||
String monoFamily,
|
String monoFamily,
|
||||||
String uiFamily, {
|
String uiFamily,
|
||||||
|
String openInEditor, {
|
||||||
bool mono = false,
|
bool mono = false,
|
||||||
}) {
|
}) {
|
||||||
return WidgetSpan(
|
return WidgetSpan(
|
||||||
@@ -583,7 +597,7 @@ class ClideMarkdown extends StatelessWidget {
|
|||||||
baseline: TextBaseline.alphabetic,
|
baseline: TextBaseline.alphabetic,
|
||||||
child: ClideTappable(
|
child: ClideTappable(
|
||||||
onTap: () => onOpenFile(absPath, line),
|
onTap: () => onOpenFile(absPath, line),
|
||||||
tooltip: 'Open in editor',
|
tooltip: openInEditor,
|
||||||
builder: (_, hovered, _) => Text(
|
builder: (_, hovered, _) => Text(
|
||||||
display,
|
display,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|||||||
@@ -60,13 +60,14 @@ class _CloseButton extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideSettings.theme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
final closeLabel = ClideSettings.i18n.string(context, 'pane.close', namespace: 'core', placeholder: 'Close pane');
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'Close pane',
|
label: closeLabel,
|
||||||
onTap: onPressed,
|
onTap: onPressed,
|
||||||
child: ClideTappable(
|
child: ClideTappable(
|
||||||
onTap: onPressed,
|
onTap: onPressed,
|
||||||
tooltip: 'Close pane',
|
tooltip: closeLabel,
|
||||||
builder: (context, hovered, _) => Container(
|
builder: (context, hovered, _) => Container(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -94,7 +95,13 @@ class _Header extends StatelessWidget {
|
|||||||
return Semantics(
|
return Semantics(
|
||||||
container: true,
|
container: true,
|
||||||
explicitChildNodes: 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(
|
child: ColoredBox(
|
||||||
color: tokens.panelHeader,
|
color: tokens.panelHeader,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -52,13 +52,23 @@ class _I18n {
|
|||||||
|
|
||||||
/// Resolve a catalog string through the live i18n service (D-21) — the
|
/// Resolve a catalog string through the live i18n service (D-21) — the
|
||||||
/// uniform widget-facing lookup (T-462). [placeholder] is the inline English
|
/// uniform widget-facing lookup (T-462). [placeholder] is the inline English
|
||||||
/// fallback rendered until/unless the catalog covers the key.
|
/// fallback. With no kernel in scope (isolated primitive tests) it returns
|
||||||
String string(BuildContext context, String key, {required String namespace, String? placeholder}) =>
|
/// the placeholder, so a widget never needs one to render.
|
||||||
of(context).string(key, namespace: namespace, placeholder: placeholder);
|
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] with `replaceAll` interpolation per replacer (templated labels).
|
||||||
String interpolated(BuildContext context, String key, {required String namespace, String? placeholder, List<I18nReplacer> replacers = const []}) =>
|
String interpolated(BuildContext context, String key, {required String namespace, String? placeholder, List<I18nReplacer> replacers = const []}) {
|
||||||
of(context).interpolated(key, namespace: namespace, placeholder: placeholder, replacers: replacers);
|
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
|
/// Root-provided InheritedWidget carrying the live font families (D-101). The
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ class ClideSpine extends StatelessWidget {
|
|||||||
final tokens = ClideSettings.theme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
final borderSide = BorderSide(color: tokens.dividerColor);
|
final borderSide = BorderSide(color: tokens.dividerColor);
|
||||||
|
|
||||||
|
final expandSuffix = ClideSettings.i18n.string(context, 'spine.expandSuffix', namespace: 'core', placeholder: 'click to expand');
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: '$label — click to expand',
|
label: '$label — $expandSuffix',
|
||||||
child: ClideTappable(
|
child: ClideTappable(
|
||||||
onTap: onExpand,
|
onTap: onExpand,
|
||||||
builder: (context, hovered, _) => Container(
|
builder: (context, hovered, _) => Container(
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class _ClideToastState extends State<ClideToast> {
|
|||||||
const SizedBox(width: clideGapStandard),
|
const SizedBox(width: clideGapStandard),
|
||||||
Semantics(
|
Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'Dismiss notification',
|
label: ClideSettings.i18n.string(context, 'toast.dismiss', namespace: 'core', placeholder: 'Dismiss notification'),
|
||||||
child: ClideTappable(
|
child: ClideTappable(
|
||||||
onTap: widget.onDismiss,
|
onTap: widget.onDismiss,
|
||||||
builder: (ctx, hovered, _) =>
|
builder: (ctx, hovered, _) =>
|
||||||
|
|||||||
@@ -181,7 +181,11 @@ class _ExLineOverlayState extends State<ExLineOverlay> {
|
|||||||
if (_rejected)
|
if (_rejected)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ class _AddButton extends StatelessWidget {
|
|||||||
final tokens = ClideSettings.theme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'New tab',
|
label: ClideSettings.i18n.string(context, 'tab.new', namespace: 'core', placeholder: 'New tab'),
|
||||||
excludeSemantics: true,
|
excludeSemantics: true,
|
||||||
child: ClideTappable(
|
child: ClideTappable(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
|||||||
Reference in New Issue
Block a user