Skip to content

Answer decisions on phones from one pane, with a save bar and a closure - #473

Merged
MaggieAppleton merged 7 commits into
mainfrom
mobile/decisions-phone
Oct 11, 2026
Merged

MaggieAppleton merged 7 commits into
mainfrom
mobile/decisions-phone

Conversation

@MaggieAppleton

@MaggieAppleton MaggieAppleton commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Why

On a phone, Decisions was hard to finish (critique M-32, M-33 and M-34):

  • M-32: every open decision showed up twice, as a full card in the document and again in Decisions. Each card always showed Discard and Save, and Save was a 47×44 button in the bottom-right corner.
  • M-33: after a save, the card disappeared into "1 resolved" without any sign that it had worked.
  • M-34: there was about 45px of empty space above the first card, and "Add an option" was cramped once the keyboard opened.

What changed (phones only)

"Phone" means a coarse pointer and a width of 600px or less, or a height of 500px or less. That condition is defined once in packages/editor/src/phone.ts, so it can be replaced by the shared phone-class hook. CSS only reacts to .plan-decisions[data-phone].

  • Inline decisions become chips. An open decision in the document is now a chip, at least 44px tall, showing a decision icon and the question (up to 2 lines) with a "›". Tapping it opens Decisions and focuses that card, using the existing reveal mechanism through a new onShowDecision widget option. Decided cards and their gutter markers in the document are unchanged.
  • Less empty space. The top inset is now 16px and the side gutter is 16px.
  • Options stay as full-width rows, not chips. Every option has a description, and chips would drop it or squeeze it. The rows are already 56–76px tall.
  • A save bar acts only on your own answers. It covers only cards this person chose an answer on in this session, tracked per card in QuestionView. A collaborator's draft and a visible Planner suggestion never count, and Clear and Save never touch them.
    • It reads "N unsaved answers · Clear · Save", and Save is 44px tall.
    • It rises in from below with a springy ease and only fades when reduced motion is on. It hides while the add-option field has focus.
    • Its position accounts for the safe area and leaves room for a future tab bar through --phone-bottom-chrome (default 0).
    • It calls each complete card's own submit(), so claimSubmit(), the revision check and persistence-before-publication are unchanged.
  • Unsaved answers survive leaving the pane, and stay yours. A small session store, mirrored to sessionStorage (wrapped in try/catch), keeps the answers this person chose on each question of each card. A card counts in the bar only while the shared draft still matches those answers. This is rechecked on every draft change and after a remount, so a tab switch or a reload keeps it. If a collaborator changes the card afterwards, live or while you're away, the card leaves your bar and offers its own visible Save, the same deliberate tap as on desktop. Your bar never submits their choice. The store is cleared on Clear and once the card resolves.
  • Failed saves are visible. If a save fails, the bar says "Couldn't save N answers" (role="alert"), its button becomes "Try again", and the failed card is scrolled into view and focused so its own explanation is visible. It is scrolled back into view if a card saved alongside it folds away above it.
  • Partly answered cards are named. If a multi-question card is only partly answered and nothing else can be saved, the bar says "1 decision needs another answer" and offers Show, which takes you to that card. Save is never left disabled without a reason.
  • Suggestions are accepted on their own card. On a phone, a card that shows a Planner suggestion has its own Accept suggestion button.
  • Clear instead of Discard on the bar. Clear deselects only your own unsaved answers. Chopin already uses Discard to mean removing a decision, so each card keeps that action as a small, quiet "Discard decision" text button with the existing confirmation step.
  • "Decided" state. It is triggered only when the server publishes the card's status as decided, never by tapping Save.
    • If this client saved the card, it shows a confirmation, "Decided · You", with the chosen answer, then collapses into the resolved group after about 1.1s.
    • Anyone else's decision folds away quietly in a neutral style, without a status announcement.
    • With reduced motion there is no collapse animation.
    • The "N resolved" count includes a folding card straight away, so it agrees with the tab badge.
  • Add an option. The field is 16px. Its scroll margin leaves room for the hint and the next row above the keyboard (--keyboard-inset).

New tokens: --save-bar-open-dur, --save-bar-close-dur, --decision-closure-dur. Desktop is unchanged: the 1440×900 Decisions pane matches main pixel for pixel, apart from sidebar data.

