feat(ui): decision-control primitives (PR1, nothing mounted) - #1452
Closed
backnotprop wants to merge 6 commits into
Closed
feat(ui): decision-control primitives (PR1, nothing mounted)#1452backnotprop wants to merge 6 commits into
backnotprop wants to merge 6 commits into
Conversation
buildDecisionSpec maps { app, gate, count, hasFeedback,
approvalNotesSupported } onto one primary plus ordered menu items —
labels, subtitles and confirm strings verbatim from the approved
prototype (DESIGN_final-proposal.html). approvalNotesSupported gates
every approve-carrying item; the discard item is count-gated so it can
never offer to discard zero annotations; frozen copy is marked inline.
Pure (no React, no DOM) so the full state matrix runs in the plain
bun test lane.
…e-popover hook The joined split control: incumbent primary segment (never opens the popover, never fades while it is open — the popover holds only alternates, so the primary keeps its meaning), caret popover with role=menu rows and roving arrow-key focus, in-place composer morph (Esc ladder consumes on exactly two rungs, drafts kept; Mod+Enter submits trimmed; plain Enter is a newline; empty note refocuses instead of graying), and the single discard confirm through ConfirmDialog. useDismissablePopover is the shared pointerdown-outside + Escape effect with the framed-surface strategy: window blur to an iframe dismisses, since iframe clicks never reach the parent document. ActionMenuItem gains additive role/className props and ExitButton an additive appearance='ghost' form; defaults are byte-identical for existing consumers. DOM tests registered in the CI seam-contract step so they cannot silently skip.
One scope at the shortcuts root (both apps mount the identical control) documenting only the two chords the control implements: Mod+Enter submits the note, Escape steps back keeping it. Enter is deliberately not documented as submit — it is a newline. Registry test guards that the scope composes into both adopting settings registries without id or token collisions ahead of the PR2/PR3 registration.
Internal (non-host-surface) decision-control primitives only: DecisionControl, decisionSpec, useDismissablePopover join the unsupported list — no supported import is added. Core pin stays exact at 0.25.1; smoke:package verified the 0.38.0 tarball.
Review F1: the hook's document-level Escape dismissed the popover but let the event travel on to the apps' window-level Escape ladders — from PR2 one Esc would both close the menu AND drop an HTML-annotate surface to Interact. The hook now skips defaultPrevented events and, when it acts, calls preventDefault + stopPropagation (document bubbles before window). An outside-focus Escape while the composer is open closes the whole popover — an outside-dismissal gesture like an outside click; the draft is kept — commented at the dismiss call site. New DOM test pins all three: open+outside-focus consumes, composer case closes-all keeping the draft, closed control consumes nothing (verified failing without the hook fix). Review F3: pin the frozen 'Request changes…' label (it is in the spec's frozen set but was never asserted) and downgrade the unfrozen sentence pins (composer action labels, approve-note labels, discard sentences) to data assertions — flow verb, live count, singular form, composer distinctness — per the Testing Rules.
Owner
Author
|
Superseded by #1458, which carries this branch's commits as the first stage of the integrated stack. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR1: Decision-control primitives (nothing mounted)
First PR of the unified decision-control project (
DESIGN_decision-control.md§7 "PR1").Adds the shared primitives to
packages/ui; no app mounts them yet. Zero changes topackages/editor,packages/review-editor, or any server.What ships
packages/ui/utils/decisionSpec.ts: purebuildDecisionSpec({ app, gate, count, hasFeedback, approvalNotesSupported })returning{ primary, items[] }. Labels,subtitles and confirm strings are verbatim from the approved prototype
(
DESIGN_final-proposal.html, authoritative over the older branch/mock labels).approvalNotesSupportedgates every approve-carrying item (spec §2.2, singlemechanism). Frozen copy (
Done,Approve,Send Feedback,Request changes…,Approve with notes,Discard & finish,Discard & approve) is marked inline.packages/ui/components/DecisionControl.tsx: the joined split control(
DecisionControl+DecisionNoteField+DecisionNoteDialog). The incumbent primarysegment never opens the popover and deliberately never fades while it is open (audit
finding 3 reversed, rationale in a code comment). The caret popover renders
role="menu"rows overActionMenuItemwith component-implemented roving focus(Up/Down wrap, Home/End), an in-place composer morph (same element, children swap),
the three-rung Esc ladder (composer to menu keeping the draft, menu to close focusing
the caret, else propagate; consumed on exactly the two acting rungs), Mod+Enter submit
with Enter-is-newline, empty-note no-op-and-refocus (the action is never
disabled-gray), one ConfirmDialog path for
discard-and-finishwith cancel returningto the open menu, and
busyclosing everything.packages/ui/hooks/useDismissablePopover.ts: shared pointerdown-outside plus Escapedismissal with the framed-surface strategy (
dismissOnIframeFocus: window blur, then anext-task
activeElementiframe check), spec §2.4. Any Escape it acts on is consumed(
preventDefault+stopPropagationat document, which bubbles before the apps'window-level ladders) and
defaultPreventedevents are skipped, so one Escape is neverboth "close the menu" and a rung of a host ladder. An outside-focus Escape while the
composer is open closes the whole popover (it is an outside-dismissal gesture, like an
outside click); the draft is kept either way. Commented at the
dismisscall site andpinned by a DOM test.
packages/ui/shortcuts/decisionControl.shortcuts.ts: scopedecision-controlat theshortcuts root (spec §4: both apps, identical semantics), documenting only the two
chords the control implements:
Mod+Entersubmit-note andEscapestep-back. Enter isdeliberately not documented as submit anywhere.
ActionMenuItemrole?: 'menuitem'+className?, andExitButtonappearance?: 'pill' | 'ghost'(ghost keeps its
aria-label; the pinned default-label test still passes).decisionSpec.test.ts(pure lane, full state matrix and invariant sweeps),DecisionControl.test.tsx(DOM lane, registered in the CIRun UI seam-contract + DOM testsfile list per spec §8B so it cannot silently skip),and a shortcuts.test.ts guard that the new scope composes into both adopting
registries ahead of PR2/PR3.
@plannotator/ui0.37.0 → 0.38.0 (spec §2.1 cost note and §9, "batch the bump intoPR1");
smoke:packageverified the tarball. One-line version prose updates inREADME/HANDOFF.
Support-surface stance (one asymmetry worth naming)
The component, hook and util files are deliberately not host-supported surface. Like
ActionMenu/ConfirmDialog, they are absent from the README supported-import list andtsconfig.strict-consumer.json, and the only HANDOFF change is one bullet in the"Unsupported" list (spec §2.1). One asymmetry:
decisionControlShortcutsis exportedfrom the blessed
@plannotator/ui/shortcutsbarrel, because spec §4 places every scopethere (the registry entries feed the in-app help modal and the generated marketing
shortcuts page). The scope is documentation data, not a backend seam; no
configurePlannotatorUIseam is added anywhere.Self-flagged decisions, with review rulings
className?onActionMenuItem. Spec §2.3 names one additive prop (role) butalso demands "Destructive rows get the existing destructive text token", and
ActionMenuItemhad no tone/styling vector at all. Ruling: justified. A dedicatedtone?:prop was considered and set aside as the larger API for the same one classstring;
className(defaultundefined) keeps existing consumers byte-identical.DecisionSpecInput. The task brief asked for "platform statein the type model"; spec §2.2's input type has no such field and §3.4 maps platform
(PR) mode onto the same output model in PR6 (composer-less items,
Post Commentsprimary, all expressible today). Ruling: spec wins; the output types already carry
the platform shape, and the input grows an arm only if PR6 needs one.
discard-and-finish/approve-with-notes. See "Open item"below; left as implemented pending a maintainer ruling.
packages/ui/package.json") and §9 ("batch the bump into PR1 and PR7"). Minor perthe 0.36.0/0.37.0 precedent: new files land in the published tarball even though they
are not supported surface.
bun.lockdeliberately untouched (bump-commit precedent496a1fd1).Open item awaiting maintainer ruling (F2)
The
count: 0+hasFeedback: truecell (annotate sessions whose feedback is directedits, saved-file changes, or attachments, with no annotations): the spec's matrix keys
on "feedback (n)" and never addresses it. As implemented, the primary is
Send Feedbackwith no count pill,
Send with a note…renders with a countless subtitle, and bothdiscard-and-finish("discard 0 annotations…") andapprove-with-notes("your 0annotations ride along") are omitted. If the maintainer wants an approve or discard path
for direct-edit-only sessions, that is a one-conditional change plus copy for the zero
form.
Handoff notes for PR2/PR3
specprop changes while the composer isopen and the active item id disappears from the new spec (e.g. an annotation delete
flips feedback to empty), the popover renders an empty shell until Escape. The control
keeps state by item id on purpose (drafts survive spec updates); the adopting apps
should close or morph the popover gracefully when the active item leaves the spec, or
PR2 adds that effect to the control itself.
Done/Approveprimaries follow theFeedbackButtonresponsive pattern (icon-only below the label breakpoint) where the old
ApproveButtonshowed a compactOKlabel. The tooltip (title) carries theaccessible name. Narrow-desktop reviewers will see a visual change;
mobileLabel("Send feedback" etc.) is in the spec model for the compact rows PR2/PR3 generate.
decision-controlscope must be added toannotateSettingsShortcutRegistryandreviewSettingsShortcutRegistryin the adopting PRs (spec §4); the shortcuts.test.tsguard already proves the composition is collision-free.
AI-assisted (Claude) under maintainer direction.