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:
@@ -90,7 +90,7 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
||||
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<ClideCollapserCard> {
|
||||
/// 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<ClideCollapserCard> {
|
||||
}
|
||||
|
||||
/// 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<ClideCollapserCard> {
|
||||
/// 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<ClideCollapserCard> {
|
||||
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<ClideCollapserCard> {
|
||||
/// 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(
|
||||
|
||||
@@ -145,7 +145,12 @@ class _ClideLightboxState extends State<ClideLightbox> {
|
||||
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<ClideLightbox> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<I18nReplacer> replacers = const []}) =>
|
||||
of(context).interpolated(key, namespace: namespace, placeholder: placeholder, replacers: replacers);
|
||||
String interpolated(BuildContext context, String key, {required String namespace, String? placeholder, List<I18nReplacer> 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -86,7 +86,7 @@ class _ClideToastState extends State<ClideToast> {
|
||||
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, _) =>
|
||||
|
||||
@@ -181,7 +181,11 @@ class _ExLineOverlayState extends State<ExLineOverlay> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user