Screenshots (iPhone 15 Pro unless noted; left = before, right = after)

Decisions at rest
Decisions at rest, before and after

With your own unsaved answer: the save bar
Dirty state with save bar, before and after

After Save: Decided · You (before: the card just vanished)
Confirmed state, before and after

Then it folds into resolved history
Resolved after collapse

A failed save: the bar says so, and the card is brought into view and focused
Failed save

Two users: hubot's own save confirms for hubot (left); octocat's open answer is untouched and octocat's bar still counts only octocat's answer (right)
Two users

Inline decision in the document becomes a chip (after: iPhone SE 375px)
Inline decision chip, before and after

Add an option with the keyboard (393×380)
Add option above keyboard

Desktop 1440×900, unchanged
Desktop decisions

Testing

  • bun test apps/web packages/editor packages/question: 1468 pass. packages/editor/src/save-bar.test.ts covers what the bar counts, failure detection, incomplete cards, local-save tracking, which cards fold away, and the "Decided · You / @handle" label. tokens.test.ts allows the ghost tier on the deferred Discard.
  • bun run types: pass. bun run ci: passes after the hash renewal below.
  • e2e/decisions-phone.e2e.ts runs in the fixtures project:
    • The bar appears only when there are unsaved answers. Clear empties the draft. Save shows "Decided · You" and then "1 resolved".
    • Two users (octocat and hubot): hubot's bar ignores octocat's selection. hubot's Clear and Save don't affect it. octocat's bar keeps counting only octocat's answer, and hubot's decision makes no status announcement for octocat.
    • On a phone, select an answer, go to Document and back, then reload: the bar still shows "1 unsaved answer" and Save works.
    • A selection the bar doesn't count keeps the card's own Save.
    • A collaborator changes your selection, both live and while you're on Document: it leaves your bar, and the card's own Save appears.
    • The chip opens Decisions focused on that card.
    • Desktop keeps the in-card Save and the inline card.
  • Two existing phone specs were updated on purpose: responsive-decisions and discarded-mobile-geometry.
  • Checked by hand with headless Playwright, including a forced failed submit (the socket was closed on question:submit).

Needs a real device: the iOS keyboard together with --keyboard-inset, the safe-area offset of the bar, and how the bar's motion feels.

Design-contract review

Reviewed and renewed in Renew design-contract review hashes for question-view.tsx, plan-editor.tsx and questionnaire.tsx. No new dynamic class or style logic at the reviewed sites; the only new class choice is a ternary between two literal button tiers on Discard.

Rebase notes

#474 landed first with its own packages/editor/src/phone.ts (comment surfaces, coarse pointer and ≤430px). This PR's phone definition (coarse pointer and ≤600px, or ≤500px tall for landscape) is identical to #471's packages/editor/src/widgets/phone.ts, so decisions.tsx and widgets/questionnaire.tsx now import usePhone from widgets/phone and #474's phone.ts is unchanged. Behaviour is the same as before. Unifying both onto the shared phone class from #475 is still a follow-up.

Known future overlap

The planned "decision decks" work (lane decision-decks) may later restyle decision cards. Everything in this PR is behind the phone condition and in the presentation layer: the chip, the bar, the confirmation, and the opt-in QuestionView props deferSave and onPending. Records, drafts and the submit path are not touched.

🤖 Generated with Claude Code

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Changes needed

I tested this against a fake-GitHub server with DEV_QUESTIONS on: iPhone 15 Pro, iPhone SE, phone landscape, reduced motion, desktop at 1440×900 and 1280×800, and two browser contexts signed in as different users (octocat and hubot). I also forced a failed submit by intercepting the socket. The per-card submit path itself is unchanged: claimSubmit(), the revision check and persistence before publication all still apply. Concurrent Save on the same card from both users is fine; both clients end with "1 resolved" and no error. The problems are in what the bar adds up and what it hides.

