diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 8364741c..979bf7a7 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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 ` 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 ` 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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 04d0db32..6ff382a3 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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 ` 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 ` 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 ` 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 ` 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a08ce95..b5d42003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/theme_picker/src/extension.dart b/lib/builtin/theme_picker/src/extension.dart index 376db4d6..8a52b6a3 100644 --- a/lib/builtin/theme_picker/src/extension.dart +++ b/lib/builtin/theme_picker/src/extension.dart @@ -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 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( - (context, dismiss) => ThemePickerView( + (context, dismiss) => SettingsView( controller: ctx.theme, onDismiss: dismiss, ), diff --git a/lib/builtin/theme_picker/src/picker_view.dart b/lib/builtin/theme_picker/src/picker_view.dart deleted file mode 100644 index 34e9369b..00000000 --- a/lib/builtin/theme_picker/src/picker_view.dart +++ /dev/null @@ -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 createState() => _ThemePickerViewState(); -} - -class _ThemePickerViewState extends State { - 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), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/builtin/theme_picker/src/settings_view.dart b/lib/builtin/theme_picker/src/settings_view.dart new file mode 100644 index 00000000..b3f70d67 --- /dev/null +++ b/lib/builtin/theme_picker/src/settings_view.dart @@ -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 createState() => _SettingsViewState(); +} + +class _SettingsViewState extends State { + 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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/builtin/theme_picker/theme_picker.dart b/lib/builtin/theme_picker/theme_picker.dart index 9a298824..a0ea0100 100644 --- a/lib/builtin/theme_picker/theme_picker.dart +++ b/lib/builtin/theme_picker/theme_picker.dart @@ -1,2 +1,2 @@ export 'src/extension.dart'; -export 'src/picker_view.dart'; +export 'src/settings_view.dart'; diff --git a/test/builtin/theme_picker/widget_test.dart b/test/builtin/theme_picker/widget_test.dart index 105ca237..fdf446e5 100644 --- a/test/builtin/theme_picker/widget_test.dart +++ b/test/builtin/theme_picker/widget_test.dart @@ -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();