lift text-zoom into kernel, surface it in the palette (T-114)
test / unit + widget + golden + a11y (push) Failing after 30s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m1s
test / unit + widget + golden + a11y (push) Failing after 30s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m1s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
+8
-9
@@ -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: <Type, Action<Intent>>{
|
||||
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
widget.services.textZoom.increase();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
widget.services.textZoom.decrease();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
|
||||
onInvoke: (_) {
|
||||
setState(() => _textScale = 1.0);
|
||||
widget.services.textZoom.reset();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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<void> activate(ClideExtensionContext ctx) async {}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> 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});
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.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';
|
||||
|
||||
@@ -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<KernelServices> 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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> main() async {
|
||||
..register(CanvasExtension())
|
||||
..register(GraphExtension())
|
||||
// UI extensions
|
||||
..register(ViewExtension(textZoom: services.textZoom))
|
||||
..register(SettingsUiExtension())
|
||||
..register(ExtensionsUiExtension())
|
||||
..register(KeybindingsUiExtension())
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user