promote the Ctrl+K theme picker into a Settings modal

The status-bar popover already handles quick theme switching, so a
separate theme-only modal was redundant. Repurpose it as a general
Settings surface whose first (currently only) section is Appearance:
base themes, sorted, with a High-contrast toggle for -hc siblings,
reusing the theme_families helpers shared with the popover.

Command id stays theme.pick (the welcome theme-link and tests
reference it); only the title and the view it opens change.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-06 09:32:20 +02:00
co-authored by Claude
parent ffd22069a7
commit 968c3dbfa8
8 changed files with 413 additions and 169 deletions
+48
View File
@@ -281,3 +281,51 @@ Fix (scope to confirm):
Acceptance: a bogus `CLIDE_SOCK` fails loudly instead of returning data from a different instance; a valid `CLIDE_SOCK` pins the CLI to that instance; startup leaves exactly one live socket for one running app (no orphan accumulation); there is a CLI affordance to list/identify running instances.', NULL, '2026-06-05 21:30:04', '2026-06-05 21:30:04', '2026-06-05 21:30:04', NULL, '80c539f71840c0b6b07455d4fe936e45', 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-239', 'status', 'in_progress', 'done', NULL, '2026-06-06 06:44:40', '2026-06-06 06:44:40', '2026-06-06 06:44:40', NULL, '4038822bf1bb104a931939ce1a84b572', 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-238', 'status', 'backlog', 'in_progress', NULL, '2026-06-06 06:51:47', '2026-06-06 06:51:47', '2026-06-06 06:51:47', NULL, '3d2d4639b374be7a0e3e81f5e403045a', 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-248', 'description', NULL, 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable.
Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery — `clide help` is a stub, `clide <subsystem>` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there.
Scope:
- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry — e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts.
- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct.
This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register — no skill edit per panel.
Refs: D-6 (CLI/event-surface parity). Related: T-249.', NULL, '2026-06-06 07:21:30', '2026-06-06 07:21:30', '2026-06-06 07:21:30', NULL, '29a73bfc03d14ff7764f614d3c07aa3a', 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-249', 'description', NULL, 'Add an image-viewer card to the Claude conversation log, with the CLI verb that drives it (D-6 parity).
Scope:
- Conversation card: render an image inline in the Claude conversation widget — clide-owned rendering (no opinionated package), display-only per D-78 (no inline interactive controls; any controls belong in the interaction zone). Respect theme tokens / ui-design.
- Plumbing: a `clide` verb to show an image in the log (e.g. `clide image show <path>` or via the pane subsystem), accepting a workspace path; define accepted formats (PNG/JPEG/...), path resolution (workspace-relative), and sizing/scaling behavior.
- Parity (D-6): the verb is the CLI counterpart of the card; ensure it registers in the dispatcher so it shows up in the discovery verb from T-248.
Dependency: pairs with T-248 — without the /clide skill + discovery verb, Claude won''t know this card/verb exists even once shipped. Build the plumbing here; T-248 makes it discoverable.
Refs: D-6 (parity), D-78 (interaction zone, display-only conversation widgets). Related: T-248.', NULL, '2026-06-06 07:21:36', '2026-06-06 07:21:36', '2026-06-06 07:21:36', NULL, '2fc1ece427f44c640340b0ad7fbdb1ca', 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-249', 'status', 'backlog', 'ready', NULL, '2026-06-06 07:22:48', '2026-06-06 07:22:48', '2026-06-06 07:22:48', NULL, '4a539286dd5caa33660c37c9d5ebf385', 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-248', 'status', 'backlog', 'ready', NULL, '2026-06-06 07:22:48', '2026-06-06 07:22:48', '2026-06-06 07:22:48', NULL, 'e54319c011c21c6304b3fbbaab1f0d2b', 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-238', 'description', 'T-237 redesigned the status-bar theme POPOVER (theme_status_item.dart): base themes only, sorted by display name, a ''High contrast'' toggle that applies the -hc sibling, palette icon. The MODAL picker (picker_view.dart, opened by theme.pick / ctrl+k) still lists every theme including the -hc rows in available order. For consistency, apply the same treatment to picker_view: use the theme_families helpers (baseThemes / isHcName / resolveThemeName, already built + unit-tested in lib/builtin/theme_picker/src/theme_families.dart) to show base themes sorted, with a High contrast checkbox at the top. Low priority — the modal works; this is consistency polish.', 'T-237 redesigned the status-bar theme POPOVER (theme_status_item.dart): base themes only, sorted by display name, a ''High contrast'' toggle that applies the -hc sibling, palette icon. The MODAL picker (picker_view.dart, opened by theme.pick / ctrl+k) still lists every theme including the -hc rows in available order. For consistency, apply the same treatment to picker_view: use the theme_families helpers (baseThemes / isHcName / resolveThemeName, already built + unit-tested in lib/builtin/theme_picker/src/theme_families.dart) to show base themes sorted, with a High contrast checkbox at the top. Low priority — the modal works; this is consistency polish.
DONE (2026-06-06). Re-scoped per user: instead of just applying the -hc-checkbox+sort polish to the theme modal, the modal was PROMOTED to a general Settings modal (picker_view.dart -> settings_view.dart, ThemePickerView -> SettingsView). ctrl+k / theme.pick now opens Settings, whose only section today is Appearance: base themes (sorted) + a High contrast toggle, reusing the theme_families helpers (shared with the status-bar popover, T-237). Command id kept as theme.pick (welcome link + tests reference it); retitled ''Settings...''. Extensible: more sections later (or split to a builtin/settings extension when it grows). Tests in test/builtin/theme_picker/widget_test.dart (base-only list, hc toggle applies sibling). NOTE: status-bar popover (T-237) was already done — that is what the user saw ''done in the ui''.', NULL, '2026-06-06 07:24:00', '2026-06-06 07:24:00', '2026-06-06 07:24:00', NULL, '550fc661464648f8da23dbe52acdd134', 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-238', 'status', 'in_progress', 'done', NULL, '2026-06-06 07:24:00', '2026-06-06 07:24:00', '2026-06-06 07:24:00', NULL, '18cdaac49c11fa6606f456690d3cbc18', 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-250', 'description', NULL, 'Symptom: the permission-mode badge in the Claude status line does nothing visible when clicked, and Ctrl/Cmd+M (cycle mode, T-226) likewise appears to do nothing. Either the mode isn''t actually changing, or — more likely — it changes in the Claude process but the status-bar tracker is never updated.
Likely root cause (confirmed by reading the code): the cycle is fire-and-forget with no status feedback.
- Composer Ctrl/Cmd+M -> widget.onCycleMode -> _ClaudePaneState._cycleMode (lib/builtin/claude/src/claude_pane.dart:302) -> s.setPermissionMode(nextSafePermissionMode(...)).
- The badge click path (_ModeBadge.onCycle) routes to the same _cycleMode, so both symptoms share one cause.
- StreamJsonSession.setPermissionMode (lib/builtin/claude/src/stream_json_session.dart:671) only writes a `set_permission_mode` control_request to the process. It does NOT optimistically update `_status` nor emit on `_statusCtl`.
- `_status.permissionMode` is only ever set from a `system/init` event via `_statusFromEvent` (stream_json_session.dart:567-571). A set_permission_mode control_request returns a control_response, which is not folded into status — so `_status` never re-emits, the badge label (claude_pane.dart:105-122, driven by `_status` over statusStream) is stale, and the UI looks dead.
So the status bar / badge value is not updated on change — matching the reported hypothesis.
Fix direction:
- Optimistically merge the new mode into `_status` and emit on `_statusCtl` immediately after writing the control_request (mirror how other state transitions surface), and/or handle the `control_response` for set_permission_mode and reconcile from it.
- Separately verify the control_request is actually honored by the Claude process (the ''they don''t work'' branch) — if the response reports failure, surface it rather than silently assuming success.
Acceptance:
- Clicking the badge and pressing Ctrl/Cmd+M both visibly cycle default -> accept-edits -> plan -> default in the status line.
- The displayed mode reflects the session''s actual mode (reconciled from the response / next init), not just an optimistic guess.
- Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream.
Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path — keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', NULL, '2026-06-06 07:31:06', '2026-06-06 07:31:06', '2026-06-06 07:31:06', NULL, '1aa3d2ea3fd0501d699c5a26d87ac4eb', 1) ON CONFLICT(hash) DO NOTHING;
+72
View File
@@ -659,3 +659,75 @@ Acceptance: a bogus `CLIDE_SOCK` fails loudly instead of returning data from a d
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-239', 'bug', NULL, 'Status-bar right group still not hugging the center block''s right edge', 'Unresolved after T-237 (commits 643417e then reverted by dc84f4c). GOAL: in the single bottom status bar, left items (git branch ''main'', skills count) sit flush-LEFT and the global right group (ipc ''application ok'' + theme switcher) sits flush-RIGHT of the CENTER (workspace) block — NOT the window edge, and NOT two separate left-aligned columns. Keep the 3-rail bottom bar (sidebar rail | workspace status | context tab-rail). CURRENT STATE (dc84f4c): StatusbarHost is a single Container(Row[ ...left, Spacer(), ...right ]) with NO alignment, sitting in Expanded(StatusbarHost()) inside the bottom-bar Row (app.dart ~261). In theory the Spacer should right-align the right group within the workspace Expanded; in practice (verified live by the user, even after a hot RESTART) the right group does NOT hug the center block''s right edge. ATTEMPTS THAT FAILED: (a) removing ''alignment: Alignment.center'' from the StatusbarHost Container; (b) splitting into two hosts and trailing the right group past the context rail (overshot to the window edge — wrong). LEAD / NEXT STEP: instrument the actual constraints rather than guess. Prime suspect: the OUTER Column at app.dart ~214 has NO crossAxisAlignment (defaults to center), so the bottom-bar Container may receive LOOSE width and size to its content, collapsing the Spacer (right group ends up adjacent to the left group, both effectively left-aligned). Try CrossAxisAlignment.stretch on that Column (or give the status Container an explicit full width), and confirm Expanded(StatusbarHost) actually receives a bounded full-workspace width. A widget/integration test asserting the right group''s x-offset == workspace-region right edge would lock it. Files: lib/app.dart (StatusbarHost + the bottom-bar Row + the outer Column).
FIXED (2026-06-05): root cause confirmed by probe at 3440px width the bottom bar used Row[ ...left (incl. a Flexible flex:1 loose item, the Claude status marquee), Spacer(), ...right ]. The Spacer (Expanded, flex:1) and the left flex:1 item SPLIT the free space 50/50, so the Spacer only pushed the right group by HALF the free space. That drift is proportional to width: tiny at 1200px (why probes/normal screens looked fine), ~1500px at 3440px (the right group floated to mid-bar) hence ''only on ultrawide''. Probe: OLD R.right=1934 at a 3440 edge; NEW R.right=3440. FIX (app.dart StatusbarHost): explicit two-column layout left group wrapped in Expanded(Row[...]) so it absorbs ALL free space (flex item flexes within it), right group trails at intrinsic width hugs the workspace block''s right edge by construction, width-independent. Regression test test/app_statusbar_test.dart asserts the right group at width-8 for BOTH 600px and 3440px. Full fast suite green.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-03 21:41:58', '2026-06-06 06:44:40', NULL, '12e99f52a8fbd81e86cff491fcce2b29', 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-238', 'task', NULL, 'Apply the -hc-as-checkbox + sort to the ctrl+k theme modal (T-237 follow-up)', 'T-237 redesigned the status-bar theme POPOVER (theme_status_item.dart): base themes only, sorted by display name, a ''High contrast'' toggle that applies the -hc sibling, palette icon. The MODAL picker (picker_view.dart, opened by theme.pick / ctrl+k) still lists every theme including the -hc rows in available order. For consistency, apply the same treatment to picker_view: use the theme_families helpers (baseThemes / isHcName / resolveThemeName, already built + unit-tested in lib/builtin/theme_picker/src/theme_families.dart) to show base themes sorted, with a High contrast checkbox at the top. Low priority — the modal works; this is consistency polish.', 'in_progress', 'low', NULL, NULL, 'D-6', '2026-06-03 21:11:40', '2026-06-06 06:51:47', NULL, '13ce67e56195cefb843cd67352e8de03', 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-248', 'story', NULL, '/clide skill — advertise the clide tool surface to Claude', NULL, 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 07:21:17', NULL, '93050411c08275cb579a410bf915506d', 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-249', 'story', NULL, 'Image viewer conversation card + plumbing', NULL, 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 07:21:17', NULL, 'b2f7caef031f2af6a341cd2bdcca6553', 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-248', 'story', NULL, '/clide skill — advertise the clide tool surface to Claude', 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable.
Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery `clide help` is a stub, `clide <subsystem>` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there.
Scope:
- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts.
- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct.
This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register no skill edit per panel.
Refs: D-6 (CLI/event-surface parity). Related: T-249.', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 07:21:30', NULL, '6657ddeb69fd1b4c1851dba75b9ae40f', 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-249', 'story', NULL, 'Image viewer conversation card + plumbing', 'Add an image-viewer card to the Claude conversation log, with the CLI verb that drives it (D-6 parity).
Scope:
- Conversation card: render an image inline in the Claude conversation widget clide-owned rendering (no opinionated package), display-only per D-78 (no inline interactive controls; any controls belong in the interaction zone). Respect theme tokens / ui-design.
- Plumbing: a `clide` verb to show an image in the log (e.g. `clide image show <path>` or via the pane subsystem), accepting a workspace path; define accepted formats (PNG/JPEG/...), path resolution (workspace-relative), and sizing/scaling behavior.
- Parity (D-6): the verb is the CLI counterpart of the card; ensure it registers in the dispatcher so it shows up in the discovery verb from T-248.
Dependency: pairs with T-248 without the /clide skill + discovery verb, Claude won''t know this card/verb exists even once shipped. Build the plumbing here; T-248 makes it discoverable.
Refs: D-6 (parity), D-78 (interaction zone, display-only conversation widgets). Related: T-248.', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 07:21:36', NULL, 'd680715048a59598aa95144197765f1f', 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-248', 'story', NULL, '/clide skill — advertise the clide tool surface to Claude', 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable.
Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery `clide help` is a stub, `clide <subsystem>` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there.
Scope:
- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts.
- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct.
This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register no skill edit per panel.
Refs: D-6 (CLI/event-surface parity). Related: T-249.', 'ready', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 07:22:48', NULL, '0dcde6af3f6f4064aa90b4b1599f3bcb', 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-249', 'story', NULL, 'Image viewer conversation card + plumbing', 'Add an image-viewer card to the Claude conversation log, with the CLI verb that drives it (D-6 parity).
Scope:
- Conversation card: render an image inline in the Claude conversation widget clide-owned rendering (no opinionated package), display-only per D-78 (no inline interactive controls; any controls belong in the interaction zone). Respect theme tokens / ui-design.
- Plumbing: a `clide` verb to show an image in the log (e.g. `clide image show <path>` or via the pane subsystem), accepting a workspace path; define accepted formats (PNG/JPEG/...), path resolution (workspace-relative), and sizing/scaling behavior.
- Parity (D-6): the verb is the CLI counterpart of the card; ensure it registers in the dispatcher so it shows up in the discovery verb from T-248.
Dependency: pairs with T-248 without the /clide skill + discovery verb, Claude won''t know this card/verb exists even once shipped. Build the plumbing here; T-248 makes it discoverable.
Refs: D-6 (parity), D-78 (interaction zone, display-only conversation widgets). Related: T-248.', 'ready', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 07:22:48', NULL, '2961d27113d9f367b8e98da950c519ec', 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-238', 'task', NULL, 'Apply the -hc-as-checkbox + sort to the ctrl+k theme modal (T-237 follow-up)', 'T-237 redesigned the status-bar theme POPOVER (theme_status_item.dart): base themes only, sorted by display name, a ''High contrast'' toggle that applies the -hc sibling, palette icon. The MODAL picker (picker_view.dart, opened by theme.pick / ctrl+k) still lists every theme including the -hc rows in available order. For consistency, apply the same treatment to picker_view: use the theme_families helpers (baseThemes / isHcName / resolveThemeName, already built + unit-tested in lib/builtin/theme_picker/src/theme_families.dart) to show base themes sorted, with a High contrast checkbox at the top. Low priority — the modal works; this is consistency polish.
DONE (2026-06-06). Re-scoped per user: instead of just applying the -hc-checkbox+sort polish to the theme modal, the modal was PROMOTED to a general Settings modal (picker_view.dart -> settings_view.dart, ThemePickerView -> SettingsView). ctrl+k / theme.pick now opens Settings, whose only section today is Appearance: base themes (sorted) + a High contrast toggle, reusing the theme_families helpers (shared with the status-bar popover, T-237). Command id kept as theme.pick (welcome link + tests reference it); retitled ''Settings...''. Extensible: more sections later (or split to a builtin/settings extension when it grows). Tests in test/builtin/theme_picker/widget_test.dart (base-only list, hc toggle applies sibling). NOTE: status-bar popover (T-237) was already done that is what the user saw ''done in the ui''.', 'in_progress', 'low', NULL, NULL, 'D-6', '2026-06-03 21:11:40', '2026-06-06 07:24:00', NULL, 'c17e9e7ec5a97c6f7adbc537f609f339', 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-238', 'task', NULL, 'Apply the -hc-as-checkbox + sort to the ctrl+k theme modal (T-237 follow-up)', 'T-237 redesigned the status-bar theme POPOVER (theme_status_item.dart): base themes only, sorted by display name, a ''High contrast'' toggle that applies the -hc sibling, palette icon. The MODAL picker (picker_view.dart, opened by theme.pick / ctrl+k) still lists every theme including the -hc rows in available order. For consistency, apply the same treatment to picker_view: use the theme_families helpers (baseThemes / isHcName / resolveThemeName, already built + unit-tested in lib/builtin/theme_picker/src/theme_families.dart) to show base themes sorted, with a High contrast checkbox at the top. Low priority — the modal works; this is consistency polish.
DONE (2026-06-06). Re-scoped per user: instead of just applying the -hc-checkbox+sort polish to the theme modal, the modal was PROMOTED to a general Settings modal (picker_view.dart -> settings_view.dart, ThemePickerView -> SettingsView). ctrl+k / theme.pick now opens Settings, whose only section today is Appearance: base themes (sorted) + a High contrast toggle, reusing the theme_families helpers (shared with the status-bar popover, T-237). Command id kept as theme.pick (welcome link + tests reference it); retitled ''Settings...''. Extensible: more sections later (or split to a builtin/settings extension when it grows). Tests in test/builtin/theme_picker/widget_test.dart (base-only list, hc toggle applies sibling). NOTE: status-bar popover (T-237) was already done that is what the user saw ''done in the ui''.', 'done', 'low', NULL, NULL, 'D-6', '2026-06-03 21:11:40', '2026-06-06 07:24:00', NULL, 'e25b56f7c3ddbddcf94993177830c98e', 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-250', 'bug', NULL, 'Permission-mode badge + Ctrl-M cycle don''t update the status bar', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:26:50', NULL, '28e51870658c6c7ca40f57b68d4cd618', 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-250', 'bug', NULL, 'Permission-mode badge + Ctrl-M cycle don''t update the status bar', 'Symptom: the permission-mode badge in the Claude status line does nothing visible when clicked, and Ctrl/Cmd+M (cycle mode, T-226) likewise appears to do nothing. Either the mode isn''t actually changing, or — more likely — it changes in the Claude process but the status-bar tracker is never updated.
Likely root cause (confirmed by reading the code): the cycle is fire-and-forget with no status feedback.
- Composer Ctrl/Cmd+M -> widget.onCycleMode -> _ClaudePaneState._cycleMode (lib/builtin/claude/src/claude_pane.dart:302) -> s.setPermissionMode(nextSafePermissionMode(...)).
- The badge click path (_ModeBadge.onCycle) routes to the same _cycleMode, so both symptoms share one cause.
- StreamJsonSession.setPermissionMode (lib/builtin/claude/src/stream_json_session.dart:671) only writes a `set_permission_mode` control_request to the process. It does NOT optimistically update `_status` nor emit on `_statusCtl`.
- `_status.permissionMode` is only ever set from a `system/init` event via `_statusFromEvent` (stream_json_session.dart:567-571). A set_permission_mode control_request returns a control_response, which is not folded into status so `_status` never re-emits, the badge label (claude_pane.dart:105-122, driven by `_status` over statusStream) is stale, and the UI looks dead.
So the status bar / badge value is not updated on change matching the reported hypothesis.
Fix direction:
- Optimistically merge the new mode into `_status` and emit on `_statusCtl` immediately after writing the control_request (mirror how other state transitions surface), and/or handle the `control_response` for set_permission_mode and reconcile from it.
- Separately verify the control_request is actually honored by the Claude process (the ''they don''t work'' branch) if the response reports failure, surface it rather than silently assuming success.
Acceptance:
- Clicking the badge and pressing Ctrl/Cmd+M both visibly cycle default -> accept-edits -> plan -> default in the status line.
- The displayed mode reflects the session''s actual mode (reconciled from the response / next init), not just an optimistic guess.
- Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream.
Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:31:06', NULL, 'e555fe45be44f3a22e30005370aa6ec0', 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);
+3
View File
@@ -100,6 +100,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed
- ⌘K now opens a **Settings** modal instead of a theme-only picker. Its first
(currently only) section is Appearance — base themes, sorted, with a High
contrast toggle for `-hc` siblings — matching the status-bar switcher. (T-238)
- The pql search panel merged into the Search tab, which now has modes: Find
(content grep), Vault (pql ranked search), Query (PQL DSL), and Markdown (the
synced file listing). The standalone pql sidebar tab is gone; Backlinks stays
+6 -3
View File
@@ -1,5 +1,5 @@
import 'package:clide/clide.dart';
import 'package:clide/builtin/theme_picker/src/picker_view.dart';
import 'package:clide/builtin/theme_picker/src/settings_view.dart';
import 'package:clide/builtin/theme_picker/src/theme_status_item.dart';
import 'package:clide/extension/extension.dart';
@@ -20,10 +20,13 @@ class ThemePickerExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
// Opens the settings modal (T-238). Command id kept as `theme.pick`
// (the welcome theme-link and other callers reference it); ⌘K opens
// Settings, whose only section today is the theme picker.
CommandContribution(
id: 'theme.pick',
command: 'theme.pick',
title: 'Theme: Pick',
title: 'Settings',
defaultBinding: 'ctrl+k',
run: _pick,
),
@@ -50,7 +53,7 @@ class ThemePickerExtension extends ClideExtension {
);
}
final selected = await ctx.dialog.show<String>(
(context, dismiss) => ThemePickerView(
(context, dismiss) => SettingsView(
controller: ctx.theme,
onDismiss: dismiss,
),
@@ -1,159 +0,0 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ThemePickerView extends StatefulWidget {
const ThemePickerView({
super.key,
required this.controller,
required this.onDismiss,
});
final ThemeController controller;
final void Function([String? selected]) onDismiss;
static const ns = 'builtin.theme-picker';
@override
State<ThemePickerView> createState() => _ThemePickerViewState();
}
class _ThemePickerViewState extends State<ThemePickerView> {
String? _hovered;
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
final themes = widget.controller.available;
final currentName = widget.controller.currentName;
final i = kernel.i18n;
return Semantics(
container: true,
label: i.string('modal.title', namespace: ThemePickerView.ns, placeholder: 'Select theme'),
explicitChildNodes: true,
child: ClideSurface(
width: 420,
color: tokens.modalSurfaceBackground,
border: tokens.modalSurfaceBorder,
padding: const EdgeInsets.all(16),
borderRadius: BorderRadius.circular(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClideText(
i.string('modal.title', namespace: ThemePickerView.ns, placeholder: 'Select theme'),
fontSize: 15,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 8),
ClideDivider(),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 360),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final t in themes)
_ThemeRow(
name: t.name,
displayName: t.displayName,
selected: t.name == currentName,
hovered: _hovered == t.name,
hint: i.string('row.select.hint', namespace: ThemePickerView.ns, placeholder: 'Activate this theme'),
onEnter: () => setState(() => _hovered = t.name),
onExit: () => setState(() => _hovered = null),
onTap: () {
widget.controller.select(t.name);
widget.onDismiss(t.name);
},
),
],
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(
label: i.string('modal.cancel', namespace: ThemePickerView.ns, placeholder: 'Cancel'),
semanticHint:
i.string('modal.cancel.hint', namespace: ThemePickerView.ns, placeholder: 'Close the theme picker without changing the current theme'),
onPressed: () => widget.onDismiss(),
),
],
),
],
),
),
);
}
}
class _ThemeRow extends StatelessWidget {
const _ThemeRow({
required this.name,
required this.displayName,
required this.selected,
required this.hovered,
required this.hint,
required this.onEnter,
required this.onExit,
required this.onTap,
});
final String name;
final String displayName;
final bool selected;
final bool hovered;
final String hint;
final VoidCallback onEnter;
final VoidCallback onExit;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final bg = selected ? tokens.listItemSelectedBackground : (hovered ? tokens.listItemHoverBackground : tokens.listItemBackground);
final fg = selected ? tokens.listItemSelectedForeground : tokens.listItemForeground;
return Semantics(
button: true,
selected: selected,
label: displayName,
hint: hint,
onTap: onTap,
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => onEnter(),
onExit: (_) => onExit(),
child: GestureDetector(
onTap: onTap,
child: Container(
color: bg,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
if (selected)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideIcon(const CheckIcon(), size: 12, color: fg),
)
else
const SizedBox(width: 20),
Expanded(
child: ClideText(displayName, color: fg),
),
ClideText(name, color: tokens.globalTextMuted, fontSize: clideFontCaption),
],
),
),
),
),
);
}
}
@@ -0,0 +1,247 @@
import 'package:clide/builtin/theme_picker/src/theme_families.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// The ⌘K settings modal. Promoted from the old theme-only picker (T-238): a
/// general Settings surface that today holds a single **Appearance** section —
/// the theme list. Like the status-bar popover (T-237) it shows base themes
/// only (sorted) with a "High contrast" toggle that maps to the `-hc` sibling
/// (D-69); the shared `theme_families` helpers keep both surfaces in sync.
/// Selecting a theme applies it live and dismisses; Cancel just closes.
class SettingsView extends StatefulWidget {
const SettingsView({
super.key,
required this.controller,
required this.onDismiss,
});
final ThemeController controller;
final void Function([String? selected]) onDismiss;
static const ns = 'builtin.theme-picker';
@override
State<SettingsView> createState() => _SettingsViewState();
}
class _SettingsViewState extends State<SettingsView> {
String? _hovered;
late bool _hc;
@override
void initState() {
super.initState();
_hc = isHcName(widget.controller.currentName);
}
void _toggleHc() {
setState(() => _hc = !_hc);
// Re-apply the current base with the new variant, live.
final base = baseThemeName(widget.controller.currentName);
widget.controller.select(resolveThemeName(widget.controller.available, base, highContrast: _hc));
}
void _pick(ThemeDefinition base) {
widget.controller.select(resolveThemeName(widget.controller.available, base.name, highContrast: _hc));
widget.onDismiss(base.name);
}
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
final i = kernel.i18n;
final themes = baseThemes(widget.controller.available);
final currentBase = baseThemeName(widget.controller.currentName);
final title = i.string('modal.title', namespace: SettingsView.ns, placeholder: 'Settings');
return Semantics(
container: true,
label: title,
explicitChildNodes: true,
child: ClideSurface(
width: 420,
color: tokens.modalSurfaceBackground,
border: tokens.modalSurfaceBorder,
padding: const EdgeInsets.all(16),
borderRadius: BorderRadius.circular(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClideText(title, fontSize: 15, fontWeight: FontWeight.w600),
const SizedBox(height: 8),
ClideDivider(),
const SizedBox(height: 12),
// --- Appearance section (the only one for now) ---
ClideText(
i.string('section.appearance', namespace: SettingsView.ns, placeholder: 'Appearance'),
fontSize: clideFontCaption,
color: tokens.sidebarSectionHeader,
fontFamily: clideMonoFamily,
),
const SizedBox(height: 8),
_HighContrastToggle(
checked: _hc,
label: i.string('toggle.highContrast', namespace: SettingsView.ns, placeholder: 'High contrast'),
onTap: _toggleHc,
),
const SizedBox(height: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final t in themes)
_ThemeRow(
name: t.name,
displayName: t.displayName,
selected: t.name == currentBase,
hovered: _hovered == t.name,
hint: i.string('row.select.hint', namespace: SettingsView.ns, placeholder: 'Activate this theme'),
onEnter: () => setState(() => _hovered = t.name),
onExit: () => setState(() => _hovered = null),
onTap: () => _pick(t),
),
],
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(
label: i.string('modal.cancel', namespace: SettingsView.ns, placeholder: 'Cancel'),
semanticHint: i.string('modal.cancel.hint', namespace: SettingsView.ns, placeholder: 'Close settings without changing anything'),
onPressed: () => widget.onDismiss(),
),
],
),
],
),
),
);
}
}
/// "High contrast" checkbox row — toggles the `-hc` sibling for the current
/// theme (mirrors the status-bar popover's toggle).
class _HighContrastToggle extends StatefulWidget {
const _HighContrastToggle({required this.checked, required this.label, required this.onTap});
final bool checked;
final String label;
final VoidCallback onTap;
@override
State<_HighContrastToggle> createState() => _HighContrastToggleState();
}
class _HighContrastToggleState extends State<_HighContrastToggle> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
checked: widget.checked,
label: widget.label,
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
color: _hover ? tokens.listItemHoverBackground : null,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Container(
width: 13,
height: 13,
decoration: BoxDecoration(
color: widget.checked ? tokens.buttonBackground : null,
border: Border.all(color: widget.checked ? tokens.buttonBackground : tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(3),
),
child: widget.checked ? ClideIcon(const CheckIcon(), size: 9, color: tokens.buttonForeground) : null,
),
const SizedBox(width: 8),
Expanded(child: ClideText(widget.label, color: tokens.listItemForeground)),
],
),
),
),
),
);
}
}
class _ThemeRow extends StatelessWidget {
const _ThemeRow({
required this.name,
required this.displayName,
required this.selected,
required this.hovered,
required this.hint,
required this.onEnter,
required this.onExit,
required this.onTap,
});
final String name;
final String displayName;
final bool selected;
final bool hovered;
final String hint;
final VoidCallback onEnter;
final VoidCallback onExit;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final bg = selected ? tokens.listItemSelectedBackground : (hovered ? tokens.listItemHoverBackground : tokens.listItemBackground);
final fg = selected ? tokens.listItemSelectedForeground : tokens.listItemForeground;
return Semantics(
button: true,
selected: selected,
label: displayName,
hint: hint,
onTap: onTap,
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => onEnter(),
onExit: (_) => onExit(),
child: GestureDetector(
onTap: onTap,
child: Container(
color: bg,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
if (selected)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideIcon(const CheckIcon(), size: 12, color: fg),
)
else
const SizedBox(width: 20),
Expanded(
child: ClideText(displayName, color: fg),
),
ClideText(name, color: tokens.globalTextMuted, fontSize: clideFontCaption),
],
),
),
),
),
);
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
export 'src/extension.dart';
export 'src/picker_view.dart';
export 'src/settings_view.dart';
+36 -6
View File
@@ -36,7 +36,9 @@ void main() {
i18nCatalogs: {
'builtin.theme-picker': {
const Locale('en', 'US'): const {
'modal.title': {'translation': 'Select theme'},
'modal.title': {'translation': 'Settings'},
'section.appearance': {'translation': 'Appearance'},
'toggle.highContrast': {'translation': 'High contrast'},
'modal.cancel': {'translation': 'Cancel'},
'modal.cancel.hint': {'translation': 'Dismiss'},
'row.select.hint': {'translation': 'Activate this theme'},
@@ -63,11 +65,11 @@ void main() {
);
});
testWidgets('modal lists every bundled theme', (tester) async {
testWidgets('settings modal lists base themes + a High contrast toggle', (tester) async {
await tester.pumpWidget(
harness(
f,
ThemePickerView(
SettingsView(
controller: f.services.theme,
onDismiss: ([_]) {},
),
@@ -77,7 +79,10 @@ void main() {
// test fixtures so the label appears twice per row.
expect(find.text('summer-night'), findsNWidgets(2));
expect(find.text('forest'), findsNWidgets(2));
expect(find.text('Select theme'), findsOneWidget);
// Promoted to a Settings modal with an Appearance section (T-238).
expect(find.text('Settings'), findsOneWidget);
expect(find.text('Appearance'), findsOneWidget);
expect(find.text('High contrast'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
});
@@ -86,7 +91,7 @@ void main() {
await tester.pumpWidget(
harness(
f,
ThemePickerView(
SettingsView(
controller: f.services.theme,
onDismiss: ([v]) => dismissed = v,
),
@@ -103,7 +108,7 @@ void main() {
await tester.pumpWidget(
harness(
f,
ThemePickerView(
SettingsView(
controller: f.services.theme,
onDismiss: ([v]) => dismissed = v,
),
@@ -114,6 +119,31 @@ void main() {
expect(dismissed, isNull);
});
testWidgets('settings modal hides -hc siblings; High contrast applies them (T-238)', (tester) async {
// KernelFixture.create does real file I/O (temp dir + boot); awaiting it
// inside the testWidgets fake-async body would hang (the T-122 trap), so
// build it on the real event loop via runAsync.
late KernelFixture hf;
await tester.runAsync(() async => hf = await KernelFixture.create(bundledThemes: [_def('paper'), _def('paper-hc')]));
addTearDown(hf.dispose);
await tester.pumpWidget(
harness(
hf,
SettingsView(controller: hf.services.theme, onDismiss: ([_]) {}),
),
);
// Base theme listed once (displayName + muted name); the -hc sibling is
// folded into the toggle, not shown as a row.
expect(find.text('paper'), findsNWidgets(2));
expect(find.text('paper-hc'), findsNothing);
// Toggling High contrast applies the -hc sibling live.
expect(hf.services.theme.currentName, 'paper');
await tester.tap(find.bySemanticsLabel('High contrast'));
await tester.pump();
expect(hf.services.theme.currentName, 'paper-hc');
});
testWidgets('status switcher shows the active theme and opens a popover (T-234)', (tester) async {
await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem()));
await tester.pump();