Must fix

  1. A failed save is invisible (save-bar.tsx, decisions.tsx). I tapped Save with 2 cards dirty while scrolled to the bottom and made one submit fail. The successful card folded away. The failed card's "Couldn't save" callout was above the viewport, and only a red sliver showed at the top. The bar quietly went back to "1 unsaved answer · Clear · Save", so it looks as if Save did nothing. Desktop doesn't have this problem because the error renders right beside the Save that was pressed. Fix: when a card goes from submitting to an error, have the bar say so (for example "Couldn't save 1 answer" in a role="alert"), scroll that card into view and focus it. PendingAnswers needs an error field for this.

  2. Planner suggestions count as "unsaved" and get accepted in bulk (question-view.tsx, the answeredCount block). answeredCount uses projection.draft for the current question. When a suggestion is visible and the draft is untouched, projectSuggestion fills in choice = suggestion. So any card with meta.suggested opens the bar on load with "1 unsaved answer" even though nobody touched it. One Save then accepts every visible suggestion on every card at once. On desktop, accepting a suggestion is a deliberate per-card Save. Fix: count from drafts[...] only, so a suggestion is not an unsaved answer. Either leave suggestion-only cards out of the bar's Save, or show them separately ("Accept suggestion" on the card).

  3. Shared drafts: the bar counts and acts on other people's work (save-bar.tsx:593 and :603). I tested this with two users:

    • hubot's bar shows "1 unsaved answer" for an option octocat selected on a different card.
    • hubot's Clear wiped octocat's selection on octocat's screen too (In SQLite became unchecked and octocat's bar closed).
    • hubot's Save submitted octocat's answer as hubot. octocat then saw a "Decided" confirmation with no name for a save they never made.

    Deselecting a shared draft by hand has the same reach, but you can see the card you are touching. A pane-wide Clear or Save acts on cards you may never have looked at. Recommendation:

    • Limit the bar's count, Clear and Save to cards this client edited in this session. Record this in onChange/markHumanEdit per card; don't infer it from draft contents.
    • For cards that only a collaborator has touched, show them on the card as now (presence face), and leave them out of the bar.
    • If you keep the pane-wide scope, at least rename the label (for example "2 answers ready") and don't let Clear touch drafts this client didn't edit.

Should fix

  1. The confirmation doesn't say who decided (decisions.tsx, Confirmed). It plays whenever anyone's card becomes decided: another member, the Planner, or an MCP caller. Seeing your own save confirmed is good. Seeing someone else's decision announced in the same voice ("Decided ✓") is confusing, and the card you were reading suddenly turns green and folds away. Add the resolver ("In SQLite · hubot", like the prototype's "· You" / "· Octocat" in 09-decisions-saved.png). You could also play the full green hold only for the local client's own submit, and fold a remote decision with a plain collapse. role="status" on a remote decision is also noisy for screen readers.

  2. The bar can't save some cards and doesn't say why (summarize). A multi-question card that is only partly answered adds to "N unsaved answers" but is skipped by Save. When it is the only dirty card, Save is disabled and nothing tells you which card needs work. Say "1 needs another answer", or make Save focus the first incomplete card.

Nice to have

  1. The chip hides the question on narrow phones (styles.css, .decision-chip). At 375px the chip reads "Decision · Where should…" and "Decision · Which of thes…", which is two words of the question. The icon already says "decision", so drop the "Decision ·" label, or allow 2 lines.
  2. Doubled punctuation in the chip's label. The aria-label reads "Decision: Where should room state live?. Answer in Decisions"; the prompt already ends in "?". Use "Decision: {question}, answer in Decisions" instead.
  3. "Clear" versus "Discard". Keeping "Discard" for removing the decision is right; I agree with the reasoning. "Discard decision" as a ghost button at the left of every card footer is still the most prominent text in the footer on a phone (see the screenshots). Consider moving it into the card's overflow menu, or using a smaller tertiary text button.
  4. The pending count includes cards that are folding (decisions.tsx:337). pending includes cards that are mid-confirmation, so outstanding stays high for about 1.4s while the tab badge has already dropped. That is harmless, but the pane header and the badge disagree briefly.

Verified, no change needed

  • Chip to Decisions: tapping a chip opens Decisions, focuses that card and brings it into view, including the second card on iPhone SE. Decided cards and their gutter markers in the document are unchanged.
  • Bar placement: the bar stays at the bottom of the pane while scrolling. The pane's bottom padding keeps the last card's footer clear of it. Its targets are 44px, and it uses env(safe-area-inset-bottom). It hides while the add-option field has focus, and that field is 16px.
  • Reduced motion: the bar only fades and the confirmation doesn't collapse.
  • Desktop: 1440 and 1280 with a fine pointer keep the inline cards and the in-card Discard/Save, with no bar and no chips. Every new style is gated on [data-phone].
  • Landscape phone: this is still the split layout (owned by another slice), and the bar sits correctly inside the Decisions column.
  • Compared with the prototype (08 and 09): using full-width option rows instead of chips is a defensible call, because the descriptions matter. The prototype's "Decided" list shows who resolved each decision; this PR's confirmation leaves that out (see 4).

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Changes needed (one remaining issue; everything else is fixed)

