refactor(settings): ClideSettings — one live-preferences facade (D-101)

Introduce ClideSettings, a single widget-facing facade for the app's live
user preferences, namespaced by concern: ClideSettings.fonts.monoOf(context)
/ .fonts.uiOf(context), ClideSettings.theme.of(context),
ClideSettings.i18n.of(context). "Plumb once, use many."

Fonts are carried by a new root-provided ClideSettingsScope (resolved from the
font settings in root_shell, rebuilt on change); theme and i18n delegate to
their existing live providers (ClideTheme / the I18n service) so there's one
source of truth and their many consumers migrate incrementally rather than in
a big-bang. Reads outside a scope fall back to the bundled font defaults, so a
widget renders without a provider (isolated tests).

Migrate ~93 monospace-font call sites across 33 files from the clideMonoFamily
const to ClideSettings.fonts.monoOf(context) — pure refactor, identical family
when no override is set. 11 context-less helper sites (markdown static spans,
a few top-level/static builders) keep the const for now and are tracked in
T-472. Records D-101; updates the ui-design skill's font-family rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 20:57:16 +02:00
co-authored by Claude Opus 4.8
parent 8ad5c9ba6e
commit a7ea38acde
44 changed files with 452 additions and 206 deletions
+18 -5
View File
@@ -34,7 +34,12 @@ class ProjectSwitcherButton extends StatelessWidget {
builder: (context, hovered, _) => Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideText(label, fontSize: 12, color: hovered ? tokens.globalForeground : tokens.chromeForeground, fontFamily: clideMonoFamily),
ClideText(
label,
fontSize: 12,
color: hovered ? tokens.globalForeground : tokens.chromeForeground,
fontFamily: ClideSettings.fonts.monoOf(context),
),
const SizedBox(width: 4),
ClideIcon(PhosphorIcons.byName('caret-down'), size: 8, color: tokens.chromeForeground),
],
@@ -195,7 +200,7 @@ class _RecentProjectRow extends StatelessWidget {
project.relativePath,
muted: true,
fontSize: 12,
fontFamily: clideMonoFamily,
fontFamily: ClideSettings.fonts.monoOf(context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -203,11 +208,18 @@ class _RecentProjectRow extends StatelessWidget {
ClideText(' · ', muted: true, fontSize: 12),
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
const SizedBox(width: 3),
ClideText(project.branch!, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
ClideText(project.branch!, muted: true, fontSize: 12, fontFamily: ClideSettings.fonts.monoOf(context)),
],
)
else
ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
ClideText(
project.relativePath,
muted: true,
fontSize: 12,
fontFamily: ClideSettings.fonts.monoOf(context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
@@ -236,7 +248,8 @@ class _ActionRow extends StatelessWidget {
child: Row(
children: [
Expanded(child: ClideText(label, fontSize: 14)),
if (shortcut != null && shortcut!.isNotEmpty) ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
if (shortcut != null && shortcut!.isNotEmpty)
ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
],
),
),
+119 -110
View File
@@ -74,118 +74,127 @@ class RootShellState extends State<RootShell> {
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return DefaultTextStyle(
style: TextStyle(
color: tokens.globalForeground,
fontSize: 15,
height: clideLineHeight,
fontWeight: clideUiDefaultWeight,
// User-selected UI font (Settings → Appearance, T-460), else the
// bundled default. The fallback chain still covers a missing asset.
fontFamily: widget.services.settings.get<String>(kUiFontSettingKey) ?? clideUiFamily,
fontFamilyFallback: clideUiFamilyFallback,
),
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
child: Actions(
actions: <Type, Action<Intent>>{
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
onInvoke: (_) {
widget.services.textZoom.increase();
return null;
},
),
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
onInvoke: (_) {
widget.services.textZoom.decrease();
return null;
},
),
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
onInvoke: (_) {
widget.services.textZoom.reset();
return null;
},
),
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
onInvoke: (intent) {
widget.services.commands.execute(intent.commandId);
return null;
},
),
PaletteOpenIntent: CallbackAction<PaletteOpenIntent>(
onInvoke: (_) {
widget.services.palette.open();
return null;
},
),
QuickOpenIntent: CallbackAction<QuickOpenIntent>(
onInvoke: (_) {
widget.services.quickOpen.open();
return null;
},
),
ExLineOpenIntent: CallbackAction<ExLineOpenIntent>(
onInvoke: (_) {
widget.services.exLine.open();
return null;
},
),
ExLineWriteQuitIntent: CallbackAction<ExLineWriteQuitIntent>(
onInvoke: (_) {
// ZZ — save+close the active tab without opening the overlay.
unawaited(exWriteQuitActive(widget.services.ipc));
return null;
},
),
FindInFilesIntent: CallbackAction<FindInFilesIntent>(
onInvoke: (_) {
widget.services.arrangement.setVisible(Slots.sidebar, true);
widget.services.arrangement.setCollapsed(Slots.sidebar, false);
widget.services.panels.activateTab(Slots.sidebar, 'search.findInFiles');
return null;
},
),
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
onInvoke: (_) {
widget.services.focus.focusNextSlot();
return null;
},
),
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
onInvoke: (_) {
widget.services.focus.focusPreviousSlot();
return null;
},
),
},
child: KeyboardListener(
focusNode: _keyFocus,
autofocus: true,
onKeyEvent: _onKey,
child: ColoredBox(
color: tokens.globalBackground,
child: ClideResizeBorder(
windowControls: widget.services.window,
child: Column(
children: [
HatBar(kernel: widget.services, menuBar: _menuBar),
Expanded(
child: DialogHost(
router: widget.services.dialog,
child: Stack(
children: [
const Positioned.fill(child: RootLayout()),
const ClidePalette(),
const QuickOpenOverlay(),
const ExLineOverlay(),
const Positioned.fill(child: _WelcomeOverlay()),
const ToastOverlay(),
],
// Resolve the user-selected fonts once (Settings → Appearance, T-460/T-471)
// and publish them via ClideFonts; the settings listener rebuilds this on a
// change so descendants re-read live. The UI font flows through the
// DefaultTextStyle below; mono sites read ClideFonts.monoOf(context).
final settings = widget.services.settings;
final uiFont = settings.get<String>(kUiFontSettingKey) ?? clideUiFamily;
final monoFont = settings.get<String>(kMonoFontSettingKey) ?? clideMonoFamily;
return ClideSettingsScope(
ui: uiFont,
mono: monoFont,
child: DefaultTextStyle(
style: TextStyle(
color: tokens.globalForeground,
fontSize: 15,
height: clideLineHeight,
fontWeight: clideUiDefaultWeight,
fontFamily: uiFont,
fontFamilyFallback: clideUiFamilyFallback,
),
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
child: Actions(
actions: <Type, Action<Intent>>{
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
onInvoke: (_) {
widget.services.textZoom.increase();
return null;
},
),
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
onInvoke: (_) {
widget.services.textZoom.decrease();
return null;
},
),
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
onInvoke: (_) {
widget.services.textZoom.reset();
return null;
},
),
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
onInvoke: (intent) {
widget.services.commands.execute(intent.commandId);
return null;
},
),
PaletteOpenIntent: CallbackAction<PaletteOpenIntent>(
onInvoke: (_) {
widget.services.palette.open();
return null;
},
),
QuickOpenIntent: CallbackAction<QuickOpenIntent>(
onInvoke: (_) {
widget.services.quickOpen.open();
return null;
},
),
ExLineOpenIntent: CallbackAction<ExLineOpenIntent>(
onInvoke: (_) {
widget.services.exLine.open();
return null;
},
),
ExLineWriteQuitIntent: CallbackAction<ExLineWriteQuitIntent>(
onInvoke: (_) {
// ZZ — save+close the active tab without opening the overlay.
unawaited(exWriteQuitActive(widget.services.ipc));
return null;
},
),
FindInFilesIntent: CallbackAction<FindInFilesIntent>(
onInvoke: (_) {
widget.services.arrangement.setVisible(Slots.sidebar, true);
widget.services.arrangement.setCollapsed(Slots.sidebar, false);
widget.services.panels.activateTab(Slots.sidebar, 'search.findInFiles');
return null;
},
),
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
onInvoke: (_) {
widget.services.focus.focusNextSlot();
return null;
},
),
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
onInvoke: (_) {
widget.services.focus.focusPreviousSlot();
return null;
},
),
},
child: KeyboardListener(
focusNode: _keyFocus,
autofocus: true,
onKeyEvent: _onKey,
child: ColoredBox(
color: tokens.globalBackground,
child: ClideResizeBorder(
windowControls: widget.services.window,
child: Column(
children: [
HatBar(kernel: widget.services, menuBar: _menuBar),
Expanded(
child: DialogHost(
router: widget.services.dialog,
child: Stack(
children: [
const Positioned.fill(child: RootLayout()),
const ClidePalette(),
const QuickOpenOverlay(),
const ExLineOverlay(),
const Positioned.fill(child: _WelcomeOverlay()),
const ToastOverlay(),
],
),
),
),
),
],
],
),
),
),
),