#+TITLE: Spec: a configurable default view #+AUTHOR: Craig Jennings #+DATE: 2026-06-02 * Status *Ready.* Codex review incorporated (2026-06-02, =Not ready= → resolved); the one blocker (HP1: the transient set-default-view key was unspecified and =D= is taken by save-locally) is settled by binding the transient suffix =.=, and the medium finding (MP1: missing test-surface drop-in task) is folded in. See *Review dispositions*. Triggered by the 2026-06-02 keybindings reconciliation: that work reserved =C-; L v D= for a not-yet-built set-default-view command and flagged the open question of whether the hot key =l= should run a configurable default instead of being hardwired to "my open issues." This spec settles four decisions before code — the value model, per-account vs global, the =l=-rebind question, and persistence — and is deliberately light: the feature is local config plus dispatch, with no external API surface, so no live probe is needed. Closest precedent in size is [[file:multi-state-filter-spec.org][multi-state-filter-spec.org]] (one short review round). * Problem Pearl's default fetch is hardwired. =pearl-list-issues= with no project runs =(:assignee :me :open t)= — "my open issues" (=pearl--list-issues-source=, =pearl.el:6100=) — and it's bound to the hot key =l= and to =C-; L f o=. There's no way to say "when I open Pearl, show me /this/ instead" — a particular local view, or a narrower slice than everything assigned to me. Most people live in one view. For one user it's their open issues; for another it's a curated local view ("My active sprint work"), or a team board they've saved locally. Today the second user runs =C-; L v l= and picks the view by name every time, while =l= — the shortest key — runs a fetch they rarely want. The goal: let =l= open whatever the user has chosen as their default, defaulting to "my open issues" so nothing changes for anyone who never sets one. * The four decisions These are the design calls worth settling on paper. The first is load-bearing (getting it wrong means a defcustom-shape change plus migration); the rest are contained. ** Decision 1 — value model: nil or a local-view name The default is one of: - *unset (nil)* — "my open issues", today's behavior. - *a local-view name* (a string keying =pearl-local-views=) — run that local view. A Linear Custom View is /not/ a direct default value. To default to a Linear view, save it locally first (=pearl-save-linear-view-locally=, copy-down) and set the resulting local view as the default. This keeps v1 from needing a second identification scheme (Linear views are id-keyed and need a fetch to resolve a name), and it costs the user nothing they can't already do. The defcustom type mirrors =pearl-default-account= exactly: =(choice (const :tag "My open issues" nil) (string :tag "Local view name"))=. ** Decision 2 — per-account, not global (the load-bearing one) Local views carry an optional =:account= stamp and refuse to run under a different active account (=pearl--require-local-view-account=, =pearl.el:4349=). A single global default naming an account-tagged local view would therefore /refuse/ whenever a different account is active — the default would be broken half the time in a multi-account setup. So the default is *per-account when accounts are configured, global otherwise*, resolved through the active-account context the same way =:default-team-id= already is: - *Accounts mode* (=pearl-accounts= set): the default lives in each account's plist as =:default-view=, alongside the existing =:default-team-id= and =:org-file=. Each account names its own default, so a tagged local view is always run under the account it belongs to — the cross-account refusal can't fire. - *Legacy mode* (=pearl-accounts= nil): the default lives in a standalone =pearl-default-view= defcustom. This parallels the existing =pearl-default-account= / per-account split exactly: a durable global preference for the simple case, per-account state for the multi-workspace case. The alternative (one global default that silently falls back to my-open-issues when its view is cross-account) was rejected — it makes =l= mean different things depending on which account is active, which is the confusion the per-account model removes. ** Decision 3 — rebind =l= to the default; keep =pearl-list-issues= literal A new command =pearl-open-default-view= resolves the active account's default and dispatches: nil → run my-open-issues (=pearl-list-issues= with no project), a name → run that local view (=pearl-run-local-view=). Top-level =l= rebinds from =pearl-list-issues= to =pearl-open-default-view=. =pearl-list-issues= is *not* overloaded. It keeps its exact contract ("my open issues, assignee=me") because =pearl-list-issues-by-project= calls it with a project id and the literal fetch still needs a home — it stays at =C-; L f o=. So: - =C-; L l= (hot key) → =pearl-open-default-view= — your default. - =C-; L f o= → =pearl-list-issues= — literal my-open-issues, unchanged. Back-compat holds by construction: with the default unset (every current config), =pearl-open-default-view= runs my-open-issues, so =l= does exactly what it does today. Setting a default is the only thing that changes =l='s behavior. *Transient key for set-default-view.* The prefix keymap reserves =C-; L v D= (D = Default) for =pearl-set-default-view=. The transient =pearl-menu= can't reuse =D= in its Views group: it's a flat keyspace and =D= already runs =pearl-save-linear-view-locally= there. So the transient binds =pearl-set-default-view= to =.= in the Views group ("set the current/default view"). The mismatch is expected and not worth re-keying the just-shipped save-locally suffix to fix: the keybinding reconciliation established that the transient's per-command letters are read off the screen and don't track the keymap's two-key chains. =v D= is the chain you type from memory; =.= is the slot you see in the menu. (The key is a free-slot pick, easy to change at implementation if a better one surfaces.) ** Decision 4 — persistence: durable via customize A default is a durable preference, so the setter persists it the way =pearl-default-account= is meant to be set: through =customize-save-variable= so it survives a restart. In accounts mode the setter rewrites the active account's entry in =pearl-accounts= (updating its =:default-view= key) and saves; in legacy mode it sets and saves =pearl-default-view=. Session-only setting (a plain =setq= that evaporates on restart) is not the default behavior — a "default" the user has to re-set every session isn't one. The accounts-mode rewrite is *metadata-preserving*: it updates only =:default-view= on the active account's plist and leaves every other key (=:api-key-source=, =:org-file=, =:default-team-id=, =:url=, and anything the user added) intact, the same way local-view edits preserve unrelated metadata. And =pearl-accounts='s docstring and =:value-type= gain =:default-view= so the new key is visible to a Customize reader rather than silently undocumented. * Proposed commands ** =pearl-set-default-view= (the reserved =C-; L v D=) Sets the active account's default view and persists it. Two entry paths: - *From a Pearl buffer whose source is a local view* — offers that view as the default ("Set 'My active sprint work' as the default view?"). The obvious "make what I'm looking at my default" gesture. - *Otherwise* — =completing-read= over =pearl-local-views= names, with a =[ My open issues ]= sentinel at the top that clears the default back to nil. Cancelling leaves the default unchanged. Writes through =customize-save-variable= (Decision 4). In accounts mode it targets the active account's =:default-view=; in legacy mode =pearl-default-view=. ** =pearl-open-default-view= (the hot key =l=) Resolves the active account's default (Decision 2) and dispatches (Decision 3): - nil → =pearl-list-issues= (my open issues). - a local-view name that exists in =pearl-local-views= → =pearl-run-local-view= on it. - a local-view name that no longer exists (deleted since it was set) → fall back to =pearl-list-issues= with a one-line message ("Default view 'X' no longer exists; showing my open issues"). Never errors — a stale default degrades to the universal default rather than blocking the shortest key in the keymap. A local view named as a per-account default is, by Decision 2, always tagged to (or shared with) the account it's stored under, so =pearl-run-local-view='s account guard passes by construction. * Current state - =pearl--list-issues-source= / =pearl-list-issues= (=pearl.el:6100-6134=) — the hardwired my-open-issues fetch; =l= and =f o= both point here today. - =pearl-run-local-view= (=pearl.el:3857=) — runs a named local view through the account guard; the dispatch target for a name-valued default. - =pearl-accounts= (=pearl.el:105=) — per-account plist; gains an optional =:default-view= key. =pearl-default-account= (=pearl.el:124=) is the value-type and persistence precedent. - =pearl-prefix-map= (=pearl.el:6748=) — top-level =l= rebinds to =pearl-open-default-view=; =v D= (currently reserved/unbound) binds =pearl-set-default-view=; =f o= unchanged. - The transient =pearl-menu= Views group currently binds =D= to =pearl-save-linear-view-locally= (=pearl-views-map= separately reserves =v D= for set-default-view). The transient gains a new =.= suffix for =pearl-set-default-view= (see Decision 3); the existing =D= suffix is untouched. *Unaffected:* =pearl-list-issues-by-project= (still calls =pearl-list-issues= with a project id), =pearl-refresh-current-view= (re-runs the buffer's recorded =#+LINEAR-SOURCE=, which already records whichever view was opened — a default-opened view refreshes like any other). * Migration None for existing configs. With no =:default-view= on any account and =pearl-default-view= nil, =pearl-open-default-view= runs my-open-issues, so =l= is unchanged. The feature is purely additive: a new defcustom (legacy), a new optional account plist key, and two new commands. No stored data changes shape. * Acceptance criteria - With no default set (legacy mode, =pearl-default-view= nil): =pearl-open-default-view= runs my-open-issues — same source plist =pearl-list-issues= produces today. - With =pearl-default-view= set to a local-view name: =pearl-open-default-view= runs that local view (same path as =pearl-run-local-view=). - With a default naming a non-existent local view: =pearl-open-default-view= falls back to my-open-issues and messages; it does not error. - Accounts mode: the default resolves from the *active* account's =:default-view=; switching accounts switches which default =l= opens. - Accounts mode: a default naming an account-tagged local view runs without tripping the cross-account guard. - =pearl-set-default-view= from a local-view buffer offers that view; elsewhere it prompts over local-view names with a clear-to-my-open-issues sentinel. - =pearl-set-default-view= persists through =customize-save-variable= — the choice survives a fresh Emacs (verified by re-reading the saved custom value, not a live =setq=). - Accounts mode: setting the default rewrites only =:default-view= on the active account and leaves the account's other plist keys (=:api-key-source=, =:org-file=, =:default-team-id=, =:url=, any extras) intact. - =pearl-accounts='s docstring and =:value-type= document the =:default-view= key. - =pearl-list-issues= is unchanged in contract and still reachable at =C-; L f o=; =pearl-list-issues-by-project= still works. - Top-level =C-; L l= resolves to =pearl-open-default-view=; =C-; L v D= resolves to =pearl-set-default-view=; the transient Views group exposes set-default-view under =.= with no duplicate suffix. - Full ERT suite green, =make lint= and byte-compile clean. * Implementation phases (commits) 1. *Resolver + open command* — =pearl-default-view= defcustom (legacy) + =:default-view= account-plist key (documented in =pearl-accounts='s docstring and =:value-type=); a =pearl--resolve-default-view= helper that returns the active scope's default (per-account in accounts mode, the defcustom in legacy); =pearl-open-default-view= dispatching nil → my-open-issues, name → local view, stale-name → fallback+message. Unit tests for the resolver (both modes) and the dispatch/fallback. (=feat:=) 2. *Setter* — =pearl-set-default-view= with the from-buffer and prompt paths, persisting via =customize-save-variable= to the right scope. The accounts-mode rewrite preserves unrelated account plist keys (metadata-preserving). Tests for scope targeting, plist-key preservation, and the clear-to-nil sentinel. (=feat:=) 3. *Keymap + transient + docs* — rebind top-level =l= to =pearl-open-default-view=, bind =v D= to =pearl-set-default-view=, add the =.= set-default-view suffix to the transient Views group; keymap and menu tests updated (=v D= and transient =.= resolve, no duplicate suffix); README documents the default view, the =l= behavior, and the per-account scoping. (=feat:= + doc changes ride along) * Out of scope (vNext) - A Linear Custom View as a direct default value (save it locally first; see Decision 1). - Auto-opening the default view on Emacs startup or on visiting the account's org file — v1 is the command + the =l= binding only. - An ad-hoc filter (not saved as a local view) as a default. - Per-project or context-sensitive defaults. * Implementation tasks (drop-in for todo.org) #+begin_src org ,** TODO [#B] Default view — resolver + open command :feature: =pearl-default-view= defcustom (legacy) + =:default-view= account-plist key; =pearl--resolve-default-view= returns the active scope's default (per-account in accounts mode, defcustom in legacy); =pearl-open-default-view= dispatches nil → my-open-issues, a local-view name → =pearl-run-local-view=, a stale name → my-open-issues + message (never errors). Spec: [[file:docs/default-view-spec.org]] (phase 1). ,** TODO [#B] Default view — set-default-view command :feature: =pearl-set-default-view= (the reserved =C-; L v D=): from a local-view buffer offers that view; otherwise prompts over local-view names with a clear-to-my-open-issues sentinel. Persists via =customize-save-variable= to the active account's =:default-view= (accounts mode) or =pearl-default-view= (legacy). Spec: [[file:docs/default-view-spec.org]] (phase 2). ,** TODO [#B] Default view — keymap, transient, docs :feature: Rebind top-level =C-; L l= to =pearl-open-default-view= (=f o= stays literal my-open-issues); bind =C-; L v D= to =pearl-set-default-view=; add the =.= set-default-view suffix to the transient Views group (=D= stays save-locally). Update keymap + menu tests + README (default view, =l= behavior, per-account scoping). Spec: [[file:docs/default-view-spec.org]] (phase 3). ,** TODO [#B] Default view — test surface :test: Unit: =pearl--resolve-default-view= returns =pearl-default-view= in legacy mode and the active account's =:default-view= in accounts mode; the accounts-mode setter rewrite updates only =:default-view= and preserves other plist keys; stale-name fallback. Command flow: nil default dispatches to =pearl-list-issues= (no project), an existing local-view name to =pearl-run-local-view=, a stale name to =pearl-list-issues= with the fallback message; setter from a local-view buffer offers that view, from elsewhere prompts with the clear sentinel, cancel leaves state unchanged; persistence calls =customize-save-variable= for the right scope. Surface: =C-; L l= → =pearl-open-default-view=, =C-; L f o= → =pearl-list-issues=, =C-; L v D= → =pearl-set-default-view=, transient Views =.= → set-default-view with no duplicate suffix. E2e/manual-verify: =C-; L l= opens the configured default; switching accounts switches which default opens. Spec: [[file:docs/default-view-spec.org]] (Acceptance criteria). #+end_src * Review dispositions Codex returned =Not ready= with one blocker and one medium; both accepted. - *HP1 (transient set-default-view key unspecified; =D= taken) — accepted.* The spec now binds the transient suffix =.= for =pearl-set-default-view= in the Views group, leaves the shipped =D= = save-locally suffix untouched, and explains why the transient key differs from the keymap's =v D= (flat keyspace, read off the screen — the same surface-divergence the keybinding reconciliation established). Decision 3, the Current-state bullet, acceptance, phase 3, and the phase-3 drop-in task all name =.= now. The key is a free-slot pick, flagged as easy to change at implementation. - *MP1 (missing =:test:= drop-in task) — accepted.* Added a fourth drop-in task, =Default view — test surface :test:=, mirroring the acceptance criteria (resolver in both modes, plist-key preservation, stale-name fallback, dispatch, setter paths, persistence, surface, e2e). Supporting recommendations folded in: the Current-state transient fact corrected to the real binding (=D= = save-locally, =v D= reserved); the metadata-preserving account rewrite (preserve unrelated plist keys) added to Decision 4, acceptance, phase 2, and the test surface; =pearl-accounts='s docstring/=:value-type= documenting =:default-view= added to acceptance and phase 1. The remaining test-strategy points were already covered by acceptance and the new test-surface task. Status raised to Ready. * Review and iteration history ** 2026-06-02 Tue @ 12:18:21 -0500 — Codex — reviewer - *What changed or was recommended:* Reviewed the spec against the current local-view, account, issue-list, keymap, transient, README, tests, and task-tracking surfaces. Verdict: =Not ready=. One high-priority blocker: the spec asks the transient Views group to expose =pearl-set-default-view= but does not choose a flat transient key, and =D= is already used by =pearl-save-linear-view-locally=. One medium finding: the drop-in task block needs the required test-surface entry. - *Why:* The core feature design is coherent, but command-surface keys are visible UX and must not be invented during implementation. The task handoff also needs a dedicated test entry so acceptance coverage survives into =todo.org=. - *Artifacts:* =default-view-spec-review.org= (consumed and deleted by the response pass below). ** 2026-06-02 Tue @ 12:30:50 -0500 — Claude Code (pearl) — responder - *What changed:* Accepted both findings. HP1: bound the transient suffix =.= to =pearl-set-default-view= (keeping =D= = save-locally), with Decision 3 explaining the keymap/transient key divergence; updated the Current-state bullet, acceptance, phase 3, and the phase-3 drop-in task to name =.=. MP1: added the fourth =:test:= drop-in task. Folded the supporting recommendations: corrected the Current-state transient fact, added the metadata-preserving account rewrite to Decision 4 / acceptance / phase 2 / test surface, and added =pearl-accounts= docstring/=:value-type= documentation to acceptance and phase 1. Raised status to Ready. - *Why:* The blocker was a real visible-UX gap — naming the transient key now keeps the implementer from inventing command surface mid-build. The plist-preservation and docstring points prevent a setter that clobbers account config or an undocumented Customize key. - *Artifacts:* Review file deleted on completion.