I re-checked head 8ba699db on a fresh server and database with DEV_QUESTIONS on, using two phone contexts signed in as octocat and hubot. Locally, bun test packages/editor packages/question (836 tests) and bun run types pass. In CI, ci fails only on the design-contract review hashes (questionnaire, question-view and plan-editor, as the PR body says); e2e was still pending when I posted.

Fixed, verified in the browser

  • Shared drafts. octocat's selection on a card hubot never touched no longer shows in hubot's bar. octocat's Clear cleared only octocat's card and left hubot's Anchors selection alone.
  • Closure. octocat sees "Decided · You" with a status announcement. hubot sees only an aria-hidden fold, with no green hold.
  • Remembering your answers. Switching tabs (Document → Decisions) and reloading both keep "1 unsaved answer".
  • Storage failures. With sessionStorage getItem/setItem throwing for the touched key, the bar still works and survives a tab switch, using memory only.
  • Failed save. The bar shows "Couldn't save 1 answer" as an alert, with Try again. Try again worked, ending at "2 resolved".
  • Cross-document entries. Card ids are ULIDs, so entries from different documents can't collide. Entries for decided cards in other documents stay in sessionStorage until you open that document. That's harmless: they are bounded by the session and are only ever ids.
  • Chip. The label is gone, the punctuation is fixed, and the "Show" path works for a partly answered card.

Must fix: "touched" is a per-card flag, so the bar can still submit someone else's answer

touched.ts remembers that you chose something on a card, not what you chose. The bar then counts and saves whatever is in the shared draft. I reproduced it two ways:

  1. Live. hubot selects "In SQLite", then octocat changes the same card to "On disk as MDX". hubot's bar still says "1 unsaved answer". hubot taps Save and the server records choices: ["On disk as MDX"], resolver: "hubot". hubot saved octocat's answer believing it was their own.
  2. After leaving and coming back. hubot picks Anchors and then navigates away. octocat unchecks Anchors and picks Export. hubot comes back and the sessionStorage flag is still set. hadAnswers starts false after the remount, so the "selection went away" check never fires, and hubot's bar counts octocat's Export as hubot's unsaved answer.

Fix: store this person's selections, not a boolean.

  • Keep {cardId: {questionId: optionIds}} in the touched store and sessionStorage.
  • Count a card in the bar only while the current draft equals this person's last recorded selection.
  • Once a collaborator changes the card, drop it from the bar and fall back to the card's own Save, which is visible and deliberate (the same as desktop).
  • Check it again on remount, against the stored selection.

Should fix

  • The failed card scrolls away again (decisions.tsx, showCard). It is centred when the failure arrives. About 1.4s later the successful card's confirmation collapses, the layout shifts, and the failed card ends up above the viewport. The focused article's top was at -169px, and only a sliver of the "Couldn't save" callout was visible (screenshot r2-07). Fix: scroll after the closure finishes, or scroll the error callout itself into view with block: "nearest" once the collapse is done.

Product note, no change needed

On a card only a collaborator touched, hubot now sees a per-card Save (the safety net). It can still submit octocat's answer. It does so as a visible, deliberate action on that card, as on desktop, so I think that is acceptable. If "never submit someone else's selection from a phone" is the rule, that button should become "Save their answer", or be hidden.

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Looks good. Nobody can submit someone else's choice from their own save bar any more. One small follow-up is below and doesn't block.

Checked on head ca4e7313 with a fresh server and database (DEV_QUESTIONS on), using two phone contexts signed in as octocat and hubot. Locally, bun test packages/editor packages/question (837 tests) and bun run types pass. In CI, the format/lint/types/tests check fails only on the design-contract review hashes, as before. The container build passes and e2e was still pending when I posted.

