aboutsummaryrefslogtreecommitdiff
path: root/patterns
diff options
context:
space:
mode:
Diffstat (limited to 'patterns')
-rw-r--r--patterns/README.org50
-rw-r--r--patterns/collapse-orthogonal-prompts.org47
-rw-r--r--patterns/default-most-common-friction-proportional.org33
-rw-r--r--patterns/label-matches-behavior.org32
-rw-r--r--patterns/no-empty-input-as-meaningful.org44
-rw-r--r--patterns/one-prompt-picker-typed-prefix.org45
-rw-r--r--patterns/transient-state-buttons.org29
7 files changed, 280 insertions, 0 deletions
diff --git a/patterns/README.org b/patterns/README.org
new file mode 100644
index 0000000..ac454c2
--- /dev/null
+++ b/patterns/README.org
@@ -0,0 +1,50 @@
+#+TITLE: Pattern Catalog
+
+Reusable interaction-design patterns, one file per pattern. Each is a small principle with wide surface area, found in one project and written down so the next one doesn't re-derive it.
+
+The catalog lives in rulesets because every project's agent already pulls from here. The thin pointer rule =claude-rules/patterns.md= tells the agent the catalog exists and when to consult it. The agent reads a single pattern file on demand rather than carrying all of them. Humans grep this directory.
+
+* The root principle
+
+The six seed patterns are one principle in different shapes:
+
+#+begin_quote
+The choices the user has should all be on screen, accurately labeled, ordered by what they'll most often want, with friction sized to the cost of being wrong.
+#+end_quote
+
+* The catalog
+
+| Pattern | Principle |
+|---------+-----------|
+| [[file:one-prompt-picker-typed-prefix.org][one-prompt-picker-typed-prefix]] | When the kind of candidate matters as much as its name, put kind and name in one picker with a typed prefix. Pick once, no disambiguation chain. |
+| [[file:transient-state-buttons.org][transient-state-buttons]] | Put all the levers in one place with their state and key affordances visible, reachable by one chord from anywhere. |
+| [[file:no-empty-input-as-meaningful.org][no-empty-input-as-meaningful]] | If "no value" is a meaningful choice, it's a candidate in the list, not a hidden empty-input convention. |
+| [[file:label-matches-behavior.org][label-matches-behavior]] | A visible choice's label has to match what picking it does. "none" must not mean "any." |
+| [[file:default-most-common-friction-proportional.org][default-most-common-friction-proportional]] | Default the choice the user most often wants, and size the friction to the cost of being wrong. |
+| [[file:collapse-orthogonal-prompts.org][collapse-orthogonal-prompts]] | When prompts compose, show the cross-product with each row labeled as a complete end-state. Let the user pick the destination, not the coordinates. |
+
+Patterns 3-6 are facets of the same idea and cross-link to each other.
+
+* Frontmatter contract
+
+Each pattern file opens with =#+KEYWORD:= metadata lines (greppable across the directory):
+
+#+begin_example
+#+TITLE: <human-readable title>
+#+SLUG: <kebab-case slug, matches the filename>
+#+PRINCIPLE: <one line, the rule stated once>
+#+PROBLEM: <one line, what goes wrong without it>
+#+TAGS: <surface-area keywords for grep>
+#+SOURCE: <where it was first articulated: project, note, commit>
+#+EXAMPLES: <links or grep targets for real uses>
+#+end_example
+
+The body carries the worked detail under these headings: Problem, Do (with a before/after), Anti-pattern, Applicability (when it applies and when it doesn't), Related.
+
+* Intake cadence: capture on landing, promote on review
+
+When a pattern lands (a handoff note from another project, a discovery mid-session), the raw note drops into that project's =docs/design/= as it does today. Promotion into this catalog happens in a batched review, folded into =task-audit= or a periodic pass, so the catalog stays curated instead of accreting every rough note. The six seed patterns came from four pearl notes formalized in one pass on 2026-06-05.
+
+* Generalization
+
+Seed patterns keep their concrete Elisp examples rather than being abstracted up front. Generalize a pattern beyond its origin domain when a second caller in a different domain arrives, not before. The principle lines are already domain-neutral; the worked examples stay concrete.
diff --git a/patterns/collapse-orthogonal-prompts.org b/patterns/collapse-orthogonal-prompts.org
new file mode 100644
index 0000000..ab24a54
--- /dev/null
+++ b/patterns/collapse-orthogonal-prompts.org
@@ -0,0 +1,47 @@
+#+TITLE: Collapse N orthogonal prompts into one enriched prompt
+#+SLUG: collapse-orthogonal-prompts
+#+PRINCIPLE: When prompts compose, show the cross-product with each row labeled as a complete end-state, so the user picks the destination rather than the coordinates.
+#+PROBLEM: Two or more sequential prompts force the user to mentally assemble the combination and to defend against meaningless combinations in code.
+#+TAGS: completing-read prompts cross-product end-state
+#+SOURCE: pearl saved-query-sync-spec.org (sync command), handoff note 2026-05-28
+#+EXAMPLES: pearl saved-query sync, team scope x visibility collapsed to one prompt
+
+* Problem
+
+Two sequential prompts, team scope then shared/personal visibility, make the user assemble the combination in their head. Some combinations are meaningless (personal scope + shared visibility, since there's no team to share with) and have to be defended against in code. Each prompt conveys a coordinate, not the outcome.
+
+* Do
+
+Collapse to one prompt where each candidate spells out the complete end-state:
+
+#+begin_example
+Where does this view live?
+ [ Team: Engineering, visible to the team ] <- default
+ [ Personal, only I see it ]
+ [ Team: Engineering, only I see it ]
+ [ Team: Marketing, visible to the team ]
+ ...
+ [ Cancel. ]
+#+end_example
+
+One fewer modal moment. Each row reads as its consequence ("visible to the team"), not its mechanism. The meaningless combination simply isn't in the list, so there's nothing to defend against. The default sits on top per [[file:default-most-common-friction-proportional.org][default-most-common-friction-proportional]].
+
+When dimensions compose, each combination is itself a meaningful choice, so show the cross-product and let the user pick the destination, not the coordinates.
+
+* Anti-pattern
+
+Sequential orthogonal prompts the user has to combine mentally, where degenerate combinations exist and must be coded around.
+
+* Applicability
+
+Only when the cross-product is small enough to scan (roughly 5-20 rows). It doesn't work when:
+
+- The cross-product is large (many teams x many statuses becomes a wall).
+- A dimension is re-set independently and often (the user wants to change one without re-picking the rest).
+- A dimension is free text rather than a small enumerable set.
+
+For those, sequential prompts stay right.
+
+* Related
+
+The cross-product form of the root principle. Builds on [[file:label-matches-behavior.org][label-matches-behavior]] (each row accurately labeled) and [[file:default-most-common-friction-proportional.org][default-most-common-friction-proportional]] (default on top).
diff --git a/patterns/default-most-common-friction-proportional.org b/patterns/default-most-common-friction-proportional.org
new file mode 100644
index 0000000..4011cf5
--- /dev/null
+++ b/patterns/default-most-common-friction-proportional.org
@@ -0,0 +1,33 @@
+#+TITLE: Default the most-common choice, friction proportional to consequence
+#+SLUG: default-most-common-friction-proportional
+#+PRINCIPLE: Default the choice the user most often wants, and size the friction to the cost of being wrong.
+#+PROBLEM: "Always default to yes" optimizes the wrong thing; the right default is the most-common choice, and destructive choices should keep their friction.
+#+TAGS: prompts defaults yes-no confirmation safety
+#+SOURCE: pearl commit 505e707 (read-yes-no helper), handoff note 2026-05-28
+#+EXAMPLES: pearl read-yes-no helper; destructive prompts stay yes-or-no-p
+
+* Problem
+
+A first cut at yes/no prompts is "default to yes so RET takes it." That defaults to a fixed answer rather than the right one. What the user actually wants is the most-common or preferred option on top, and sometimes that's "no." A blanket default-and-go is also wrong for destructive choices, where typing the answer is a safety feature, not friction to remove.
+
+* Do
+
+Take the default as a parameter and order the candidate list with it first, so the framework highlights it and RET takes it without typing:
+
+#+begin_example
+read-yes-no PROMPT &optional DEFAULT ; default-first ordering
+#+end_example
+
+Size the friction to the consequence. Non-destructive confirmations get the default-on-top, one-keystroke treatment. Destructive ones (delete issue, delete saved query) deliberately stay full =yes-or-no-p=. The user types "yes" on purpose. Naming this companion rule keeps the principle from collapsing into "always default-yes."
+
+* Anti-pattern
+
+Two failure modes. Hardcoding "yes" as the default regardless of which answer is actually most common. And stripping friction uniformly, so a destructive action is one accidental RET away.
+
+* Applicability
+
+Every confirmation or small multiple-choice prompt. The default-first helper makes the next prompt where "no" is more common a one-argument change rather than a special case. The friction rule applies wherever an action is hard to undo.
+
+* Related
+
+The ordering and friction halves of the root principle. Composes with [[file:no-empty-input-as-meaningful.org][no-empty-input-as-meaningful]] and [[file:label-matches-behavior.org][label-matches-behavior]].
diff --git a/patterns/label-matches-behavior.org b/patterns/label-matches-behavior.org
new file mode 100644
index 0000000..0883abc
--- /dev/null
+++ b/patterns/label-matches-behavior.org
@@ -0,0 +1,32 @@
+#+TITLE: The label matches what the prompt does
+#+SLUG: label-matches-behavior
+#+PRINCIPLE: A visible choice's label has to match what picking it does; visibility without accuracy still leaves the user with the wrong model.
+#+PROBLEM: A uniform sentinel label ("none") used across prompts that mean different things (one means "any", another means "cancel") misdescribes what the choice does.
+#+TAGS: completing-read prompts labels sentinel
+#+SOURCE: pearl commit 505e707 (filter vs saved-query prompts), handoff note 2026-05-28
+#+EXAMPLES: pearl filter dimensions ([ Any. ]) vs pick prompts ([ Cancel. ])
+
+* Problem
+
+Once "no value" is a visible candidate (see [[file:no-empty-input-as-meaningful.org][no-empty-input-as-meaningful]]), the next trap is a label that doesn't match behavior. Pearl used =[ None. ]= uniformly, but it meant two different things. For a filter dimension, picking it meant "no constraint, every value matches," which is any, not none. For a pick-an-existing-thing prompt (delete, run), it meant "don't act on anything," which is cancel. A label that says "none" when the behavior is "any" leaves the user holding the wrong model, no better than the invisible empty-input idiom it replaced.
+
+* Do
+
+Give each behavior a sentinel whose label states what picking it does:
+
+- =[ Any. ]= for a filter dimension (no constraint, everything matches)
+- =[ Cancel. ]= for a pick-an-existing-thing prompt (act on nothing)
+
+A generic =with-sentinel SENTINEL CANDIDATES= helper lets each call site choose the label that fits. One opt-out predicate recognizes any sentinel (and nil/empty) so downstream logic stays uniform.
+
+* Anti-pattern
+
+One sentinel label reused across prompts whose semantics differ. Visibility without accuracy is its own failure: the user sees the choice, but its name describes the wrong outcome.
+
+* Applicability
+
+Anywhere a sentinel or "special" choice appears in more than one prompt with different meanings. The fix is per-call-site labels over a shared predicate, not a single label stretched to cover both.
+
+* Related
+
+The accuracy half of the root principle. Sharpens [[file:no-empty-input-as-meaningful.org][no-empty-input-as-meaningful]]: visible and accurately labeled.
diff --git a/patterns/no-empty-input-as-meaningful.org b/patterns/no-empty-input-as-meaningful.org
new file mode 100644
index 0000000..0791c3b
--- /dev/null
+++ b/patterns/no-empty-input-as-meaningful.org
@@ -0,0 +1,44 @@
+#+TITLE: No empty input as meaningful
+#+SLUG: no-empty-input-as-meaningful
+#+PRINCIPLE: If "no value" is a meaningful choice, it belongs in the candidate list, not behind an empty-input convention.
+#+PROBLEM: An empty-RET-means-none idiom is an affordance the user has to know but can't see; the only hint lives in the prompt label, not where the eye is looking.
+#+TAGS: completing-read prompts affordances sentinel
+#+SOURCE: pearl commit 1288c2a (ad-hoc filter builder), handoff note 2026-05-28
+#+EXAMPLES: pearl ad-hoc filter builder
+
+* Problem
+
+A prompt lets the user pick a value or pick "no value." The common shortcut is to let empty input mean "none," so RET on an untouched prompt does something silently. The user has to remember the convention, and the only on-screen hint is a parenthetical in the prompt label, which sits away from the candidate list where the eye actually is.
+
+* Do
+
+Make the "no value" choice a candidate the user sees and picks. Prepend a sentinel like =[ None. ]= as the first candidate, picked by default RET, with =require-match= on so the user chooses from the list rather than free-typing.
+
+Before:
+
+#+begin_example
+Team (empty for any): _
+#+end_example
+
+After:
+
+#+begin_example
+Team: [ None. ] <- first candidate, default RET
+ Engineering
+ Design
+ ...
+#+end_example
+
+The cost is one predicate plus one list-prepender and a defconst for the sentinel string. The predicate also treats nil and empty as the opt-out, so any caller still producing empty input is handled. The win: users stop having to know a rule that isn't on screen. The underlying rule is no hidden affordances: if it's a choice, it's in the list.
+
+* Anti-pattern
+
+"Empty input means none/any/skip." Any behavior triggered by the absence of input is invisible. A label hint is not a substitute for the choice being a visible candidate.
+
+* Applicability
+
+Every prompt across every project where "none" is a meaningful answer is a candidate. This is the base case the next three patterns sharpen.
+
+* Related
+
+The root of patterns 4-6. Pair it with [[file:label-matches-behavior.org][label-matches-behavior]]: a visible sentinel whose label is wrong is no better than a hidden idiom.
diff --git a/patterns/one-prompt-picker-typed-prefix.org b/patterns/one-prompt-picker-typed-prefix.org
new file mode 100644
index 0000000..9de2060
--- /dev/null
+++ b/patterns/one-prompt-picker-typed-prefix.org
@@ -0,0 +1,45 @@
+#+TITLE: One-prompt picker with typed prefix
+#+SLUG: one-prompt-picker-typed-prefix
+#+PRINCIPLE: When the kind of candidate matters as much as its name, put kind and name in one picker with a typed prefix so the user picks once.
+#+PROBLEM: A multi-prompt chain to first choose a kind, then an item within it, adds modal moments the user has to track.
+#+TAGS: completing-read picker prompts disambiguation
+#+SOURCE: pearl pearl-pick-source (issue-sources), handoff note 2026-05-27
+#+EXAMPLES: pearl pearl-pick-source
+
+* Problem
+
+A project has N candidates of several kinds, and the kind matters as much as the name. The reflexive shape is a chain: first prompt for the kind (view? project? saved query?), then a second prompt for the item within that kind. Every extra prompt is a mode switch the user holds in their head, and the kind/name split is artificial. The user already knows the specific thing they want.
+
+* Do
+
+Put every candidate into one =completing-read=, each rendered as =[kind] name=, sorted by the kind's natural order then alphabetically. The user sees kind, identity, and rank in one scan, picks once, and is done.
+
+Before, two prompts:
+
+#+begin_example
+Source kind: view / project / saved
+(then) View: Active bugs / Recent / ...
+#+end_example
+
+After, one prompt:
+
+#+begin_example
+Source: [view] Active bugs
+ [view] Recent
+ [project] Platform
+ [saved] My open
+#+end_example
+
+The only cost is a small helper that maps each candidate's metadata to its display string. The win is the picker collapsing three decisions to one.
+
+* Anti-pattern
+
+A prompt chain that disambiguates by kind first and item second, when the user already has a specific item in mind. The intermediate "which kind?" prompt is a decision the combined picker never makes the user state.
+
+* Applicability
+
+Reach for it anywhere N candidates span multiple kinds and the kind is worth showing. It stops being right when the candidate list is too large to scan in one pass (then a kind filter earns its prompt), or when picking the kind genuinely narrows an expensive downstream fetch.
+
+* Related
+
+A facet of the root principle (see [[file:README.org][README]]): all the choices on screen, ordered by what the user most often wants.
diff --git a/patterns/transient-state-buttons.org b/patterns/transient-state-buttons.org
new file mode 100644
index 0000000..7b7358d
--- /dev/null
+++ b/patterns/transient-state-buttons.org
@@ -0,0 +1,29 @@
+#+TITLE: Transient state-buttons
+#+SLUG: transient-state-buttons
+#+PRINCIPLE: Put all the levers in one place with their current state and key bindings visible, reachable by one chord from anywhere.
+#+PROBLEM: Scattered commands with invisible state force the user to remember what's toggled and which key does what.
+#+TAGS: transient menu ui affordances state
+#+SOURCE: Craig's magit transient.el reference, handoff note 2026-05-27
+#+EXAMPLES: magit transient.el, pearl pearl-menu (C-; L m)
+
+* Problem
+
+A feature accumulates several toggles and actions, each on its own command and keybinding. The state lives in variables the user can't see, and the keys live in muscle memory or a manual. The user has to remember what's currently on and which chord flips it.
+
+* Do
+
+A single popup (magit's =transient.el= is the reference implementation) that shows every lever as a button row: the current state of each toggle, the key that flips it printed inline, summoned by one chord from anywhere. All the affordances are visible at once, and nothing about the current state is hidden.
+
+This is the "all the levers in one place, affordances visible, one chord to get there" shape. The everyday analogue is the music-player transport bar: play, pause, shuffle, repeat state all shown, all reachable.
+
+* Anti-pattern
+
+A set of independent commands whose combined state is invisible, where the user learns what's toggled only by triggering something and watching what happens.
+
+* Applicability
+
+Worth it when a feature has three or more pieces of toggleable state or a cluster of related actions. Overkill for a single command with no state. A light transient (a command menu with no state shown) is a partial step. The full value comes from showing toggle state in the buttons, not just listing actions.
+
+* Related
+
+The visible-affordances half of the root principle (see [[file:README.org][README]]), applied to a control surface rather than a single prompt.