aboutsummaryrefslogtreecommitdiff
path: root/docs/modified-ticket-indicator-spec.org
diff options
context:
space:
mode:
Diffstat (limited to 'docs/modified-ticket-indicator-spec.org')
-rw-r--r--docs/modified-ticket-indicator-spec.org242
1 files changed, 242 insertions, 0 deletions
diff --git a/docs/modified-ticket-indicator-spec.org b/docs/modified-ticket-indicator-spec.org
new file mode 100644
index 0000000..4725a2c
--- /dev/null
+++ b/docs/modified-ticket-indicator-spec.org
@@ -0,0 +1,242 @@
+#+TITLE: pearl — Modified-Ticket Indicator Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-07
+#+STARTUP: showall
+
+* Status
+
+*Ready — visual model revised to a field-region highlight (Craig, 2026-06-07; Codex rounds 1-2 incorporated).* A brainstorm-and-pick spec, like the task asked: survey the ways to surface "this ticket has unsaved local edits," weigh them against what the buffer can drive cheaply, and commit to a v1 contract. Companion to [[file:ticket-save-model-v2-spec.org][ticket-save-model-v2-spec.org]], which created the gap this closes: edits defer to =pearl-save-issue= / =pearl-save-all=, so a ticket can carry unsaved changes with nothing on screen saying so.
+
+Round 1 (Codex, rubric =Not ready=) closed five blocking gaps: it caught that the spec equated "changed comment" with "needs pushing" (a non-own comment is local drift =pearl-save-*= skips, not pushes); it corrected three wrong helper names in Current state; it pushed the default cue from face-only to face + explicit text + count; and it asked for a real handoff (v1 decisions, drop-in tasks). All accepted. Round 2 (rubric =Ready with caveats=, no blockers) cleaned up the handoff: the marker-wording defaults are now final in [[*v1 decisions]] (defcustoms keep them adjustable), the tag-coexistence test is explicit, and the drop-in task priorities are dispositioned. Dispositions for both rounds in [[*Review dispositions]] below; the six round-0 open decisions are resolved in [[*v1 decisions]].
+
+* Problem
+
+Under the deferred save model an issue (title, description, state, priority, assignee, labels) and its own comments can all carry unsaved local edits, and the only signal that an edit happened is the buffer being modified — a single dot in the mode line that says nothing about *which* ticket changed or that the change still needs pushing. At the point of the edit there is no cue at all. A user edits three tickets across a long buffer, runs =pearl-save-all=, and has no way to see beforehand what was dirty or to confirm afterward that it cleared.
+
+Two sharpenings from dogfooding (Craig, 2026-05-26): the cue must cover an edited *comment*, not just an issue, and it must read specifically as "unsaved — needs pushing," not the generic "buffer modified."
+
+* Current state
+
+The detector already exists and is the same one merge protection and save-all use, so the cue can ride it with no new dirty logic:
+
+- =pearl--issue-dirty-fields= (pearl.el:5914) returns, for the issue subtree at point and with no network, a plist of booleans (=:title= =:description= =:priority= =:state= =:assignee= =:labels=) plus =:comment-candidates=, the changed-comment list from =pearl--changed-comment-candidates=. Each field compares a live value against its synced baseline (drawer SHA / id properties).
+- =pearl--issue-has-dirty-fields-p= (pearl.el:6493) collapses that plist to a single boolean.
+- =pearl--scan-all-dirty= (pearl.el:6565) is the existing full-buffer scan: it walks every issue subtree and returns markers paired with their dirty plists. =pearl-save-all= drives off it.
+- =pearl--subtree-has-local-edits-p= (pearl.el:6699) is the merge-protection predicate, wrapping =pearl--issue-dirty-fields= for one subtree.
+- =pearl--changed-comment-candidates= yields per-comment markers; =pearl--classify-comment-candidates= splits them into =:own= (editable, pushable) and =:read-only= (edited locally, not pushable) *once a viewer id is known*. A candidate alone only means the body differs — ownership, and therefore pushability, needs the viewer.
+- =pearl--viewer-async= (pearl.el:7813) resolves and caches the viewer id; =pearl-save-issue= / =pearl-save-all= call it only when comments are dirty.
+
+The overlay and mode-line machinery the cue should reuse is also already in place, and the overlays leave the buffer text untouched so parsing, save, and merge keep working:
+
+- =pearl--apply-heading-glyphs= (pearl.el:7890) rides a =display= overlay on the space after a heading's leading stars, ahead of the TODO keyword, leaving the stars (and any org-superstar / org-modern bullet) alone.
+- =pearl--apply-comment-highlights= (pearl.el:7924), driven by the public =pearl-highlight-comments= (pearl.el:7956), puts a *face* overlay on a comment heading's text (starting after the stars, so org's own fontification on the stars is preserved) and =pearl-readonly-comment= over a read-only comment subtree.
+- =pearl--hide-preamble= (pearl.el:4255) overlays the source header. All these are idempotent: each clears its own tagged overlays before reapplying, and runs after a render/refresh.
+- =pearl--mode-line-lighter= (pearl.el:654) builds the =pearl-mode= lighter, currently =Pearl= or =Pearl[account]=. The aggregate count extends this, not a new global.
+
+The save and merge lifecycle the cue must track:
+
+- A successful field save advances the subtree's baselines (writes the SHA / =-SYNCED= id properties), so the next dirty scan sees that field as clean. The cue for a saved field must clear.
+- =pearl-save-issue= / =pearl-save-all= are async and can partially push, skip (read-only / viewer-unavailable comments), conflict, or fail. The cue must recompute from the dirty scan *after the save queue completes and baselines are advanced*, not from the outcome status — a conflicted or failed field stays dirty and must stay marked.
+- The merge refresh keeps unsaved edits on locally-edited subtrees (that is its whole point). The cue must persist on those kept subtrees after a merge, and must not appear on freshly-fetched clean ones.
+
+* Candidate cues (the brainstorm)
+
+The task listed five candidates. Measured against four constraints — (a) drivable cheaply from the existing dirty scan, (b) doesn't fight org's own fontification, (c) covers comments as well as issues, (d) reads as "unsaved, needs pushing" — they sort into two groups.
+
+*Rejected: anything that mutates the buffer.*
+
+- *Org tag* (e.g. =:unsaved:= on the heading) — writing the tag changes the subtree text, which the dirty scan hashes. The indicator would feed back into the very thing it measures: adding the tag makes the subtree differ from its baseline, and on save the tag is part of what gets compared or pushed. Self-referential and fragile. Reject.
+- *Drawer flag* (e.g. a =:LINEAR-DIRTY:= property) — same feedback problem, same reason. The drawer is part of the subtree the scan hashes, and a written flag is buffer state that has to be cleaned up and kept out of the saved payload. Reject.
+
+Both also duplicate state that the dirty scan already computes on demand; the cue should *render* dirtiness, never *store* it.
+
+*Viable: overlays and the mode line, which never touch buffer text.* The field-region highlight and the count below are overlay/mode-line constructs. The v1 cue is a background highlight over the *region of the field that changed* — the title text, the description body, a comment, or the tag — which answers "which ticket" and "what in it" at once, plus an aggregate mode-line count for work scrolled off-screen (see [[*v1 decisions]]). A leading display glyph and a fringe bitmap were considered and deferred to vNext (the leading-stars slot already carries the ticket glyph + TODO keyword; the fringe is narrow and hard to theme).
+
+* v1 decisions
+
+1. *Cue = a background highlight over the changed field's region.* One face, =pearl-modified-highlight=, painted over the buffer region of whatever field is dirty — the title text, the description body, a comment, or the tag. It answers "which ticket" and "what changed in it" in one stroke, and being a background it leaves the field's own text untouched and readable. This replaces round 1/2's heading-face + trailing-"unsaved"-word design (rationale in [[*Review dispositions]]): highlighting the actual changed field is more informative than a word on the heading, and the explicit "unsaved" wording moves to the mode line.
+2. *The heading is always highlighted for a pushable-dirty ticket*, on top of the specific changed field. That's the fold-visibility / bubble-up channel: a collapsed ticket still shows the heading highlight, and expanding reveals which field is lit. A title change is just the case where the changed field *is* the heading.
+3. *Highlight face inherits =diff-changed=* ( =:extend t= ). A cross-theme legibility survey (modus variants + dupre) showed =diff-changed= stays readable as a background everywhere measured (contrast ~5.5–16), while =diff-refine-changed= breaks in some themes (dupre renders it white-on-gold, ~1.35). See [[*Faces and customization]].
+4. *Mode-line count ships in v1*, appended to the existing lighter: =Pearl[work] 3 unsaved=. *N counts tickets, not fields* — the number of issues with pushable dirty work, each counted once no matter how many of its fields or own comments changed. The ticket is the unit you act on (=pearl-save-issue= saves a ticket, navigation jumps between tickets), and "3 unsaved" reads as 3 items; a field count would need "7 unsaved changes" to not mislead. The format is a defcustom, so the bare number can become "3 tickets unsaved" if it reads ambiguously.
+5. *Comment ownership decides which highlight* (Codex HP2). Three classes:
+ - *Pushable-dirty* = any dirty issue field, plus a dirty comment whose author id equals the resolved viewer id. Gets =pearl-modified-highlight=, is counted, and lights the parent heading (bubble-up).
+ - *Local-only-dirty* = a dirty non-own (bot / external) comment. =pearl-save-*= never pushes it, so it gets a *distinct muted* highlight ( =pearl-modified-local= ), is *not* counted, and does *not* bubble up.
+ - *Unknown* = a changed comment seen before the viewer resolves. Gets a neutral =pearl-modified-unknown= highlight, excluded from the count, until the async viewer classifies it.
+6. *Trigger = change-scoped after-change + idle debounce* for live feedback, *plus* a full-pass redecorate after render / merge / open / sort / regroup / =pearl-mode= enable, *plus* a post-save recompute after the save queue completes. Details in [[*Refresh lifecycle and buffer-local state]].
+7. *Field-to-region map* — the buffer region each dirty field highlights:
+ - title -> the title text on the heading line
+ - description -> the description body region
+ - a comment -> that comment's heading + body
+ - labels -> the tag run ( =:tag:tag:= ) on the heading line
+ - state -> the TODO keyword
+ - priority -> the =[#x]= cookie
+ - assignee -> the =LINEAR-ASSIGNEE= drawer line, or the heading when the drawer is folded
+ - any pushable-dirty ticket -> its heading line as well (decision 2)
+8. *Optional leading glyph and fringe marker are vNext*, not part of the v1 cue.
+
+* Proposed design
+
+Driven entirely by the existing dirty scan plus viewer classification; no new dirty logic, no buffer mutation.
+
+** The highlight, per field
+
+For each pushable-dirty ticket, =pearl-modified-highlight= is painted over its heading line (fold-visible) and over each changed field's region per the [[*v1 decisions]] map: title text, description body, the tag run, the TODO keyword, the =[#x]= cookie, or the assignee drawer line. A dirty own comment highlights that comment's region and lights the parent heading. The highlight is background-only, so the field's text keeps its own color.
+
+Comment classes use distinct faces: own -> =pearl-modified-highlight=; read-only (non-own) -> =pearl-modified-local= (muted, not counted); pre-viewer -> =pearl-modified-unknown= (neutral, not counted), until the async viewer resolves it.
+
+** The count, in the mode line
+
+=pearl--buffer-dirty-issue-count= returns the number of *tickets* with pushable dirty work (dirty fields or own dirty comments), each ticket once. =pearl--mode-line-lighter= appends " N unsaved" when N > 0, preserving the account name. The count is cached buffer-locally and refreshed by the redecorate pass, so it never recomputes during mode-line redisplay; a change calls =force-mode-line-update=.
+
+** One decoration entry point (Codex architecture obs)
+
+Pearl already calls =pearl-highlight-comments= after many render/merge paths; adding another independently-called applier multiplies stale-overlay bugs. v1 introduces a single =pearl--redecorate-buffer= that runs, in order: =pearl--hide-preamble=, =pearl--apply-heading-glyphs=, comment editability highlighting (when the viewer is available), and the modified-highlight applier. Every full-pass caller (render, merge, sort, regroup, open, =pearl-mode= enable) calls this one helper instead of remembering separate appliers.
+
+* Refresh lifecycle and buffer-local state
+
+The cue appears at the point of edit, clears on save, and survives a merge. Concrete contracts (Codex HP3):
+
+- *Buffer-local state:* the dirty-count cache, the set of pending changed ranges/markers, the debounce idle timer, and the overlay tag (=pearl-modified=) for clean removal.
+- *On edit (appear).* =after-change-functions= records the changed region; an idle timer (debounced ~0.3–0.5s) recomputes the cue for the issue subtree(s) intersecting that region only — one subtree per edit, not the whole buffer, and never inside font-lock.
+- *On save (clear).* After the (async) save queue completes, recompute the affected subtree(s) from the dirty scan with baselines advanced — not from outcome status, so a conflicted/failed/skipped field that is still dirty stays marked.
+- *On merge / refresh / sort / regroup / open / mode enable (full pass).* Any operation that advances baselines or moves subtrees schedules a full =pearl--redecorate-buffer=. Because the applier reads the live scan, kept (locally-edited) subtrees keep their cue and fresh clean ones get none — no special-casing.
+- *Timer and callback cleanup.* Cancel the idle timer when =pearl-mode= is disabled or the buffer is killed. Any =pearl--viewer-async= callback used for comment classification must check =buffer-live-p= and that it still belongs to the same account context before applying overlays (account switch can change ownership).
+- *Mode-line.* Update the cached count in the redecorate pass and call =force-mode-line-update= when it changes.
+
+* Performance
+
+The per-issue scan hashes the subtree (=secure-hash 'sha256=) and reads a handful of drawer properties. A full-buffer pass is O(issues × subtree size); the change-scoped trigger holds steady-state cost to one subtree per edit, and the full pass runs only where the buffer is already being walked (render / merge / sort / regroup / open). Do *not* run viewer lookup on every idle tick: use the cached viewer id when present; if absent and comment candidates exist, schedule one async classification pass and show the unknown cue until it returns. The mode-line count is served from the cache, not recomputed on redisplay.
+
+* Faces and customization
+
+- =pearl-modified-highlight= — the highlight over a pushable-dirty field (and the heading). Default =((t :inherit diff-changed :extend t))=. =diff-changed= was chosen over =diff-refine-changed= after a cross-theme legibility survey: as a background under the theme's own text, =diff-changed= stayed readable everywhere measured (contrast ~5.5 in dupre, ~13–16 across the modus variants), while =diff-refine-changed= is a bright word-level-emphasis face that breaks in some themes (dupre renders it white-on-gold, ~1.35). Background-only, so the field's text keeps its color. Caveat for the public package: a theme that leaves =diff-changed= unstyled yields no visible highlight — ship a fallback background or document the dependency; for dupre + modus it's styled and legible. (A handoff asked the emacs.d project to bring dupre's diff faces in line with modus.)
+- =pearl-modified-local= — muted highlight for a read-only (non-own) dirty comment, visibly weaker than =pearl-modified-highlight= so "can't push this" reads differently from "unsaved". Applied *after* the editability overlay with explicit overlay priority so a theme can't hide it under the comment overlay (Codex MP2).
+- =pearl-modified-unknown= — neutral highlight for a pre-viewer changed comment.
+- =pearl-show-modified-indicator= (defcustom, default t) — master toggle; nil removes all highlights and the count.
+- =pearl-modified-mode-line-format= (defcustom, default " %d unsaved") — the mode-line segment, so "3 unsaved" can become "3 tickets unsaved".
+- =pearl-modified-glyph= (defcustom, default "") — vNext optional leading glyph; off by default.
+
+* Edge cases
+
+- *Glyphs off* (=pearl-show-glyphs= nil) — the field highlight and the count still work; only the vNext leading glyph is suppressed.
+- *Legacy buffer with no baselines* — a field with a missing baseline is *not* dirty (the existing scan's behavior), so an old file shows no false cue; the next refresh writes baselines.
+- *Folded subtree* — the heading is highlighted for any pushable-dirty ticket (decision 2), so a folded dirty issue still shows the cue; expanding reveals the specific highlighted field, including a dirty own comment inside.
+- *Group / non-issue headings* — group headings, the =Comments= container, and the help/preamble carry no =LINEAR-ID=; the applier skips them, like save and merge do.
+- *Account switch* — invalidates viewer-derived comment classification; a full redecorate runs and pending viewer callbacks are dropped if the context changed.
+- *Partial / failed / conflicted save* — fields still dirty after the queue stay marked; only cleared fields lose the cue.
+- *Whitespace-only / round-trip edits* — the cue exactly mirrors the dirty scan, so anything the scan treats as clean shows no cue.
+
+* Implementation phases
+
+1. =pearl--buffer-dirty-issue-count= (counts tickets with pushable dirty work, each once; viewer-aware — dirty fields always, own comments once the viewer resolves, read-only/unknown excluded) + the mode-line segment extending =pearl--mode-line-lighter=, gated on =pearl-show-modified-indicator=. Cheapest standalone slice.
+2. =pearl-modified-highlight= face + the field-region applier (modeled on =pearl--apply-comment-highlights=) with the field-to-region map, heading bubble-up, and own-comment coverage, folded into the new =pearl--redecorate-buffer= entry point and every full-pass caller.
+3. The change-scoped after-change idle trigger + buffer-local state + post-save recompute + timer/callback cleanup, so the highlight is live during editing and clears on save.
+4. The read-only =pearl-modified-local= / unknown =pearl-modified-unknown= comment highlights and the async viewer classification pass.
+
+* Test surface
+
+- =pearl--buffer-dirty-issue-count=: counts *tickets* (each once) with dirty fields or own dirty comments; a ticket with several dirty fields still counts one; excludes read-only and unknown comments; behaves per spec when the viewer is unavailable.
+- Mode-line: the lighter appends the count while preserving the account name; nil toggle removes it.
+- Field-region applier: a dirty title highlights the title text; a dirty description highlights the description body; dirty labels highlight the tag run; a clean field gets no highlight; applying twice leaves exactly one =pearl-modified-highlight= overlay per region (idempotence).
+- Heading bubble-up: any pushable-dirty ticket has its heading highlighted; a folded ticket with only a dirty own comment still shows the heading highlight.
+- Comments: a dirty own comment highlights the comment region and bubbles to the parent issue; the =Comments= container is unmarked; a read-only dirty comment gets the muted local-only highlight and does not bubble or count.
+- Save: completion clears the highlight for fields whose baselines advanced; conflicted/failed/skipped-still-dirty fields stay highlighted.
+- Merge: keeps the highlight on retained dirty subtrees and does not mark freshly-fetched clean ones.
+- Trigger: an edit scopes recompute to the changed subtree; the idle timer is cancelled on a killed buffer / disabled mode.
+- Tag coexistence: a dirty issue heading carrying right-aligned org tags (labels-as-org-tags) still highlights the tag run without corrupting the heading text or the tags.
+- =pearl-show-modified-indicator= nil removes highlights and the count; glyphs disabled leaves the highlight + count active.
+
+* Implementation tasks (drop-in for todo.org)
+
+Priorities are =[#B]=: Craig bumped the parent task and these phases from =[#C]= to =[#B]= (round-2 MP3), so this is near-term v1 work, not deferred polish.
+
+#+begin_src org
+** TODO [#B] Modified-ticket indicator: mode-line count + dirty classifier :feature:
+Viewer-aware pushable-dirty count (=pearl--buffer-dirty-issue-count=), appended to =pearl--mode-line-lighter= as "N unsaved" while preserving =Pearl[account]=, cached buffer-locally with =force-mode-line-update=. Spec: [[file:docs/modified-ticket-indicator-spec.org]] (phase 1).
+
+** TODO [#B] Modified-ticket indicator: field-region highlight overlays :feature:
+=pearl-modified-highlight= (inherits =diff-changed=) painted over the changed field's region per the field-to-region map, with heading bubble-up and own-comment coverage, behind a single =pearl--redecorate-buffer= entry point. Spec: [[file:docs/modified-ticket-indicator-spec.org]] (phase 2).
+
+** TODO [#B] Modified-ticket indicator: live recompute lifecycle :feature:
+Buffer-local after-change idle debounce, post-save recompute (read the scan, not the outcome), full-pass redecorate after render/merge/sort/regroup/open/mode-enable, timer + viewer-callback cleanup on disabled/killed buffers. Spec: [[file:docs/modified-ticket-indicator-spec.org]] (phase 3).
+
+** TODO [#B] Modified-ticket indicator: read-only + unknown comment treatment :feature:
+=pearl-modified-local= (muted highlight) for non-own dirty comments and =pearl-modified-unknown= for pre-viewer ones, via an async viewer classification pass; excluded from the count and bubble-up. Spec: [[file:docs/modified-ticket-indicator-spec.org]] (phase 4).
+
+** TODO [#B] Modified-ticket indicator test surface :test:
+Count semantics, mode-line account integration, overlay idempotence, own-comment bubble-up, read-only/unknown behavior, save-clear vs conflict-persist, merge persistence, idle-trigger cleanup, customization toggles. Spec: [[file:docs/modified-ticket-indicator-spec.org]] (Test surface).
+#+end_src
+
+* Open questions (for Craig)
+
+None block implementation. The cue is now a field-region highlight (no per-heading marker word), the count is tickets, and the highlight face inherits =diff-changed= — all settled in [[*v1 decisions]] and overridable via defcustom. One non-blocking call remains: whether dirty-ticket navigation (=pearl-next-modified-ticket= / =pearl-previous-modified-ticket=) is worth pulling into v1 — currently parked in [[*vNext candidates]].
+
+* vNext candidates
+
+- Optional leading glyph / fringe marker for visual redundancy beyond text + face + count.
+- =pearl-next-modified-ticket= / =pearl-previous-modified-ticket= navigation, so a "7 unsaved" mode line is walkable before =pearl-save-all=.
+- Header-line or side-window summary of dirty tickets if the mode-line count proves too subtle.
+- =pearl-save-all= reusing the exact count vocabulary so its prompt count matches the mode line.
+
+* Review dispositions
+
+** Round 1 (Codex, 2026-06-07) — all blocking findings accepted
+
+- *HP1 (finalize v1 contract):* accepted. The six open decisions are resolved in [[*v1 decisions]]; two wording items are flagged for Craig rather than left open.
+- *HP2 (resolve comment ownership before "needs pushing"):* accepted — the central fix. The draft's contradiction (mark every changed comment, yet exclude read-only from the count) is replaced by the three-class pushable / local-only / unknown contract. The cue now mirrors what =pearl-save-*= actually pushes.
+- *HP3 (precise update lifecycle):* accepted. Added buffer-local state, post-save recompute from the scan (not outcome), viewer-callback liveness/context checks, full-pass triggers, and =force-mode-line-update=.
+- *HP4 (correct helper names):* accepted — these were real errors. =pearl-buffer-has-dirty-issues-p= and =pearl--apply-preamble= don't exist; Current state now cites =pearl--scan-all-dirty=, =pearl--subtree-has-local-edits-p=, =pearl-highlight-comments= / =pearl--apply-comment-highlights=, =pearl--hide-preamble=, and =pearl--mode-line-lighter=, with =pearl--buffer-dirty-issue-count= introduced as new.
+- *HP5 (handoff section):* accepted. Added v1 decisions, the drop-in todo block, and this dispositions + history record.
+- *MP1–MP4:* accepted. Save-all count-vocabulary consistency noted (vNext for the prompt itself); overlay precedence + face composition specified (MP2); mode-line extends =pearl--mode-line-lighter= (MP3); the face requires a non-color attribute (MP4).
+- *Architecture / perf / tests / docs observations:* accepted — single =pearl--redecorate-buffer= entry point, viewer-id caching with one async classification pass, the expanded test surface, and the README unsaved-vs-local distinction.
+- *Refinements added beyond the review:* the trailing-marker-vs-org-tags coexistence caveat (labels-as-org-tags put tags at the heading-line end), and the explicit "unknown until viewer resolves" comment state as the viewer-unavailable default.
+
+** Round 2 (Codex, 2026-06-07) — rubric =Ready with caveats=, no blockers
+
+- *MP1 (promote status):* accepted. Status is now =Ready with caveats=.
+- *MP2 (finalize wording):* accepted. The two marker defaults (" unsaved", " local edit only") moved into [[*v1 decisions]] as final, with the defcustoms kept so they stay adjustable; the wording questions are out of Open questions.
+- *MP3 (task priority):* accepted. Craig bumped the parent task and the drop-in phases from =[#C]= to =[#B]=, confirming this as near-term v1 work.
+- *Test / docs:* accepted. Added the explicit tag-coexistence test case; the README unsaved-vs-local vocabulary note was already captured in round 1.
+
+** Post-round-2 design revision (Craig, 2026-06-07)
+
+After the spec went =Ready=, Craig reworked the visual model in conversation, which supersedes the round-1/2 cue decisions:
+
+- *Cue is now a field-region highlight, not a heading face + trailing word.* The highlight is painted over the region of the field that changed (title / description / comment / tag), plus the heading for fold-visibility. This deliberately overrides Codex's round-1 push for an explicit per-heading "unsaved" word: highlighting the actual changed field says more about *what* changed, and the explicit "unsaved" wording now lives in the mode-line count. The strikethrough / ghosted-deletion idea raised in the same discussion was dropped — a label change just highlights the new tag (present text), so there's no removed-text to render.
+- *Highlight face inherits =diff-changed=, not =diff-refine-changed=.* Chosen after a cross-theme legibility survey (modus variants + dupre): =diff-changed= stays readable as a background everywhere measured; =diff-refine-changed= breaks in dupre (white-on-gold, contrast ~1.35). Dupre's broken diff faces were handed to the emacs.d project to fix along modus lines.
+- *Count is tickets, not fields.* "N unsaved" = tickets with pushable dirty work, each once — the ticket is the unit you save and navigate.
+
+* Review and iteration history
+
+** 2026-06-07 Sunday @ 01:00:57 -0500 — Codex — reviewer
+
+- *What changed or was recommended:* Ran the spec-review workflow. Rubric =Not ready=. Recommended making the default cue face + explicit non-mutating heading text + mode-line count; resolving comment ownership/read-only semantics; defining the live recompute lifecycle; correcting current-state helper names; and adding the drop-in task block.
+- *Why:* The spec explores the right solution space but still leaves user-visible behavior open, and a face-only default is too subtle for "unsaved — needs pushing" to be clear and hard to miss.
+- *Artifacts:* [[file:modified-ticket-indicator-spec-review.org][modified-ticket-indicator-spec-review.org]].
+
+** 2026-06-07 Sunday — Craig + Claude — spec-response
+
+- *What changed:* Folded round 1 into the spec. Resolved the six open decisions into [[*v1 decisions]]; rewrote comment handling around the pushable / local-only / unknown contract; added the lifecycle/state contract, the single =pearl--redecorate-buffer= entry point, the corrected helper names, the drop-in todo block, and the review dispositions. Two marker-wording items left for Craig.
+- *Why:* Move the spec from design exploration to an implementation-ready handoff per the review.
+
+** 2026-06-07 Sunday @ 01:49:17 -0500 — Codex — reviewer
+
+- *What changed or was recommended:* Re-ran the spec-review workflow after the round-1 response. Rubric =Ready with caveats=. No high-priority blockers remain. Recommended promoting the status/rubric, finalizing or dispositioning the marker-copy defaults, using =[#B]= for the v1 drop-in tasks or explicitly justifying =[#C]=, and adding a tag-coexistence test case.
+- *Why:* The core UI and lifecycle contracts are now implementable; the remaining issues are handoff and polish rather than product-behavior blockers.
+- *Artifacts:* [[file:modified-ticket-indicator-spec-review.org][modified-ticket-indicator-spec-review.org]].
+
+** 2026-06-07 Sunday — Craig + Claude — spec-response (round 2)
+
+- *What changed:* Folded round 2 in. Status promoted to =Ready with caveats=; the marker-wording defaults finalized in [[*v1 decisions]] and dropped from Open questions; the tag-coexistence test case added; the drop-in task priorities dispositioned (kept =[#C]= to match the parent, with a justification and a noted =[#B]= bump option).
+- *Why:* Clear the round-2 caveats so the spec is an implementation-ready handoff.
+
+** 2026-06-07 Sunday — Craig + Claude — design revision + priority bump
+
+- *What changed:* Craig bumped the parent + phases to =[#B]=. Then reworked the visual model: the cue is a field-region background highlight (over the changed title / description / comment / tag, plus the heading for fold-visibility) inheriting =diff-changed=, replacing the heading-face + trailing-"unsaved"-word design; the mode-line count is defined as tickets, not fields. Backed by a cross-theme legibility survey that picked =diff-changed= over =diff-refine-changed= and surfaced a dupre legibility bug (handed to the emacs.d project).
+- *Why:* Craig wanted the highlight to show exactly what changed in a ticket, with theme-derived coloring, after seeing the survey.
+
+** 2026-06-07 Sunday @ 05:11:50 -0500 — Codex — reviewer
+
+- *What changed or was recommended:* Re-ran the spec-review workflow after the round-2 response. Rubric =Ready with caveats=. No new findings. Confirmed the marker wording is final, the tag-coexistence test is present, the =[#C]= implementation priority is explicitly justified against the parent task, and no implementation-blocking ambiguity remains.
+- *Why:* The prior caveats are either resolved or intentionally dispositioned, so implementation can proceed under the spec's stated priority.
+- *Artifacts:* this spec; no new review file was written because there are no new blocking review notes.