refactor(i18n): route planning-pane labels through the catalog (T-467)

Migrate the user-facing strings in builtin.tickets, builtin.pql,
builtin.decisions, and builtin.problems (list/detail views, section headers,
filters, empty/loading/error states, type chips) to ClideSettings.i18n; extend
each extension's catalog. The detail/links tabs that had a bare title now wire
titleKey + i18nNamespace so the tab title resolves too. Context threaded into
the static label helpers. No en_US behaviour change (D-21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 22:14:01 +02:00
co-authored by Claude Opus 4.8
parent f33cd603be
commit 17d22b9571
16 changed files with 337 additions and 64 deletions
@@ -5941,3 +5941,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 ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'status', 'backlog', 'in_progress', NULL, '2026-06-18 13:52:52', '2026-06-18 13:52:52.932', '2026-06-18 13:52:52.932', NULL, 'f0b77fdf0ef68d872155912ef3882cb1', 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 ('06FDA0WCPG458GF5FXTXM9VY98', 'status', 'in_progress', 'done', NULL, '2026-06-18 19:14:41', '2026-06-18 19:14:41.116', '2026-06-18 19:14:41.116', NULL, '2bf6c6fbef7a2e950fc1f31541f8dc22', 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;
+5
View File
@@ -7343,3 +7343,8 @@ Hotspots from the audit:
- lib/builtin/diff/src/diff_view.dart:105 ''No staged changes.''/''No unstaged changes.'', :153 ''Unstaged'', :163 ''Staged''
Acceptance: no hardcoded user-facing literals in lib/builtin/git/ or lib/builtin/diff/; keys added to builtin.git and builtin.diff catalogs. Depends on T-463 for the builtin.diff catalog file.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:30.322', '2026-06-18 19:19:25.268', NULL, 'c14bcdd5a816e86b78af9b5ac795b8a7', 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 ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate planning-pane labels — tickets, pql, decisions, problems (~21 strings)', 'The four pql/planning-surface extensions each have a tab.title-only catalog and hardcode the rest: tickets ~6, pql ~6, decisions ~6, problems ~3. Sweep all four, extend each catalog.
For each: route hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(..., namespace: ''builtin.<ext>'', placeholder: <current literal>) and add keys.
Acceptance: no hardcoded user-facing literals remain in lib/builtin/{tickets,pql,decisions,problems}/; keys present in each respective catalog.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:36.624', '2026-06-18 20:13:50.352', NULL, '919efbfb38b655d5157f73572aa20fad', 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);
@@ -88,9 +88,21 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
@override
Widget build(BuildContext context) {
if (_loading) return const Center(child: ClideText('Loading…', muted: true));
if (_loading) {
return Center(
child: ClideText(ClideSettings.i18n.string(context, 'detail.loading', namespace: 'builtin.decisions', placeholder: 'Loading…'), muted: true),
);
}
final d = _decision;
if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a decision to view details.', muted: true));
if (d == null) {
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'detail.empty', namespace: 'builtin.decisions', placeholder: 'Select a decision to view details.'),
muted: true,
),
);
}
final tokens = ClideSettings.theme.of(context).surface;
final isDark = ClideSettings.theme.of(context).dark;
@@ -171,7 +183,12 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
if (body != null && body.isNotEmpty) ...[const SizedBox(height: 12), ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id))],
if (refs.isNotEmpty) ...[
const SizedBox(height: 16),
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
ClideText(
ClideSettings.i18n.string(context, 'detail.section.refs', namespace: 'builtin.decisions', placeholder: 'CROSS-REFERENCES'),
fontSize: clideFontSmall,
color: tokens.sidebarSectionHeader,
fontFamily: ClideSettings.fonts.monoOf(context),
),
const SizedBox(height: 6),
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
],
+29 -9
View File
@@ -74,7 +74,8 @@ class _DecisionsViewState extends State<DecisionsView> {
if (!mounted) return;
if (!resp.ok) {
setState(() {
_error = resp.error?.message ?? 'failed to load decisions';
_error =
resp.error?.message ?? ClideSettings.i18n.string(context, 'error.load', namespace: 'builtin.decisions', placeholder: 'failed to load decisions');
_loading = false;
});
return;
@@ -129,10 +130,20 @@ class _DecisionsViewState extends State<DecisionsView> {
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
if (_loading) return const Center(child: ClideText('Loading decisions...', muted: true));
if (_loading) {
return Center(
child: ClideText(ClideSettings.i18n.string(context, 'loading', namespace: 'builtin.decisions', placeholder: 'Loading decisions...'), muted: true),
);
}
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
if (_decisions.isEmpty) {
return const Padding(padding: EdgeInsets.all(12), child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true));
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.decisions', placeholder: 'No decisions found.\nRun `pql decisions sync` to index.'),
muted: true,
),
);
}
final lf = _filter.toLowerCase();
@@ -161,13 +172,17 @@ class _DecisionsViewState extends State<DecisionsView> {
Row(
children: [
Expanded(
child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)),
child: ClideFilterBox(
address: 'decisions.panel',
hint: ClideSettings.i18n.string(context, 'filter.hint', namespace: 'builtin.decisions', placeholder: 'Filter decisions…'),
onChanged: (v) => setState(() => _filter = v),
),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideTappable(
onTap: _refreshing ? null : _refresh,
tooltip: 'Refresh decisions',
tooltip: ClideSettings.i18n.string(context, 'refresh.tooltip', namespace: 'builtin.decisions', placeholder: 'Refresh decisions'),
builder: (ctx, hovered, _) =>
ClideIcon(PhosphorIcons.byName('arrow-clockwise'), size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
@@ -182,7 +197,7 @@ class _DecisionsViewState extends State<DecisionsView> {
children: [
if (confirmed.isNotEmpty)
ClideAccordion(
label: 'CONFIRMED',
label: ClideSettings.i18n.string(context, 'section.confirmed', namespace: 'builtin.decisions', placeholder: 'CONFIRMED'),
count: confirmed.length,
leading: Container(
width: 8,
@@ -204,7 +219,7 @@ class _DecisionsViewState extends State<DecisionsView> {
),
if (questions.isNotEmpty)
ClideAccordion(
label: 'QUESTIONS',
label: ClideSettings.i18n.string(context, 'section.questions', namespace: 'builtin.decisions', placeholder: 'QUESTIONS'),
count: questions.length,
leading: Container(
width: 8,
@@ -226,7 +241,7 @@ class _DecisionsViewState extends State<DecisionsView> {
),
if (rejected.isNotEmpty)
ClideAccordion(
label: 'REJECTED',
label: ClideSettings.i18n.string(context, 'section.rejected', namespace: 'builtin.decisions', placeholder: 'REJECTED'),
count: rejected.length,
leading: Container(
width: 8,
@@ -322,7 +337,12 @@ class _DecisionCard extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: tokens.statusSuccess.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
child: ClideText('resolved', fontSize: clideFontBadge, color: tokens.statusSuccess, fontFamily: ClideSettings.fonts.monoOf(context)),
child: ClideText(
ClideSettings.i18n.string(context, 'badge.resolved', namespace: 'builtin.decisions', placeholder: 'resolved'),
fontSize: clideFontBadge,
color: tokens.statusSuccess,
fontFamily: ClideSettings.fonts.monoOf(context),
),
),
],
],
+2
View File
@@ -50,6 +50,8 @@ class DecisionsExtension extends ClideExtension {
id: 'decisions.detail',
slot: Slots.contextPanel,
title: 'Decision',
titleKey: 'tab.detail.title',
i18nNamespace: id,
icon: PhosphorIcons.byName('lightbulb'),
build: (_) => const DecisionDetailView(),
),
+50 -9
View File
@@ -42,10 +42,22 @@ class _BacklinksViewState extends State<BacklinksView> {
builder: (context, _) {
final tokens = ClideSettings.theme.of(context).surface;
if (c.activePath == null) {
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Open a file to see its links.', muted: true));
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'backlinks.empty', namespace: 'builtin.pql', placeholder: 'Open a file to see its links.'),
muted: true,
),
);
}
return Semantics(
label: 'backlinks for ${c.activePath}',
label: ClideSettings.i18n.interpolated(
context,
'backlinks.semantics',
namespace: 'builtin.pql',
placeholder: 'backlinks for {path}',
replacers: [I18nReplacer(from: '{path}', replace: c.activePath!)],
),
container: true,
explicitChildNodes: true,
child: SingleChildScrollView(
@@ -63,9 +75,21 @@ class _BacklinksViewState extends State<BacklinksView> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption),
),
if (c.loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
_LinkGroup(label: 'Backlinks', links: c.backlinks, pathKey: 'source'),
_LinkGroup(label: 'Outlinks', links: c.outlinks, pathKey: 'target'),
if (c.loading)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(ClideSettings.i18n.string(context, 'backlinks.loading', namespace: 'builtin.pql', placeholder: 'Loading…'), muted: true),
),
_LinkGroup(
label: ClideSettings.i18n.string(context, 'group.backlinks', namespace: 'builtin.pql', placeholder: 'Backlinks'),
links: c.backlinks,
pathKey: 'source',
),
_LinkGroup(
label: ClideSettings.i18n.string(context, 'group.outlinks', namespace: 'builtin.pql', placeholder: 'Outlinks'),
links: c.outlinks,
pathKey: 'target',
),
],
),
),
@@ -90,12 +114,29 @@ class _LinkGroup extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
child: ClideText('$label (${links.length})', fontSize: clideFontCaption, muted: true),
child: ClideText(
ClideSettings.i18n.interpolated(
context,
'group.label',
namespace: 'builtin.pql',
placeholder: '{label} ({count})',
replacers: [
I18nReplacer(from: '{label}', replace: label),
I18nReplacer(from: '{count}', replace: '${links.length}'),
],
),
fontSize: clideFontCaption,
muted: true,
),
),
if (links.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2),
child: ClideText('None', fontSize: clideFontCaption, muted: true),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
child: ClideText(
ClideSettings.i18n.string(context, 'group.none', namespace: 'builtin.pql', placeholder: 'None'),
fontSize: clideFontCaption,
muted: true,
),
),
for (final link in links) _LinkRow(link: link, pathKey: pathKey),
],
+2
View File
@@ -21,6 +21,8 @@ class PqlExtension extends ClideExtension {
id: 'pql.backlinks',
slot: Slots.contextPanel,
title: 'Links',
titleKey: 'tab.links.title',
i18nNamespace: id,
icon: PhosphorIcons.byName('link'),
priority: -80,
build: (_) => const BacklinksView(),
+16 -4
View File
@@ -104,7 +104,7 @@ class _PqlSearchBodyState extends State<PqlSearchBody> {
if (widget.mode == PqlPaneMode.markdown)
ClideFilterBox(
address: 'search.pql.markdown',
hint: 'Filter markdown…',
hint: ClideSettings.i18n.string(context, 'filter.markdown.hint', namespace: 'builtin.pql', placeholder: 'Filter markdown…'),
onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')),
)
else
@@ -118,9 +118,19 @@ class _PqlSearchBodyState extends State<PqlSearchBody> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
),
if (c.loading && c.results.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (c.loading && c.results.isEmpty)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(ClideSettings.i18n.string(context, 'loading', namespace: 'builtin.pql', placeholder: 'Loading…'), muted: true),
),
if (!c.loading && c.results.isEmpty && c.error == null && widget.mode == PqlPaneMode.markdown)
const Padding(padding: EdgeInsets.all(12), child: ClideText('No markdown files found.', muted: true)),
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'empty.markdown', namespace: 'builtin.pql', placeholder: 'No markdown files found.'),
muted: true,
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -160,7 +170,9 @@ class _PqlSearchInput extends StatelessWidget {
Widget build(BuildContext context) {
return ClideFilterBox(
address: address,
hint: dsl ? 'PQL query…' : 'Search vault…',
hint: dsl
? ClideSettings.i18n.string(context, 'search.query.hint', namespace: 'builtin.pql', placeholder: 'PQL query…')
: ClideSettings.i18n.string(context, 'search.vault.hint', namespace: 'builtin.pql', placeholder: 'Search vault…'),
debounce: dsl ? Duration.zero : const Duration(milliseconds: 300),
onChanged: dsl ? (_) {} : (v) => unawaited(controller.search(v)),
onSubmitted: dsl ? (v) => unawaited(controller.runQuery(v)) : (v) => unawaited(controller.search(v)),
+36 -7
View File
@@ -44,7 +44,7 @@ class _ProblemsViewState extends State<ProblemsView> {
builder: (context, _) {
final tokens = ClideSettings.theme.of(context).surface;
return Semantics(
label: 'problems panel',
label: ClideSettings.i18n.string(context, 'semantics.panel', namespace: 'builtin.problems', placeholder: 'problems panel'),
container: true,
explicitChildNodes: true,
child: () {
@@ -55,30 +55,59 @@ class _ProblemsViewState extends State<ProblemsView> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClideFilterBox(address: 'problems.panel', hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)),
ClideFilterBox(
address: 'problems.panel',
hint: ClideSettings.i18n.string(context, 'filter.hint', namespace: 'builtin.problems', placeholder: 'Filter problems…'),
onChanged: (v) => setState(() => _filter = v),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
children: [
Expanded(
child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground),
child: ClideText(
ClideSettings.i18n.interpolated(
context,
'count',
namespace: 'builtin.problems',
placeholder: 'Problems ({count})',
replacers: [I18nReplacer(from: '{count}', replace: '${filtered.length}')],
),
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
),
Semantics(
button: true,
label: 'refresh problems',
label: ClideSettings.i18n.string(context, 'refresh.semantics', namespace: 'builtin.problems', placeholder: 'refresh problems'),
child: GestureDetector(
onTap: () => unawaited(c.refresh()),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground),
child: ClideText(
ClideSettings.i18n.string(context, 'refresh.label', namespace: 'builtin.problems', placeholder: 'Refresh'),
fontSize: clideFontCaption,
color: tokens.sidebarForeground,
),
),
),
),
],
),
),
if (c.loading && c.problems.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Scanning…', muted: true)),
if (!c.loading && filtered.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('No problems found.', muted: true)),
if (c.loading && c.problems.isEmpty)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(ClideSettings.i18n.string(context, 'scanning', namespace: 'builtin.problems', placeholder: 'Scanning…'), muted: true),
),
if (!c.loading && filtered.isEmpty)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.problems', placeholder: 'No problems found.'),
muted: true,
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
+2
View File
@@ -52,6 +52,8 @@ class TicketsExtension extends ClideExtension {
id: 'tickets.detail',
slot: Slots.contextPanel,
title: 'Ticket',
titleKey: 'tab.detail.title',
i18nNamespace: id,
icon: PhosphorIcons.byName('ticket'),
build: (_) => const TicketDetailView(),
),
+41 -12
View File
@@ -62,9 +62,21 @@ class _TicketDetailViewState extends State<TicketDetailView> {
return ListenableBuilder(
listenable: c,
builder: (ctx, _) {
if (c.loading) return const Center(child: ClideText('Loading…', muted: true));
if (c.loading) {
return Center(
child: ClideText(ClideSettings.i18n.string(ctx, 'detail.loading', namespace: 'builtin.tickets', placeholder: 'Loading…'), muted: true),
);
}
final d = c.detail;
if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a ticket to view details.', muted: true));
if (d == null) {
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(ctx, 'detail.empty', namespace: 'builtin.tickets', placeholder: 'Select a ticket to view details.'),
muted: true,
),
);
}
final tokens = ClideSettings.theme.of(ctx).surface;
final isDark = ClideSettings.theme.of(ctx).dark;
@@ -99,13 +111,19 @@ class _TicketDetailViewState extends State<TicketDetailView> {
],
if (d.parents.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionLabel(label: 'PARENT TREE', tokens: tokens),
_SectionLabel(
label: ClideSettings.i18n.string(ctx, 'detail.section.parents', namespace: 'builtin.tickets', placeholder: 'PARENT TREE'),
tokens: tokens,
),
const SizedBox(height: 6),
for (var i = 0; i < d.parents.length; i++) _CompactCard(data: d.parents[i], tokens: tokens, typeColors: typeColors, indent: i),
],
if (d.decisions.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens),
_SectionLabel(
label: ClideSettings.i18n.string(ctx, 'detail.section.decisions', namespace: 'builtin.tickets', placeholder: 'REFERENCED DECISIONS'),
tokens: tokens,
),
const SizedBox(height: 6),
for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens),
],
@@ -158,7 +176,18 @@ class _TicketHeader extends StatelessWidget {
ClideText(detail.title, fontSize: 15, fontWeight: FontWeight.w500),
if (detail.assignedTo != null) ...[
const SizedBox(height: 6),
ClideText('assigned: ${detail.assignedTo}', muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context)),
ClideText(
ClideSettings.i18n.interpolated(
context,
'detail.assigned',
namespace: 'builtin.tickets',
placeholder: 'assigned: {name}',
replacers: [I18nReplacer(from: '{name}', replace: detail.assignedTo!)],
),
muted: true,
fontSize: clideFontSmall,
fontFamily: ClideSettings.fonts.monoOf(context),
),
],
],
),
@@ -207,7 +236,7 @@ class _StatusControls extends StatelessWidget {
border: Border.all(color: active ? tokens.statusInfo : tokens.panelBorder),
),
alignment: Alignment.center,
child: ClideText(_shortLabel(s), fontSize: clideFontBadge, color: color, fontFamily: ClideSettings.fonts.monoOf(context)),
child: ClideText(_shortLabel(ctx, s), fontSize: clideFontBadge, color: color, fontFamily: ClideSettings.fonts.monoOf(context)),
);
},
),
@@ -218,12 +247,12 @@ class _StatusControls extends StatelessWidget {
);
}
static String _shortLabel(String s) => switch (s) {
'backlog' => 'BACKLOG',
'ready' => 'READY',
'in_progress' => 'WIP',
'review' => 'REVIEW',
'done' => 'DONE',
static String _shortLabel(BuildContext context, String s) => switch (s) {
'backlog' => ClideSettings.i18n.string(context, 'status.backlog', namespace: 'builtin.tickets', placeholder: 'BACKLOG'),
'ready' => ClideSettings.i18n.string(context, 'status.ready', namespace: 'builtin.tickets', placeholder: 'READY'),
'in_progress' => ClideSettings.i18n.string(context, 'status.in_progress', namespace: 'builtin.tickets', placeholder: 'WIP'),
'review' => ClideSettings.i18n.string(context, 'status.review', namespace: 'builtin.tickets', placeholder: 'REVIEW'),
'done' => ClideSettings.i18n.string(context, 'status.done', namespace: 'builtin.tickets', placeholder: 'DONE'),
_ => s.toUpperCase(),
};
}
+63 -16
View File
@@ -150,7 +150,7 @@ class _TicketsViewState extends State<TicketsView> {
if (!mounted) return;
if (!resp.ok) {
setState(() {
_error = resp.error?.message ?? 'failed to load tickets';
_error = resp.error?.message ?? ClideSettings.i18n.string(context, 'error.load', namespace: 'builtin.tickets', placeholder: 'failed to load tickets');
_loading = false;
});
return;
@@ -169,9 +169,21 @@ class _TicketsViewState extends State<TicketsView> {
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
if (_loading) return const Center(child: ClideText('Loading tickets...', muted: true));
if (_loading) {
return Center(
child: ClideText(ClideSettings.i18n.string(context, 'loading', namespace: 'builtin.tickets', placeholder: 'Loading tickets...'), muted: true),
);
}
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
if (_tickets.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No tickets.\nRun `pql ticket new` to create one.', muted: true));
if (_tickets.isEmpty) {
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.tickets', placeholder: 'No tickets.\nRun `pql ticket new` to create one.'),
muted: true,
),
);
}
final lf = _filter.toLowerCase();
final hasTextFilter = lf.isNotEmpty;
@@ -191,6 +203,8 @@ class _TicketsViewState extends State<TicketsView> {
('done', 'DONE'),
('cancelled', 'CANCELLED'),
];
String sectionLabel(String status, String fallback) =>
ClideSettings.i18n.string(context, 'section.$status', namespace: 'builtin.tickets', placeholder: fallback);
final byStatus = <String, List<_TicketEntry>>{};
for (final t in filtered) {
@@ -205,13 +219,17 @@ class _TicketsViewState extends State<TicketsView> {
Row(
children: [
Expanded(
child: ClideFilterBox(address: 'tickets.panel', hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)),
child: ClideFilterBox(
address: 'tickets.panel',
hint: ClideSettings.i18n.string(context, 'filter.hint', namespace: 'builtin.tickets', placeholder: 'Filter tickets…'),
onChanged: (v) => setState(() => _filter = v),
),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideTappable(
onTap: _refreshing ? null : _refresh,
tooltip: 'Refresh tickets',
tooltip: ClideSettings.i18n.string(context, 'refresh.tooltip', namespace: 'builtin.tickets', placeholder: 'Refresh tickets'),
builder: (ctx, hovered, _) =>
ClideIcon(PhosphorIcons.byName('arrow-clockwise'), size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
@@ -227,7 +245,25 @@ class _TicketsViewState extends State<TicketsView> {
children: [
for (final (type, label) in _typeOrder)
_TypeChip(
label: label,
label: ClideSettings.i18n.string(context, 'type.$type', namespace: 'builtin.tickets', placeholder: label),
semanticLabel: ClideSettings.i18n.interpolated(
context,
'chip.label',
namespace: 'builtin.tickets',
placeholder: '{type} type filter',
replacers: [
I18nReplacer(
from: '{type}',
replace: ClideSettings.i18n.string(context, 'type.$type', namespace: 'builtin.tickets', placeholder: label),
),
],
),
tooltip: ClideSettings.i18n.string(
context,
'chip.tooltip',
namespace: 'builtin.tickets',
placeholder: 'Click to toggle · double-click to isolate',
),
color: typeColors.forType(type),
active: _enabledTypes.contains(type),
onToggle: () => _toggleType(type),
@@ -246,7 +282,7 @@ class _TicketsViewState extends State<TicketsView> {
for (final (status, label) in sections)
if (byStatus[status] case final items? when items.isNotEmpty)
ClideAccordion(
label: label,
label: sectionLabel(status, label),
count: items.length,
expanded: filtering || _isSectionExpanded(status),
onToggle: () => _toggle(status),
@@ -275,9 +311,20 @@ class _TicketsViewState extends State<TicketsView> {
/// type; double-click isolates it (chart-legend solo). One [GestureDetector]
/// owns both so Flutter disambiguates single vs double.
class _TypeChip extends StatelessWidget {
const _TypeChip({required this.label, required this.color, required this.active, required this.onToggle, required this.onSolo, required this.tokens});
const _TypeChip({
required this.label,
required this.semanticLabel,
required this.tooltip,
required this.color,
required this.active,
required this.onToggle,
required this.onSolo,
required this.tokens,
});
final String label;
final String semanticLabel;
final String tooltip;
final Color color;
final bool active;
final VoidCallback onToggle;
@@ -290,9 +337,9 @@ class _TypeChip extends StatelessWidget {
return Semantics(
button: true,
toggled: active,
label: '$label type filter',
label: semanticLabel,
child: ClideTooltip(
message: 'Click to toggle · double-click to isolate',
message: tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
@@ -360,7 +407,7 @@ class _TicketCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final typeColor = typeColors.forType(entry.type);
final statusLabel = _statusLabel(entry.status);
final statusLabel = _statusLabel(context, entry.status);
return Padding(
key: focusKey,
@@ -436,10 +483,10 @@ class _TicketCard extends StatelessWidget {
);
}
static String? _statusLabel(String? status) => switch (status) {
'in_progress' => 'WIP',
'review' => 'REVIEW',
'cancelled' => 'CANCELLED',
static String? _statusLabel(BuildContext context, String? status) => switch (status) {
'in_progress' => ClideSettings.i18n.string(context, 'badge.wip', namespace: 'builtin.tickets', placeholder: 'WIP'),
'review' => ClideSettings.i18n.string(context, 'badge.review', namespace: 'builtin.tickets', placeholder: 'REVIEW'),
'cancelled' => ClideSettings.i18n.string(context, 'badge.cancelled', namespace: 'builtin.tickets', placeholder: 'CANCELLED'),
_ => null,
};
}
@@ -455,7 +502,7 @@ class _PickUpAction extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClideTappable(
tooltip: 'Pick up — hand this ticket to the Claude pane',
tooltip: ClideSettings.i18n.string(context, 'pickUp.tooltip', namespace: 'builtin.tickets', placeholder: 'Pick up — hand this ticket to the Claude pane'),
onTap: () {
final kernel = ClideKernel.of(context);
unawaited(() async {
@@ -1,3 +1,16 @@
{
"tab.title": { "translation": "Decisions" }
"tab.title": { "translation": "Decisions" },
"tab.detail.title": { "translation": "Decision" },
"loading": { "translation": "Loading decisions..." },
"error.load": { "translation": "failed to load decisions" },
"empty": { "translation": "No decisions found.\nRun `pql decisions sync` to index." },
"filter.hint": { "translation": "Filter decisions…" },
"refresh.tooltip": { "translation": "Refresh decisions" },
"section.confirmed": { "translation": "CONFIRMED" },
"section.questions": { "translation": "QUESTIONS" },
"section.rejected": { "translation": "REJECTED" },
"badge.resolved": { "translation": "resolved" },
"detail.loading": { "translation": "Loading…" },
"detail.empty": { "translation": "Select a decision to view details." },
"detail.section.refs": { "translation": "CROSS-REFERENCES" }
}
@@ -1,3 +1,16 @@
{
"tab.title": { "translation": "pql" }
"tab.title": { "translation": "pql" },
"tab.links.title": { "translation": "Links" },
"filter.markdown.hint": { "translation": "Filter markdown…" },
"loading": { "translation": "Loading…" },
"empty.markdown": { "translation": "No markdown files found." },
"search.vault.hint": { "translation": "Search vault…" },
"search.query.hint": { "translation": "PQL query…" },
"backlinks.empty": { "translation": "Open a file to see its links." },
"backlinks.loading": { "translation": "Loading…" },
"backlinks.semantics": { "translation": "backlinks for {path}" },
"group.backlinks": { "translation": "Backlinks" },
"group.outlinks": { "translation": "Outlinks" },
"group.label": { "translation": "{label} ({count})" },
"group.none": { "translation": "None" }
}
@@ -1,3 +1,10 @@
{
"tab.title": { "translation": "Problems" }
"tab.title": { "translation": "Problems" },
"semantics.panel": { "translation": "problems panel" },
"filter.hint": { "translation": "Filter problems…" },
"count": { "translation": "Problems ({count})" },
"refresh.label": { "translation": "Refresh" },
"refresh.semantics": { "translation": "refresh problems" },
"scanning": { "translation": "Scanning…" },
"empty": { "translation": "No problems found." }
}
@@ -1,3 +1,36 @@
{
"tab.title": { "translation": "Tickets" }
"tab.title": { "translation": "Tickets" },
"tab.detail.title": { "translation": "Ticket" },
"type.initiative": { "translation": "Initiative" },
"type.epic": { "translation": "Epic" },
"type.story": { "translation": "Story" },
"type.task": { "translation": "Task" },
"type.bug": { "translation": "Bug" },
"loading": { "translation": "Loading tickets..." },
"error.load": { "translation": "failed to load tickets" },
"empty": { "translation": "No tickets.\nRun `pql ticket new` to create one." },
"filter.hint": { "translation": "Filter tickets…" },
"refresh.tooltip": { "translation": "Refresh tickets" },
"section.in_progress": { "translation": "IN PROGRESS" },
"section.review": { "translation": "REVIEW" },
"section.ready": { "translation": "READY" },
"section.backlog": { "translation": "BACKLOG" },
"section.done": { "translation": "DONE" },
"section.cancelled": { "translation": "CANCELLED" },
"chip.label": { "translation": "{type} type filter" },
"chip.tooltip": { "translation": "Click to toggle · double-click to isolate" },
"badge.wip": { "translation": "WIP" },
"badge.review": { "translation": "REVIEW" },
"badge.cancelled": { "translation": "CANCELLED" },
"pickUp.tooltip": { "translation": "Pick up — hand this ticket to the Claude pane" },
"detail.loading": { "translation": "Loading…" },
"detail.empty": { "translation": "Select a ticket to view details." },
"detail.assigned": { "translation": "assigned: {name}" },
"detail.section.parents": { "translation": "PARENT TREE" },
"detail.section.decisions": { "translation": "REFERENCED DECISIONS" },
"status.backlog": { "translation": "BACKLOG" },
"status.ready": { "translation": "READY" },
"status.in_progress": { "translation": "WIP" },
"status.review": { "translation": "REVIEW" },
"status.done": { "translation": "DONE" }
}