Both earlier repros are fixed

  • Live change. hubot picks "In SQLite", then octocat changes it to "On disk as MDX". hubot's bar closes, the card shows its own Save, and octocat's bar opens. When hubot picks again, it goes back to hubot's bar and leaves octocat's.
  • Leave, someone changes it, come back (multi-select). hubot checks Anchors, and it survives a reload. hubot leaves. octocat unchecks Anchors and checks Export. When hubot returns, the bar stays closed and the card offers its own Save.
  • Save racing a collaborator's change. hubot's bar Save, tapped at the same moment as octocat's change, submitted hubot's own "In SQLite" with resolver hubot. The revision check still guards the server side.
  • Storage throwing (sessionStorage getItem/setItem for the key) still degrades to memory only; the bar works and survives a tab switch.
  • Custom text. No new custom answers can be created, so I checked this by reading the code: selectionOf compares an existing custom draft by its trimmed text.

Product notes, no change needed

  • Multi-select merge. octocat checks Anchors and hubot adds Export, so octocat's bar drops the card, which is correct. If octocat then checks another option, remember records the whole visible set, Export included, as octocat's. octocat's bar would then save Export under octocat's name. octocat changed the card while Export was visibly checked, so I'd call that an endorsement and accept it.
  • Multi-question cards. stillMine checks only the questions this person answered. A collaborator's answer to another question on the same card goes into this person's save. That fits the card being one shared questionnaire. The sample data has only single-question cards, so I couldn't test this in the browser.

Follow-up, not blocking

  • The failed card still ends up off-screen (decisions.tsx, the "follow it while it keeps focus" effect). I saved 2 cards and made one fail. The bar shows "Couldn't save 1 answer" and Try again (good). Three seconds later, though, the focused failed card's top was at -169px, with only a sliver of its callout visible. Running scrollIntoView({block:"center"}) on the same element by hand brings it to 137px, so the follow-up scroll is not firing in this sequence. Likely cause: the effect's timer is cleared and re-armed on each closing change, or the activeElement check bails out. Because the bar now states the failure and Try again works, this is polish.

MaggieAppleton and others added 7 commits October 11, 2026 10:29
On a phone, open decisions in the document become one-line chips that open
Decisions focused on that card. Decisions drops its top inset, moves Save to
a bar that appears only with unsaved answers, and shows a brief Decided
confirmation once the server publishes the card as decided before folding it
into resolved history. Desktop is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bar now counts, clears and saves only cards this person chose answers
on, never a collaborator's draft or a visible Planner suggestion, which is
accepted on its own card. A failed save says so in the bar and brings the
card into view. A partly answered card is named rather than leaving Save
disabled. Only this person's own save holds the Decided confirmation, with
who decided; anyone else's decision folds away quietly. Chips give the
question the full width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Which cards this person answered now lives in a session store rather than
the card, so leaving Decisions or reloading keeps the answer in the save
bar. It is forgotten on Clear, once the card resolves, or when the
selection is gone from the draft. A card whose selection the bar does not
count keeps its own Save, so no answer is left without a way to save it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session store now keeps the answers this person chose, not a flag. A
card stays in their save bar only while the shared draft still matches,
checked on every draft change and after a remount, so a collaborator's
later change is never saved under their name; the card then offers its own
Save. A failed card that loses its place to a card folding away above it is
brought back into view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed card is now reported when its error first appears, not only after
the bar saw it saving, since a reply can land first. While it still shows
its error or keeps focus, layout changes in the list re-centre it, so a
card folding away above it no longer pushes it off screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r.tsx and questionnaire.tsx

- packages/question/src/react/question-view.tsx: adds pending-answer reporting
  and a deferred-save footer; the only new class choice is a ternary between
  two literal button tiers on Discard.
- packages/editor/src/plan-editor.tsx: threads an onShowDecision callback
  through to widget options; no style or class logic.
- packages/editor/src/widgets/questionnaire.tsx: wires the save-bar context
  and a phone decision chip with literal classes; reviewed dynamic sites are
  unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaggieAppleton
MaggieAppleton merged commit f8b34f8 into main Oct 11, 2026
3 checks passed
@MaggieAppleton
MaggieAppleton deleted the mobile/decisions-phone branch October 11, 2026 09:45
@coolify-githubnext-app

Copy link
Copy Markdown

The preview deployment for chopin failed. 🔴

Open Build Logs | Open Application Logs

Last updated at: 2026-10-11 09:45:56 CET

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant