sidebar focus indicators, bus navigation, font constants

All sidebar and detail panes use clideFontSmall/clideFontBadge/
clideFontCaption instead of hardcoded sizes — single place to tune.

Decision detail view subscribes to the message bus for selection
events (same pattern as ticket controller) and publishes focus after
loading.  Both decisions and tickets sidebar views subscribe to the
focus channel, expand the accordion section, highlight the active
card, and scroll it into view.

Clickable DQRT links wired in decision detail, ticket detail, and
markdown viewer via onRecordTap.  Ticket descriptions now render
through ClideMarkdown instead of plain text.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-23 18:53:53 +02:00
co-authored by Claude
parent 81cc062409
commit 04d133f4ed
6 changed files with 140 additions and 49 deletions
@@ -16,17 +16,25 @@ class DecisionDetailView extends StatefulWidget {
class _DecisionDetailViewState extends State<DecisionDetailView> {
Map<String, Object?>? _decision;
bool _loading = false;
StreamSubscription<Message>? _sub;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (widget.initialId != null && _decision == null && !_loading) {
if (_sub != null) return;
final kernel = ClideKernel.of(context);
_sub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'selection').listen((msg) {
final id = msg.data['id'] as String?;
if (id != null) unawaited(_load(id));
});
if (widget.initialId != null) {
unawaited(_load(widget.initialId!));
}
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@@ -35,12 +43,24 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('pql.decisions.read', args: {'id': id});
if (!mounted) return;
if (resp.ok) {
kernel.messages.publish('builtin.decisions', 'focus', {'id': id});
}
setState(() {
_loading = false;
_decision = resp.ok ? resp.data : null;
});
}
void _navigateToRecord(BuildContext context, String id) {
final kernel = ClideKernel.of(context);
if (id.startsWith('T-')) {
kernel.messages.publish('builtin.tickets', 'selection', {'id': id});
} else {
kernel.messages.publish('builtin.decisions', 'selection', {'id': id});
}
}
@override
Widget build(BuildContext context) {
if (_loading) return const Center(child: ClideText('Loading…', muted: true));
@@ -83,13 +103,13 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
),
const SizedBox(width: 8),
ClideText(id, fontSize: 13, color: typeColor, fontFamily: clideMonoFamily),
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
const Spacer(),
if (domain != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: tokens.panelBorder, borderRadius: BorderRadius.circular(3)),
child: ClideText(domain, fontSize: 10, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
),
],
),
@@ -97,7 +117,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
if (date != null) ...[
const SizedBox(height: 6),
ClideText(date, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
],
if (status != null && status != 'active') ...[
const SizedBox(height: 8),
@@ -108,11 +128,11 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
),
if (body != null && body.isNotEmpty) ...[
const SizedBox(height: 12),
ClideMarkdown(body),
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
],
if (refs.isNotEmpty) ...[
const SizedBox(height: 16),
ClideText('CROSS-REFERENCES', fontSize: 11, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
const SizedBox(height: 6),
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
],
@@ -144,9 +164,9 @@ class _RefCard extends StatelessWidget {
),
child: Row(
children: [
ClideText(targetId, fontSize: 11, color: tokens.globalFocus, fontFamily: clideMonoFamily),
ClideText(targetId, fontSize: clideFontSmall, color: tokens.globalFocus, fontFamily: clideMonoFamily),
const SizedBox(width: 8),
ClideText(refType, fontSize: 11, color: tokens.globalTextMuted),
ClideText(refType, fontSize: clideFontSmall, color: tokens.globalTextMuted),
],
),
),
@@ -170,7 +190,7 @@ class _StatusBadge extends StatelessWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: color.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
child: ClideText(status.toUpperCase(), fontSize: 10, color: color, fontFamily: clideMonoFamily),
child: ClideText(status.toUpperCase(), fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
);
}
}
+42 -11
View File
@@ -17,11 +17,18 @@ class _DecisionsViewState extends State<DecisionsView> {
String? _error;
bool _loading = true;
String _filter = '';
String? _focusedId;
final _focusedKey = GlobalKey();
final Set<String> _expanded = {'confirmed'};
StreamSubscription<Message>? _focusSub;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_focusSub == null) {
final kernel = ClideKernel.of(context);
_focusSub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'focus').listen(_onFocus);
}
if (!_loading || _decisions.isNotEmpty) return;
unawaited(_load());
}
@@ -49,6 +56,27 @@ class _DecisionsViewState extends State<DecisionsView> {
}
}
@override
void dispose() {
_focusSub?.cancel();
super.dispose();
}
void _onFocus(Message msg) {
final id = msg.data['id'] as String?;
if (id == null || id == _focusedId) return;
final entry = _decisions.where((d) => d.id == id).firstOrNull;
final section = entry?.type ?? 'confirmed';
setState(() {
_focusedId = id;
_expanded.add(section);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _focusedKey.currentContext;
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
});
}
void _toggleSection(String key) {
setState(() {
if (_expanded.contains(key)) {
@@ -91,21 +119,21 @@ class _DecisionsViewState extends State<DecisionsView> {
color: typeColors.confirmed,
expanded: hasFilter || _expanded.contains('confirmed'),
onToggle: () => _toggleSection('confirmed'),
children: [for (final d in confirmed) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors)],
children: [for (final d in confirmed) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
),
if (questions.isNotEmpty) _AccordionSection(
label: 'QUESTIONS', count: questions.length, tokens: tokens,
color: typeColors.question,
expanded: hasFilter || _expanded.contains('question'),
onToggle: () => _toggleSection('question'),
children: [for (final d in questions) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors)],
children: [for (final d in questions) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
),
if (rejected.isNotEmpty) _AccordionSection(
label: 'REJECTED', count: rejected.length, tokens: tokens,
color: typeColors.rejected,
expanded: hasFilter || _expanded.contains('rejected'),
onToggle: () => _toggleSection('rejected'),
children: [for (final d in rejected) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors)],
children: [for (final d in rejected) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
),
],
),
@@ -162,7 +190,7 @@ class _AccordionSection extends StatelessWidget {
const SizedBox(width: 6),
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6),
ClideText('$label · $count', fontSize: 11, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
],
),
),
@@ -174,24 +202,27 @@ class _AccordionSection extends StatelessWidget {
}
class _DecisionCard extends StatelessWidget {
const _DecisionCard({required this.entry, required this.tokens, required this.typeColors});
const _DecisionCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
final _DecisionEntry entry;
final SurfaceTokens tokens;
final DecisionTypeColors typeColors;
final bool focused;
final GlobalKey? focusKey;
@override
Widget build(BuildContext context) {
final typeColor = typeColors.forType(entry.type);
return Padding(
key: focusKey,
padding: const EdgeInsets.only(bottom: 4),
child: ClideTappable(
onTap: () => ClideKernel.of(context).messages.publish('builtin.decisions', 'selection', {'id': entry.id}),
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: hovered ? tokens.sidebarItemHover : tokens.panelBackground,
color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : tokens.panelBackground),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.panelBorder, width: 1),
border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -203,20 +234,20 @@ class _DecisionCard extends StatelessWidget {
child: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
),
const SizedBox(width: 6),
ClideText(entry.id, fontSize: 11, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
const Spacer(),
if (entry.domain != null)
ClideText(entry.domain!, fontSize: 10, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
],
),
const SizedBox(height: 4),
ClideText(entry.title, fontSize: 13),
ClideText(entry.title, fontSize: clideFontCaption),
if (entry.status == 'resolved') ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: tokens.statusSuccess.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
child: ClideText('resolved', fontSize: 10, color: tokens.statusSuccess, fontFamily: clideMonoFamily),
child: ClideText('resolved', fontSize: clideFontBadge, color: tokens.statusSuccess, fontFamily: clideMonoFamily),
),
],
],