From 044d1b2ff10d53db8f053bf74aeacf2098ee16b2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 18 May 2026 09:04:43 +0200 Subject: [PATCH] lift text-zoom into kernel, surface it in the palette (T-114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace text-zoom (Ctrl +/-/0) was local state on _RootShellState, reachable only via the keymap intent path. Lifted to a kernel TextZoom ChangeNotifier so the new `view.zoomIn/Out/Reset` palette commands mutate the same number the keymap does — closing T-114's "discoverable in the palette" item. Co-Authored-By: Claude --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 8 +++ CHANGELOG.md | 2 + lib/app.dart | 17 +++---- lib/builtin/view/src/extension.dart | 57 +++++++++++++++++++++ lib/builtin/view/view.dart | 1 + lib/kernel/kernel.dart | 1 + lib/kernel/src/facade.dart | 6 +++ lib/kernel/src/text_zoom.dart | 28 +++++++++++ lib/main.dart | 2 + test/kernel/src/text_zoom_test.dart | 60 +++++++++++++++++++++++ 11 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 lib/builtin/view/src/extension.dart create mode 100644 lib/builtin/view/view.dart create mode 100644 lib/kernel/src/text_zoom.dart create mode 100644 test/kernel/src/text_zoom_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index f003ba87..a7966dbd 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -1807,3 +1807,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-112', 'status', 'in_progress', 'done', NULL, '2026-05-17 20:11:16', '2026-05-17 20:11:16', '2026-05-17 20:11:16', NULL, '6d51964a3ad7f1948f54a7c769336841', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 20:13:36', '2026-05-17 20:13:36', '2026-05-17 20:13:36', NULL, '0bad40fa7f165f828bb0c0757044491e', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-118', 'status', 'backlog', 'done', NULL, '2026-05-17 20:53:41', '2026-05-17 20:53:41', '2026-05-17 20:53:41', NULL, 'ff0126655805fed0ebe45f313c5ceff7', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:04:38', '2026-05-18 07:04:38', '2026-05-18 07:04:38', NULL, '8cf198f8c824fa8f46277b7e22e68f0c', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index c239d0b6..5e74e8bb 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -1829,3 +1829,11 @@ Scope: them — adding the files is enough. Sources: D-22, D-69, consultants.md ("UX — Findings — [Minor]").', 'done', 'low', NULL, NULL, 'D-69', '2026-05-17 20:51:47', '2026-05-17 20:53:41', NULL, 'd163731890022856585e9f33432462f6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'task', 'T-97', 'quick-wins: a11y + contrast gate expansion', 'Batch of small consultant findings that share a domain (a11y / visual): + +1. Expand contrast gate `canonicalPairs` (`test/a11y/contrast_test.dart`) to cover `globalTextMuted` (muted text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and `panelActiveBorder`. +2. Tokenize the ~43 hardcoded-color sites the UX reviewer flagged — at minimum the modal/palette shadow and window-control colors that don''t adapt to the `paper` light theme. +3. Add a focus-ring rendering at the token level so every focusable widget gets it for free (depends on T-100). +4. Surface text-zoom (Ctrl +/-/0) in the palette so it''s discoverable. + +Source: consultants.md "UX — Findings — [Minor]" + Strengths section.', 'done', 'low', NULL, NULL, NULL, '2026-05-17 18:48:28', '2026-05-18 07:04:38', NULL, '70c65844ee3e52bfcd6220b77ab8957a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f368678..5402a57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ambient shadow are now tokens (`windowControl.closeHover*`, `shadow.ambient`) instead of hard-coded hex. Light themes get a softer ink-tinted shadow (T-114). +- Text-zoom (Ctrl +/-/0) is now a kernel `TextZoom` service and shows + up in the palette as `View: Zoom In/Out/Reset Zoom` (T-114). - Changelog gate is binary — dropped the soft 40-word warning, kept the 60-word hard cap. Warnings that never blocked just normalised drift. diff --git a/lib/app.dart b/lib/app.dart index ae38a6f3..56c9238b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -55,24 +55,23 @@ class _RootShell extends StatefulWidget { class _RootShellState extends State<_RootShell> { late final FocusNode _keyFocus; - double _textScale = 1.0; - - static const double _scaleStep = 0.05; - static const double _scaleMin = 0.6; - static const double _scaleMax = 2.0; @override void initState() { super.initState(); _keyFocus = FocusNode()..requestFocus(); + widget.services.textZoom.addListener(_onZoom); } @override void dispose() { + widget.services.textZoom.removeListener(_onZoom); _keyFocus.dispose(); super.dispose(); } + void _onZoom() => setState(() {}); + @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; @@ -86,24 +85,24 @@ class _RootShellState extends State<_RootShell> { fontFamilyFallback: clideUiFamilyFallback, ), child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(_textScale)), + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)), child: Actions( actions: >{ TextScaleIncreaseIntent: CallbackAction( onInvoke: (_) { - setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax)); + widget.services.textZoom.increase(); return null; }, ), TextScaleDecreaseIntent: CallbackAction( onInvoke: (_) { - setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax)); + widget.services.textZoom.decrease(); return null; }, ), TextScaleResetIntent: CallbackAction( onInvoke: (_) { - setState(() => _textScale = 1.0); + widget.services.textZoom.reset(); return null; }, ), diff --git a/lib/builtin/view/src/extension.dart b/lib/builtin/view/src/extension.dart new file mode 100644 index 00000000..45c5fbac --- /dev/null +++ b/lib/builtin/view/src/extension.dart @@ -0,0 +1,57 @@ +import 'package:clide/clide.dart'; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; + +/// Surfaces view-level commands (currently the three text-zoom verbs) +/// in the command palette so they're discoverable. The keybindings +/// themselves are owned by the default keymap; commands here exist so +/// users browsing `Ctrl+Shift+P` see the same actions. +class ViewExtension extends ClideExtension { + ViewExtension({required this.textZoom}); + + final TextZoom textZoom; + + @override + String get id => 'builtin.view'; + @override + String get title => 'View'; + @override + String get version => '0.1.0'; + + @override + Future activate(ClideExtensionContext ctx) async {} + + @override + List get contributions => [ + // Keybindings live in `assets/keymaps/default.yaml` against the + // text.scale* intents — registering a `defaultBinding` here too + // would shadow them. Palette discovery is the only goal. + CommandContribution( + id: 'view.zoomIn', + command: 'view.zoomIn', + title: 'View: Zoom In', + run: (_) async { + textZoom.increase(); + return IpcResponse.ok(id: '', data: {'scale': textZoom.scale}); + }, + ), + CommandContribution( + id: 'view.zoomOut', + command: 'view.zoomOut', + title: 'View: Zoom Out', + run: (_) async { + textZoom.decrease(); + return IpcResponse.ok(id: '', data: {'scale': textZoom.scale}); + }, + ), + CommandContribution( + id: 'view.zoomReset', + command: 'view.zoomReset', + title: 'View: Reset Zoom', + run: (_) async { + textZoom.reset(); + return IpcResponse.ok(id: '', data: {'scale': textZoom.scale}); + }, + ), + ]; +} diff --git a/lib/builtin/view/view.dart b/lib/builtin/view/view.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/lib/builtin/view/view.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index bbd993d9..0550c284 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -40,6 +40,7 @@ export 'src/os.dart'; export 'src/panels/arrangement.dart'; export 'src/project.dart'; export 'src/scheduler.dart'; +export 'src/text_zoom.dart'; export 'src/secrets.dart'; export 'src/tray.dart'; export 'src/panels/drag_resize.dart'; diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index bce61d18..48a1ae5a 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -5,6 +5,7 @@ import 'package:clide/clide.dart'; import 'package:clide/kernel/src/clipboard.dart'; import 'package:clide/kernel/src/commands/keybindings.dart'; import 'package:clide/kernel/src/keymap/keymap_service.dart'; +import 'package:clide/kernel/src/text_zoom.dart'; import 'package:clide/kernel/src/commands/palette.dart'; import 'package:clide/kernel/src/commands/registry.dart'; import 'package:clide/kernel/src/dialog.dart'; @@ -65,6 +66,7 @@ class KernelServices { required this.toolchain, required this.scheduler, required this.keymap, + required this.textZoom, }); final Logger log; @@ -94,6 +96,7 @@ class KernelServices { final Toolchain toolchain; final SchedulerService scheduler; final KeymapService keymap; + final TextZoom textZoom; static Future boot({ required Directory appDir, @@ -151,6 +154,7 @@ class KernelServices { final tc = toolchain ?? Toolchain(); final scheduler = SchedulerService(events); scheduler.start(); + final textZoom = TextZoom(); final project = ProjectManager( log: log, events: events, @@ -225,6 +229,7 @@ class KernelServices { toolchain: tc, scheduler: scheduler, keymap: keymap, + textZoom: textZoom, ); } @@ -247,6 +252,7 @@ class KernelServices { extensions.dispose(); await scheduler.dispose(); keymap.dispose(); + textZoom.dispose(); await log.dispose(); messages.dispose(); await events.dispose(); diff --git a/lib/kernel/src/text_zoom.dart b/lib/kernel/src/text_zoom.dart new file mode 100644 index 00000000..c052cbf7 --- /dev/null +++ b/lib/kernel/src/text_zoom.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; + +/// Workspace-wide text zoom factor. +/// +/// Owned by the kernel rather than the root widget so command-palette +/// entries, the keymap layer, and any future menu/CLI surface can mutate +/// the same number. The root `MediaQuery` listens via [ChangeNotifier]. +class TextZoom extends ChangeNotifier { + TextZoom(); + + static const double minScale = 0.6; + static const double maxScale = 2.0; + static const double stepScale = 0.05; + + double _scale = 1.0; + double get scale => _scale; + + void increase() => _setScale(_scale + stepScale); + void decrease() => _setScale(_scale - stepScale); + void reset() => _setScale(1.0); + + void _setScale(double next) { + final clamped = next.clamp(minScale, maxScale); + if (clamped == _scale) return; + _scale = clamped; + notifyListeners(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 4bbf2102..786a593e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'package:clide/builtin/problems/problems.dart'; import 'package:clide/builtin/settings_ui/settings_ui.dart'; import 'package:clide/builtin/terminal/terminal.dart'; import 'package:clide/builtin/theme_picker/theme_picker.dart'; +import 'package:clide/builtin/view/view.dart'; import 'package:clide/builtin/tickets/tickets.dart'; import 'package:clide/builtin/todos/todos.dart'; import 'package:clide/builtin/welcome/welcome.dart'; @@ -145,6 +146,7 @@ Future main() async { ..register(CanvasExtension()) ..register(GraphExtension()) // UI extensions + ..register(ViewExtension(textZoom: services.textZoom)) ..register(SettingsUiExtension()) ..register(ExtensionsUiExtension()) ..register(KeybindingsUiExtension()) diff --git a/test/kernel/src/text_zoom_test.dart b/test/kernel/src/text_zoom_test.dart new file mode 100644 index 00000000..bdf3a81a --- /dev/null +++ b/test/kernel/src/text_zoom_test.dart @@ -0,0 +1,60 @@ +import 'package:clide/kernel/src/text_zoom.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('TextZoom', () { + test('starts at 1.0', () { + expect(TextZoom().scale, 1.0); + }); + + test('increase adds one step, notifies listeners', () { + final z = TextZoom(); + var calls = 0; + z.addListener(() => calls++); + z.increase(); + expect(z.scale, closeTo(1.0 + TextZoom.stepScale, 1e-9)); + expect(calls, 1); + }); + + test('decrease subtracts one step', () { + final z = TextZoom(); + z.decrease(); + expect(z.scale, closeTo(1.0 - TextZoom.stepScale, 1e-9)); + }); + + test('reset jumps back to 1.0', () { + final z = TextZoom()..increase()..increase(); + expect(z.scale, isNot(1.0)); + z.reset(); + expect(z.scale, 1.0); + }); + + test('clamps at minScale', () { + final z = TextZoom(); + for (var i = 0; i < 100; i++) { + z.decrease(); + } + expect(z.scale, TextZoom.minScale); + }); + + test('clamps at maxScale', () { + final z = TextZoom(); + for (var i = 0; i < 100; i++) { + z.increase(); + } + expect(z.scale, TextZoom.maxScale); + }); + + test('no-op increment does not notify', () { + final z = TextZoom(); + for (var i = 0; i < 100; i++) { + z.increase(); + } + // Already at max — the next increase shouldn't fire. + var calls = 0; + z.addListener(() => calls++); + z.increase(); + expect(calls, 0); + }); + }); +}