Skip to content

Check the page when Publish and share is pressed - #580

Merged
richshaw merged 33 commits into
claude/relaxed-dijkstra-cpx7zo-3-publish-buttonfrom
claude/relaxed-dijkstra-cpx7zo-4-publish-check
Oct 25, 2026
Merged

richshaw merged 33 commits into
claude/relaxed-dijkstra-cpx7zo-3-publish-buttonfrom
claude/relaxed-dijkstra-cpx7zo-4-publish-check

Conversation

@richshaw

@richshaw richshaw commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Part of #503, which the owner's design comment narrows to "the check at Publish". Its other checks (merging copies, empty fields, unnamed fields, ungrouped dates) aren't in this PR, so #503 stays open for the owner to close or rewrite. This is PR 4 of 5 for the "Publish and share" redesign in #498 (row I), stacked on #578. Review it against claude/relaxed-dijkstra-cpx7zo-3-publish-button.

The problem

Publish published whatever was stored, including:

  • a hand-made draft whose only slot was still empty, which went out as "Untitled slot";
  • an AI draft with a slot that still said "TBD".

Once published, a signup can't go back to being a draft. There was also a quieter race: edits on the Build tab save about 800 ms after the last keystroke, so a Publish straight after typing published the version from before the edit.

The fix

Publish and share sends what's waiting to save, then checks.

  • When it's pressed, the button first sends any Build edits still waiting to save and waits for them. That includes saves already on their way and those a Build tab left behind when the organizer switched tabs.
  • While it waits: aria-busy and aria-disabled are set at once, so a second press is refused and focus stays on the button.
  • The busy look: "Checking…" with a spinner shows only if the answer takes longer than about 300 ms. It stays at least 400 ms, unless an answer shows first: it goes in the same render as the check's panel, the share panel or a refusal.
  • The width holds: both labels sit in one grid cell, so the header doesn't move. The accessible name is always the visible label.
  • The wait is capped at 10 s, then it goes on. If a save is refused, it doesn't publish. It says "A change didn’t save. Fix it, then publish." instead: under the header on a computer, above the button in the bar on a phone.
  • The check: the server action reads the draft fresh, policy guard included, and runs checkBeforePublish. It's a pure function, src/lib/publish-check.ts, and returns the first problem:
Problem Panel Buttons
No slots "Nothing to sign up for yet" / "There are no slots yet. Add one, then publish." Add a slot, Not now
No fields "Nothing to sign up for yet" / "Add a field that says what people sign up for, then publish." Not now
Every slot empty "Nothing to sign up for yet" / "The slot is empty…" or "Every slot is empty. Say what people sign up for, then publish." Fill it in, Not now
A slot still says TBD, TBC or TBA, as a whole word "Before people see it" / "Cashier TBD · Sat, Nov 14 still says “TBD”. People will see that, and a published signup can’t go back to being a draft." or "3 slots still say “TBA”, “TBD” or “TBC”." Name it first, Publish anyway
  • How the warning names a slot: by its values, leaving out any field whose value is made only of those words, give or take punctuation, such as "TBA", "(TBD)" or "TBD/TBC". So "Cashier TBD" stays in, and a Who of "TBA" gives "Fruit and water · Sat, Apr 6 still says “TBA”".
  • The first three block, and the form can't wave them through. Publish anyway passes only the warning.
  • With nothing wrong, it publishes at once and opens After Publish, a share panel to send the link #574's share panel.
  • A signup that's no longer a draft skips the check and gets today's "This signup changed since the page loaded. Reload to see the latest."
  • The MCP publish_signup tool, POST /api/signups/[id]/publish and the service are unchanged, apart from the service taking an optional acknowledged that only the header's Publish anyway passes (below).

The panel

  • It's a popover by the button on a computer, and a sheet over the bar on a phone, with the fix first and 48px buttons.
  • It's named by its title and described by its text, and focus starts on the fix.
  • Escape, Not now and ✕ return focus to Publish and share. On a computer, pressing Publish and share again closes it.
  • The header's live region says nothing for a check.

The fixes

  • Fill it in and Name it first open that slot's editor on the Build tab, scrolled into view, with focus in the field that needs it: the TBD's own field, or the first field.
  • Add a slot adds one and opens it.
  • From the Responses or Settings tab, the request waits in a small client bridge in the layout (BuildBridge) while it navigates to Build. The Build tab takes it once when it registers. It's never in the URL, so Back, Forward, a reload or a bookmark can't replay it.
  • A request the organizer walks away from before Build loads is dropped, so a later visit to Build can't add an empty slot. That happens when:
    • the path settles on any tab but Build;
    • they make a plain click on any tab but Build, the one they're on included (a Ctrl/Cmd, Shift, Alt or middle click doesn't navigate this window and keeps it);
    • they go Back or Forward;
    • any form is submitted, such as Settings' Save, which comes back to the same path.
  • A fix for a slot this tab hasn't loaded gives focus back to the button.

What's recorded. #525 records signup.publish_refused in the publish service. This check now stops "no slots" before the service, so the check writes the same event itself, through recordPublishCheck in src/services/signups.ts (policy guard, best effort, outside any transaction; a failed read is logged and never fails the publish):

  • { code: 'conflict', reason: 'no_slots' | 'no_fields' | 'all_blank' } for a blocking problem. no_slots is the same shape Record link copies, failed AI drafts and refused publishes #525 writes.
  • { reason: 'placeholder', warning: true } for the warning. A warning doesn't stop anyone, so docs/telemetry.md says to count real refusals as the rows whose payload has code.
  • After a warning, Publish anyway adds acknowledged: 'placeholder' to the signup.published row. A publish after the slots were fixed has no flag. The REST and MCP publishes can't set it.

Choices this makes

  • The check runs on the server, on the stored draft, after the flush. The header's own data is the layout's, which doesn't refresh while the organizer edits in Build.
    • The read isn't locked. A concurrent edit, say from an AI assistant, could empty the slots between the check and publishSignup's lock. The service's own "Add a slot before publishing." still covers the case where the last slot is deleted.
  • Only placeholder words that stand alone count. "Tbacco" and "ÉTBA" don't. The regex is the one from the closed Point out fixable problems in a draft on its review page #530.
  • The check panel is in the first load, not lazy. Lazy-loading it saved 213 B on the signup tabs and added 21 B to every page's webpack runtime, which put / and /s/[slug] over their budgets.
  • The busy look follows the owner's tophat (comment below): no flash for a quick answer, and "Checking…" rather than "Publishing…", since the answer may be a check.
  • Left for PR 5: marking slots that still say TBD on the Build page, and Done (or Enter) after a fix putting focus back on the button.
  • Known limits:
    • After the 10 s cap, the check reads what's stored. A save slower than that can still be on its way, so the panel can say a slot is empty that the organizer has just typed into.
    • A Duplicate whose save takes longer than that 10 s can take focus from the fix's slot when it lands. The root is Put a duplicated slot right after the original and open it #568's code, which opens the copy whenever its save returns, whatever was opened since; main has the same steal when another slot is opened meanwhile. It wants a follow-up there.
    • Back or Forward to Build shows Next's cached Build payload, which Build's own saves don't refresh. That's older than this PR and worth its own issue.

Tests

  • Unit:
    • publish-check.test.ts: each kind, the order of precedence, near-misses, the slot label (whole-value and punctuation-only placeholders left out, "Cashier TBD" kept), every distinct word, and the copy for one slot and for several.
    • useBuildState.test.ts: the flush sends pending saves and waits for those in flight and those that aren't debounced (a duplicated slot included), loops until nothing waits, reports a failure, and doesn't send twice on unmount.
    • useDelayedFlag.test.ts: on fake timers, an instant answer never shows, a slow one shows at 300 ms, an early end keeps it to 400 ms, and a press after it hid waits again.
  • Component:
    • PublishButton.test.tsx: the flush resolves before the action; the 10 s cap; a refused save stops it; the panel's focus and dismissal; the fixes; a phone sheet. On fake timers: "Checking…" on both buttons only after 300 ms, gone the moment the panel or a refusal shows and not back after Escape; and the stacked labels' names.
    • BuildBridge.test.tsx, with the real TabsNav:
      • a request is taken once, a remount doesn't replay it, and it's kept through a re-render on the same page and a click on Build;
      • it's dropped when the path settles elsewhere, on a plain click on the current or another tab, on Back, and on a form submit, but kept through a Ctrl-, Cmd-, Shift- or Alt-click and a middle click (auxclick);
      • a departed tab's failure stops counting once a new tab registers.
  • DB:
    • actions.db.test.ts: each kind blocks or warns and leaves a draft; an acknowledged placeholder publishes; an acknowledged blocking problem still blocks; a viewer is refused; a non-draft gets the reload message.
    • Telemetry: each check kind writes one row with the right payload, by the organizer who pressed it. Publish anyway writes acknowledged: 'placeholder' on signup.published, and a fix-then-publish, or a Publish anyway after the slot was named elsewhere, writes { from, to } only. A viewer writes nothing. A read that throws records nothing and still returns the check. The service's own row still appears when the check is bypassed.
  • e2e (organizer.spec.ts, 1280 and 390):
    • an empty slot stops Publish, Escape returns focus, and Fill it in focuses What;
    • a TBD slot warns, published from Responses: Name it first lands on Build with the slot open, and Publish anyway opens the share panel;
    • Back and Forward after Add a slot from another tab write nothing.
  • Help walkthrough: the phone run goes through the empty-slot check, the TBD warning and Name it first, and both runs keep Put a duplicated slot right after the original and open it #568's Duplicate steps.
  • Brought up to date with One Publish and share button, in a bar on phones; drafts show no public link #578 and, through it, main and the earlier PRs: Record link copies, failed AI drafts and refused publishes #525 and Tell Magic Compose to use one slot with a capacity, not a row per person #526 in 2fd9d88 and 0c9f1c5; Put a duplicated slot right after the original and open it #568 in 5d166f4; and the owner's tophat rounds on Ask before closing a signup, and move Close signup into the ⋯ menu #571 and After Publish, a share panel to send the link #574, with Cut an over-long Magic Compose title or description instead of throwing the draft away #579, in af65c82, daad50c, 84f146e and f95e870.
  • Passed:
    • on f95e870: pnpm lint && pnpm typecheck && pnpm knip && pnpm test (1675);
    • on 84f146e: pnpm test:db for the touched files, pnpm build && pnpm budgets, and the chromium e2e for organizer.spec.ts and the publish walkthrough (20);
    • on e8150c0: the full pnpm test:db (562) and the full chromium e2e with CI=1 (72).
  • Independent checks: a tophat on 3952591, and code reviews of every round, including the owner's tophat round, are below. Several of them include a running-app check at 1280 and 390. The review summarises the first five rounds. The latest fix (b3316f9, "TBD/TBC" in the label) closes an edge case the latest re-check found; I read it, and it hasn't had a separate check of its own.

Budgets

Every signup tab's first load grows by about 1.7 kB gzip, and Build's by 2.7 kB. The tabs carry:

  • the Publish and share button, its check panel (static, as above) and its delayed busy look;
  • the bridge to the Build tab, with its drop triggers.

Build also carries the flush that makes Publish wait for its saves, and the code that opens a slot from the check. Each budget goes to the next whole kB above its new size. The owner's tophat round added about 220 B per tab, which still fits.

Page #578 (21eb67c) This PR (f95e870) Change Budget
/app/signups/[id]/build 155.3 kB 157.98 kB +2.7 156 → 158
/app/signups/[id]/responses 128.1 kB 129.84 kB +1.7 129 → 130
/app/signups/[id]/settings 127.7 kB 129.38 kB +1.7 128 → 130
/app/signups/[id]/edit, /app/signups/[id] 126.7 kB 128.42 kB +1.7 127 → 129
/, /s/[slug], preview +6–7 B unchanged

Docs

  • Help: create-and-publish-a-signup has a note under the Publish step covering Nothing to sign up for yet, Fill it in, Before people see it, Name it first and Publish anyway. Those names are in its UI list, and the walkthrough clicks through them. No screenshots change.
  • CHANGELOG: a Find fixable problems in a draft and offer one-tap fixes #503 entry under Changed, including what's recorded and the busy look. The A viewer who chooses Publish, Close or Delete gets the error page #390 line no longer lists "Add a slot before publishing." as something Publish shows.
  • docs/telemetry.md: the signup.publish_refused row lists:
    • the check's reasons and both of its sources;
    • how to count real refusals;
    • how to read acknowledged: 'placeholder'. A publish with no flag after a warning means the slots were fixed first, or the signup was published through the API or an AI assistant, which don't check.
  • Privacy page: already says the log records "a publish … that didn't go through", and the new flag holds nothing personal, so its text and date are unchanged.

The stack

  1. Ask before closing a signup, and move Close signup into the ⋯ menu #571: Close signup moves into ⋯ and asks first (One click on "Close signup" closes a signup for good, from the spot where Publish was #457).
  2. After Publish, a share panel to send the link #574: the share panel, with copies and shares recorded (After publishing, help the organizer share the link #505).
  3. One Publish and share button, in a bar on phones; drafts show no public link #578: one Publish and share button, in a bar on phones; drafts show no public link (Make Publish hard to miss, and say when it fails #499).
  4. This PR: the check when Publish and share is pressed (part of Find fixable problems in a draft and offer one-tap fixes #503).
  5. After a fix, hand focus back to Publish and share; mark slots that still say TBD #581: focus back on the button after a fix, and slots that still say TBD marked in Build.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H

checkBeforePublish reads a draft's fields and slots and returns the
first problem the code can prove, blocking ones first: no slots, no
fields, or every slot empty (only spaces count as empty) block; a slot
value still holding TBD, TBC or TBA as a word, in any case, is a
warning. The warning carries the slots, each word they hold, once and
in order, the field it's in on the first slot, and that slot named by
its other values as the page prints them ("Sat, Nov 14 · 1:00 PM").
It reads only a field's ref, label and type and a slot's id and
values, never the title or description.

publishCheckCopy gives the words the panel shows: "Nothing to sign up
for yet" with Fill it in (or Add a slot; none without fields) and Not
now, or "Before people see it": "<slot> still says “TBD”." or "3 slots
still say “TBD” or “TBA”." and that a published signup can't go back
to being a draft, with Name it first and Publish anyway.
publishCheckTarget says where the fix goes: the slot to open (or a
new one) and the field to focus. PublishCheckAnswer is what Publish
answers with: the check, its copy and its target.

The placeholder regex is the one from the closed draft-notices
branch, near-misses included ("TBDX", "Tbacco", "ÉTBA").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
useBuildState gains flushPendingSaves(): it sends each edit still
waiting out its 800 ms debounce at once, clearing its timer so it
can't be sent again, then waits until no save is waiting or in
flight, going round again for one a save sets off as it ends (adding
a list choice, then the cell that uses it). It resolves to whether
every save since it began succeeded. Until now a debounced save
stopped being tracked once its timer fired, and adds, deletes, moves,
group renames and settings saves were never tracked.

Saves are tracked where they run (withSave, the move path, the group
rename, and a settings save from the moment it's queued), and a
failure is counted as it happens. The unmount flush now drops the
timers it fires, so a flush after unmount (Publish on another tab)
waits for those saves without sending them twice. addRow resolves to
the new slot's id, or null when it's refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Narrows #503. Publish and share now saves what the Build tab has
pending, then checks the draft as stored, and publishes only when
nothing is wrong or the organizer chose Publish anyway.

- publishAction reads the signup fresh (the non-cached read, then the
  write guard, so a viewer still gets "You don’t have edit access."
  before anything is checked) and, while it's a draft, runs
  checkBeforePublish. No slots, no fields or every slot empty answer
  { check, copy, target } and publish nothing; so does a slot still
  saying TBD, TBC or TBA, unless the form says
  acknowledge=placeholder. The form can't wave a blocking problem
  through. The read takes no lock, so an edit landing between the
  check and the publish isn't checked; that window is narrow. A signup
  that isn't a draft goes straight to the service, which asks for a
  reload. The service, the REST endpoint and the MCP tool are
  unchanged, and the service's "Add a slot before publishing." stays
  for a race that reaches it.
- PublishButton replaces both Publish and share buttons (the computer
  header's and the phone bar's). Pressing it starts loading the share
  panel, waits for the Build tab's saves through BuildBridge, then
  runs the action in a transition. If a save failed it stops there and
  PublishRefusal says "A change didn’t save. Fix it, then publish."
  where Publish's refusals go, so the share panel never covers the
  save's error; a save that hasn't answered in 10 s no longer holds it
  up. "Publishing…" covers the wait; the button stays focusable
  (aria-disabled).
- PublishCheckPanel opens by the button: a non-modal popover on a
  computer ([Not now|Publish anyway] [fix]), a sheet over the bar on a
  phone (fix first, both 48px), named by its title and described by
  its text, focus on the fix (Not now when there's none). Escape, Not
  now and the sheet's ✕ put focus back on Publish and share, and on a
  computer pressing it again closes the popover. The live region says
  nothing. Popover and BottomSheet take a description id for this.
- BuildBridge, a client context in the [id] layout, lets the header
  reach the Build tab, which registers flushPendingSaves and openSlot.
  Fill it in and Name it first open the slot's editor with focus in
  the field to change, scrolled into view; Add a slot adds one and
  opens it. From another tab the bridge keeps the request and goes to
  Build's own address; the Build tab takes it once as it registers.
  Nothing goes in the address, so Back, Forward or a bookmark never
  opens it, or adds a slot, again. A Build tab that unmounts hands the
  bridge its last saves, and the next Publish waits for them. A slot
  the Build tab hasn't loaded (added elsewhere) opens nothing, and
  focus goes back to Publish and share.
- Tests: the order (the action waits for the flush), a failed save,
  the 10 s cap, the panel on a computer and a phone, each fix with and
  without the Build tab, a slot it can't open, Publish anyway's form;
  BuildBridge's request taken once and the saves a tab leaves behind;
  BuildWysiwyg opening a slot through the bridge, as a new slot and
  after a remount; DB tests for each kind, Publish anyway, a blocking
  problem the form can't wave through, a viewer and a non-draft. The
  existing viewer tests' signup gets a filled slot so its publish
  still goes through.
- e2e: at a computer's and a phone's size, an empty slot stops
  Publish, Escape returns focus, and Fill it in opens the slot; a TBD
  slot warns from the Responses tab, Name it first lands on Build with
  the slot open, and Publish anyway publishes. On a computer: Add a
  slot from another tab adds one, and Back and Forward add no more; a
  save the server refuses stops Publish with the line; an edit typed
  just before going to another tab is what Publish checks. The
  existing publish smoke fills its slot first (createDraft helper).
- CHANGELOG.

Budgets, measured against 344cd6c: every signup tab's first load grows
by 1,311 bytes gzip (PublishButton, PublishCheckPanel, BuildBridge;
the publish module is 1.8 kB with the Send icon it took from
PhoneBar), and Build by 2,140 (the flush and the slot opening as
well). Over budget at 128/128/127/127/155 kB, so: [id] and edit 127 ->
128 (127.8), settings 128 -> 129 (128.8), responses 128 -> 130 (129.2),
build 155 -> 157 (156.9). Lazy-loading the panel saved only 213 bytes
on these tabs and added 21 to every page's webpack runtime, which put
/ and /s/[slug] over theirs, so the panel stays in the first load.

Nothing stored or sent changes, so the legal pages stay as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
"Create and publish your first signup" gets a note under the Publish
step: when every slot is empty, Nothing to sign up for yet, and the
signup isn't published; Fill it in opens the first slot. When a slot
still says "TBD", Before people see it, with Name it first or Publish
anyway, and people see "TBD" until it's changed. The step then says
the share panel shows once it's published. The UI list gains the two
headings and three buttons.

The phone walkthrough's new draft has its one slot empty, so it now
goes through the check: Publish and share, Nothing to sign up for
yet, Fill it in (focus in What), "TBD", Publish and share again,
Before people see it, Name it first (focus back on the TBD), "Fruit
and water", then publishes. The computer walkthrough no longer waits
for the copied slot's date to save before publishing: Publish waits
for it now, so the share message's dates check that it does. No
picture shows these screens, so none is re-taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Conflicts:
- CHANGELOG: PR 3's #499 and #390 lines as they now read, the #503
  entry first, and #390 without "Add a slot before publishing.", which
  a signup with no slots now gets as the check.
- budgets.json: this branch's numbers, re-measured after the merge.
- ShareButton.test: ShareScope's new signupId, with the real Publish
  and share button. PublishButton.test gets the signupId too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
#525 counts refused publishes as signup.publish_refused, written by
publishSignup. The check now stops a draft with no slots before the
service sees it, so from the header that row was never written again.
recordPublishCheck (services/signups.ts) writes it for every check
that stops a publish, after reading the signup and running the write
guard, best effort and outside any transaction, as #525's own write
is: { code: 'conflict', reason } with no_slots as before (the same
payload the service writes) and now no_fields and all_blank, and
{ reason: 'placeholder', warning: true } for the warning, so a warning
shown and a later Publish anyway (which writes signup.published and
nothing more) can be told apart. The publish action calls it before
answering with the check. A viewer is refused before the check and
writes nothing; the MCP tool and the REST API still get the service's
own rows. docs/telemetry.md's row says all of this; the privacy page
already covers "a publish that didn't go through".

BuildBridge, from the review's optional items:
- A fix's pending request is dropped when the path settles on any tab
  but Build, so an abandoned Add a slot can't add an empty slot on a
  later visit.
- A departed Build tab's save result stops counting once a new Build
  tab registers; the next Publish still waits for those saves.

The CHANGELOG now says the warning names each slot's first such word
(once each), which is what words holds, and that "Add a slot before
publishing." still shows if the last slot goes in the moment between
the check and the publish.

Tests: a row per check kind with its payload, none after Publish
anyway, none for a viewer, and the service's own row for a publish the
check didn't see; the bridge drops a request when the organizer goes
elsewhere, and drops a departed tab's failure once a Build tab shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
No conflicts. docs/telemetry.md keeps both changes: PR 2's
signup.link_shared lines and this branch's signup.publish_refused row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Measured against PR 3 at 6ce99a6, after merging main's #525 and #526:
every signup tab's first load grows by 1,362 bytes gzip and Build by
2,216 (the publish button and its check panel, the bridge to the
Build tab, and on Build the flush and the slot opening). That is 51
bytes more than last round, for the bridge's path watch and the
dropped result of a departed tab's saves, and #525's base moved too,
so four pages are just over what this branch had set:
- /app/signups/[id] and edit: 128.06 kB, 128 -> 129 (PR 3: 127)
- settings: 129.02 kB, 129 -> 130 (PR 3: 128)
- responses: 129.48 kB, stays 130 (PR 3: 129)
- build: 157.17 kB, 157 -> 158 (PR 3: 155)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H

Copy link
Copy Markdown
Owner Author

Tophat: passed

I ran 3952591 locally with pnpm dev, Postgres 16, and Chromium at 1280×900 and 390×844. An independent agent that didn't write the change did the run.

The head has moved on since. It now also has the catch-up merges with #578 and main, the telemetry for the check's outcomes, and the bridge's expiry (2fd9d88..9946d9d). That round is getting its own independent running-app check, which I'll post below when it's done.

Check Result What I saw
Empty slot, computer ✅ A popover by the button, "Nothing to sign up for yet" / "The slot is empty. Say what people sign up for, then publish.", named by its title and described by its text. The button shows aria-expanded=true. Focus starts on Fill it in. Escape and Not now return focus to Publish and share, and pressing it again closes the popover without publishing. Fill it in opens the slot with focus in "What value". The signup stays a draft.
Empty slot, phone ✅ A sheet over the bar with Fill it in first. Both buttons are 358×48. Escape, Not now and ✕ return focus to the bar's button. The field opens on screen, above the bar.
No slots, no fields, and fixes from another tab ✅ On Settings, a draft with no slots: Add a slot lands on Build's own address with a new slot open, after a single POST. Three rounds of Back and Forward, and a reload, wrote nothing and reopened nothing. Every slot empty, from Settings: Fill it in opens the first slot. No fields: only Not now, which takes focus.
TBD warning, computer ✅ One slot: "Fruit and water · Sat, Apr 6 still says “TBA”…". Name it first puts focus in the field that holds the TBA, not in the first field. Several slots: "3 slots still say “TBA”, “TBD” or “TBC”." "Tbacco" and "ÉTBA" are ignored. Publish anyway publishes, opens the share panel and focuses Copy link.
TBD warning, phone, from Responses ✅ Name it first (focused) sits above Publish anyway, both 48px. Name it first lands on /build with the slot open and "Cashier TBD" focused. Back and Forward reopen nothing and write nothing. On the 14th slot of a list, the field scrolls into view above the bar.
Publish sends waiting edits first ✅ Type, then press Publish at once: the slot PATCH goes out before the server action, and the signup publishes with the new value. With the PATCH slowed by 3 s, the button reads "Publishing…", focus stays on it, and the panel opens at 4.2 s. Type "Juice TBD", switch to Responses, then press Publish: it waits for the save, then warns about "TBD".
10 s cap ✅ With the PATCH held for 16 s, the button still reads "Publishing…" at 8.5 s. The panel opens at 10.4 s, checking what's stored.
A refused save ✅ A real 404, for a slot deleted through the API while its editor was open. Publish stops with "A change didn’t save. Fix it, then publish." No panel opens, and the signup stays a draft.
No longer a draft, and can't be published past ✅ Published through the API, then Publish pressed: "This signup changed since the page loaded. Reload to see the latest." Emptying the slot through the API while the TBD warning is open, then pressing Publish anyway, switches the panel to "Nothing to sign up for yet", and the signup stays a draft.
Screen reader ✅ The live region says nothing when a check opens, and "Published. Anyone with the link can sign up now." after a real publish.
Help article ✅ The new paragraphs match the app's wording.
Console and server log ✅ No console errors.

What changes for organizers: Publish and share first saves anything still being typed, then checks the draft:

  • Nothing to sign up for: a panel by the button says so and won't publish. Its button opens the slot to fill in, or adds one.
  • A slot still says TBD/TBC/TBA: the panel names it and offers Name it first or Publish anyway.
  • A refused save: it says so instead of publishing.

Looked off, but not a failure:

  • If a save takes longer than 10 s, the check can say a slot is empty that has just been typed into. That's the cap, working as designed.
  • After a refused save, the builder shows the stale slot. That's older than this PR.

Screenshots are in the session linked below.

https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H


Generated by Claude Code

Review finding (blocking). A fix pressed on another tab leaves its
slot for the Build tab to open as it mounts, and that request was
dropped only when the path settled on a page that isn't Build. When
the push to Build was overtaken by a navigation to the same page (the
tab they were on, chosen again, or Settings' Save coming back to
Settings), the path never changed, the request stayed, and a later
visit to Build added an empty slot.

The bridge now has dropPending(), and the request is dropped, besides
the path watch, when the organizer chooses any tab but Build (TabsNav,
the current tab included), goes Back or Forward (popstate), or sends a
form (a submit anywhere, which covers Settings' Save). The doc comment
lists exactly these. Tests: a click on the current tab, another tab,
Back and a form submit each drop it; a plain re-render on the same page
and a click on Build keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review finding (blocking). docs/telemetry.md said a warning and a
Publish anyway were told apart by whether signup.published followed,
but Name it first, then Publish and share again, gives the same two
rows, and the Publish anyway path recorded nothing about the choice.

publishSignup takes an optional { acknowledged: 'placeholder' }, which
goes into the signup.published row it already writes. The publish
action passes it only when the form said Publish anyway and the check
it ran still found a placeholder; the MCP tool and the REST API call
publishSignup without it. docs/telemetry.md now says: after the
warning row, a signup.published with acknowledged: 'placeholder' means
Publish anyway, and one without it means the slots were fixed first;
real refusals are the signup.publish_refused rows whose payload has
code. Nothing personal is stored, so the privacy page is unchanged.

DB tests: Publish anyway writes the flag; naming the slot first and
publishing writes none; nor does a Publish anyway that reaches the
server after the slot was named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review finding (optional). recordPublishCheck read the signup outside
the best-effort write, so a database error there threw out of the
publish action instead of showing the check, though docs/telemetry.md
says a failed telemetry write never fails the request. The read is
caught now: it logs a warning and records nothing. Test: with a
database that won't answer, it resolves ok rather than throwing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review finding (optional): the test helper read actor_id and dropped
it. Each check kind's row is now asserted to be the organizer's who
pressed Publish and share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Conflicts, both sides kept:
- useBuildState: addRow still resolves to the new slot's id (for the
  check's Add a slot), now with #568's capacity of 1 and no seed
  capacity; #568's duplicateRow (the copy placed after the original,
  resolving to its id) saves through withSave, so the flush tracks it.
  A hook test now has the flush wait for a duplicate in flight, as when
  Duplicate is pressed just before Publish.
- BuildWysiwyg: #568's focusRowId for a fresh duplicate's editor and
  this branch's focus request for the check's fix, side by side; the
  fix opens its slot through #568's expandRow, so a stale duplicate
  focus can't win.
- BuildWysiwyg.test: #568's duplicate tests and this branch's bridge
  tests.
- The publish walkthrough: #568's steps for the copy (opening below the
  original, its date's own save waited for before the reload check).
- budgets.json: PR 3's numbers, re-measured in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Measured against PR 3 at 31f97ec (which carries main's #568): every
signup tab's first load grows by 1,472 bytes gzip and Build by 2,446.
That is the Publish and share button and its check panel, and the
bridge to the Build tab, now also dropped by TabsNav, Back and Forward
and a form submit; on Build, also the flush and the slot opening. PR 3
moved [id], edit and build up with #568, so those three go over:
- /app/signups/[id] and edit: 126.70 -> 128.17 kB, budget 127 -> 129
- build: 155.28 -> 157.73 kB, budget 156 -> 158
Responses (129.59 kB) and settings (129.13 kB) stay at the 130 this
branch already set (PR 3: 129 and 128).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
The #503 entry still gave round 3's rule, that a Publish anyway after
the warning writes nothing more, which c71d757 made false. It now says
the signup.published after the warning carries acknowledged:
'placeholder' when the organizer chose Publish anyway, and not when the
slots were fixed first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review finding. A Ctrl, Cmd, Shift or middle click on a tab opens it in
a new browser tab, but TabsNav dropped the pending fix all the same, so
this window still went on to Build and the fix silently did nothing.
It now drops the request only on a plain primary click. Tests: a
Ctrl-, Cmd- and Shift-click and a middle click keep it; a plain click
still drops it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review finding. docs/telemetry.md said a signup.published without
acknowledged: 'placeholder' after a warning means the slots were fixed
first, but a publish through POST /api/signups/[id]/publish, or a
connected AI assistant's publish_signup, has no flag either and never
checked. It now says so, and that the assistant's row also carries
viaClientId (the REST route's cookie session doesn't).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H

@richshaw richshaw left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: passed (rounds 1–5)

This PR delivers the narrowed part of #503 within the redesign. Publish and share first sends any edits still being saved, then checks the draft on a fresh read:

  • Nothing to sign up for (no slots, no fields, or every slot empty): it won't publish. Fill it in or Add a slot opens the slot in Build, from any tab.
  • A slot still says TBD, TBC or TBA: it warns, with Name it first or Publish anyway.
  • Nothing wrong: it publishes at once, as before.

The REST and MCP publish paths are unchanged. Rounds 1 to 4 were each an independent agent that didn't write the change: code-review at high effort as a starting list, each item checked by hand, and from round 3 a running-app check on its own dev server at 1280 and 390. Round 5 is three small commits (29703de, 5dcfaac, 9c4281e) that fix round 4's findings. I read those myself, and a final independent pass over the whole stack will look at them again.

Blocking (0). Earlier rounds had four, all fixed:

  • Round 1: the fix's open-slot request lived in the address (?slot=new), so Back, Forward or a bookmark replayed it and added another slot. It now sits in the layout's BuildBridge, and the Build tab takes it once. e2e checks Back and Forward.
  • Round 3: a pending Add a slot survived when the organizer stayed on the tab they pressed it on (clicked it again, or Settings' Save came back to the same path), so a later Build visit added an empty slot. The bridge now drops the request on a plain click on any tab but Build, on Back and Forward, and on any form submit. Round 4 re-ran the exact scenario with Build's load delayed 5 s, at both sizes: no slot.
  • Round 3: docs/telemetry.md said a warning and a Publish anyway could be told apart by whether a signup.published follows, but fixing first then publishing gives the same rows. Publish anyway now writes acknowledged: 'placeholder' on the signup.published row, and the doc says so. Round 4 checked the rows for Publish anyway, a fix then publish, and a slot named in another tab, and that REST and MCP can't set the flag.
  • Round 4: the CHANGELOG still gave round 3's old rule. Fixed in 29703de.

Fixed from optional findings (each marked with the round that found it):

  • Flush gaps (round 1): a save sent as a Build tab unmounts, saves a departed tab left in flight, and a failed save are all waited for and counted, and a failed save stops Publish with "A change didn’t save. Fix it, then publish." A 10 s cap keeps a stuck save from holding Publish.
  • A pending fix never expired (round 2): it's dropped when the path settles anywhere but Build, and on the triggers above.
  • Check outcomes recorded (round 3): the check stops before the service, where #525 records refused publishes, so it writes the same signup.publish_refused row itself (recordPublishCheck, outside any transaction, after the write guard). A read failure there is caught and logged, so it never fails the publish. Tests assert the actor on each row. The doc says to count real refusals as rows whose payload has code, since warnings are rows too.
  • A Ctrl/Cmd or middle click on a tab (round 4) dropped the fix while this window still went to Build. Only a plain primary click drops it now (5dcfaac, tested).
  • Wording: "each slot's first such word", the "Add a slot before publishing." CHANGELOG line, the bridge's comments, and the doc's note that an API publish carries no flag either.

Optional, left as is (3):

  • A Duplicate whose save takes longer than Publish's 10 s wait can take focus from a fix's slot when it lands (round 4, reproduced with the save delayed 12 s). The root is #568's code, which opens the copy whenever its save returns, whatever was opened since. main has the same steal when another slot is opened meanwhile. One follow-up should fix it there.
  • A departed Build tab's failed save is dropped once a new Build tab registers. The new tab shows the stored values, so nothing wrong is shown, but the failure isn't mentioned again.
  • A save slower than 10 s can leave the check looking at the stored values, so it may say a slot is empty that was just typed into. That's the cap working as designed.

The merges: PR 3's catch-ups with main (#525, #526, then #568) are merged in. Round 4 checked the #568 merge: a duplicate made just before Publish is waited for, and the check sees it. Duplicate still opens the copy right after the original with focus in What. A fix's focus wins over a duplicate's that saved within the wait.

Earlier threads: none.

Checks:

  • CI is green on this SHA (9c4281e), and was on e8150c0.
  • The tophat passed on 3952591 (comment above). Rounds 3 and 4 re-ran its flows on later heads, at 1280 and 390, with no console errors.
  • Run locally on e8150c0: pnpm lint && pnpm typecheck && pnpm knip && pnpm test (1595), pnpm test:db (562), pnpm build && pnpm budgets, and the full chromium e2e suite with CI=1 (72). On 9c4281e: lint, typecheck, knip and unit tests (1599), pnpm build && pnpm budgets, and the chromium e2e for organizer.spec.ts and the publish walkthrough (20).

gate=owner: this is UX. It adds a check panel to Publish and share, opens slots from other tabs, and records each check's outcome.

https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H


Generated by Claude Code

Only a plain primary click on a tab other than Build drops the fix on its
way to Build; a Ctrl, Cmd, Shift or Alt click keeps it, and a middle click
never reaches onClick. The comments in TabsNav and BuildBridge now say so.
The test for a middle click fired a click event browsers never send; it
now fires auxclick, and an Alt-click case joins the modified clicks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Drafts lost their copyable link, so the signup.link_copied row no longer
says what a draft's link shows; only rows written before then can carry
draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H

Copy link
Copy Markdown
Owner Author

Final independent check: passed. An agent that didn't write the change checked round 5's three commits, which the review above says I had only read myself:

  • 29703de: the CHANGELOG's acknowledged: 'placeholder' sentence matches the action. The flag is passed only when the check found a placeholder and the form said Publish anyway.
  • 5dcfaac: the TabsNav guard is right in Chromium:
    • Enter on a focused tab sends a plain click, so a pending fix is dropped.
    • Ctrl+Enter and Shift+Enter, and Ctrl-, Shift- and Alt-clicks, carry their modifier, so the fix is kept.
    • A middle click sends only auxclick, so onClick never runs.
  • 9c4281e: the telemetry wording is right. The REST publish uses the cookie session, so its row has no viaClientId, and the AI assistant's tool adds one.

It found three small things, now fixed:

  • 9c44144: comments in TabsNav and on BuildBridge still said any tab but Build drops the fix. They now say only a plain primary click does. The middle-click test case fired a click that browsers never send for a middle click; it now fires auxclick, and there's an Alt-click case.
  • 62db8e0: docs/telemetry.md's signup.link_copied row said "a draft's link only says the signup isn't ready". Since One Publish and share button, in a bar on phones; drafts show no public link #578 a draft shows no link to copy, so the row now says only older rows can carry draft.

I read both, and on 62db8e0 lint, typecheck, knip and unit tests pass (1600, with 18 in BuildBridge.test.tsx). CI is green on 62db8e0.

The smoke test of the whole stack at the top (#581), at 1280 and 390, covered this PR's part:

  • An empty slot opens "Nothing to sign up for yet" with focus on Fill it in, and writes an all_blank row.
  • A TBD slot warns, and Name it first puts focus in its field and writes the placeholder row.
  • Publishing after the fix writes signup.published { from: 'draft', to: 'open' }, with no flag.
  • No console errors.

Left as is, as the review says: a Duplicate still saving after a fix can take focus when it lands. The root is #568's code on main, and it wants a follow-up there.

Nothing else is left to do here; it's waiting on review.


Generated by Claude Code

@richshaw
richshaw added this pull request to stack #582 October 24, 2026 15:33
Brings in PR 2's share message by way of PR 3: spots open rather than a
count of slots, and "Sign up here, no account needed:" as its last line,
with the help article, its walkthrough, share-panel.png and the #505
CHANGELOG entry to match. It merged without conflicts: PR 4's check
steps and note in the article and walkthrough sit beside the new message
sentence and regex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings PR 2's 4a490fb through 487b2db: the share message's rules as
their own sentences in the #505 entry, and one repeated test gone.
Merged without conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
@richshaw

Copy link
Copy Markdown
Owner Author

Tophat: two things in the publish check

I tophatted the stack locally and ran a UX walkthrough of Publish and share, on a computer and a phone. Both of these are on ad9fa96.

1. The spinner flashes before "Nothing to sign up for yet" opens

On a draft with nothing in it, pressing Publish and share turns the button into its spinner and "Publishing…", and almost at once the "Nothing to sign up for yet" panel opens. The spinner shows for a split second, so it looks like a glitch.

Why: in PublishButton.tsx, busy (saving || pending) shows <Spinner /> and "Publishing…" the moment the button is pressed. With nothing waiting to save, flushPendingSaves() resolves at once and the check comes back quickly, so busy is true only for a moment.

Suggested: show the spinner only when the answer is slow. Wait about 300 ms before showing it, and once it shows, keep it for at least about 400 ms so it never flickers. Nothing moves when the answer is instant, and a slow publish still gets a spinner.

Also, "Publishing…" isn't right when the answer is a check and nothing gets published. The delayed spinner hides that in the fast case. If a check can be slow, the label could fit both outcomes.

2. When the TBD is in the slot's name, the warning names the slot only by its date

A signup with the rows "Cashier TBD · Sat, Nov 14" and "Grill · Sat, Nov 14". Publish and share says:

Sat, Nov 14 still says “TBD”. People will see that, and a published signup can’t go back to being a draft.

It reads as if the date is TBD. Both rows have that date, so it doesn't say which one. (Name it first does open the right row, and the row's TBD tag marks it.)

Why: checkBeforePublish in src/lib/publish-check.ts names the slot by its other values (slotLabel), so the panel can quote the word itself. That works for the PR's example, "Fruit and water · Sat, Apr 6 still says “TBA”", where the TBA is a field's whole value. It doesn't work when the word is part of a longer value, such as the slot's name.

Suggested: leave a field out of slotLabel only when its whole value is the placeholder word. Then this case reads "Cashier TBD · Sat, Nov 14 still says “TBD”", and the PR's example doesn't change. Minor.


Generated by Claude Code

Pressing Publish and share on a signup with nothing to save showed the
spinner and "Publishing…" for the moment the check took, then the check's
panel. The button now looks busy only once it has been for 300 ms, and
then for 400 ms at least, through a small useDelayedFlag hook, so an answer
that comes at once flashes nothing and one just slower doesn't flicker. It
is still busy at once, so a second press does nothing, and aria-busy and
aria-disabled still follow that. It says "Checking…", which is true
whether the press ends in the check or a publish, since a publish checks
first. Both buttons, the header's and the phone bar's, read the one flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
… into the publish check

Brings in, by way of PR 3: the close question's "Everyone already signed
up keeps their spot." once someone has signed up; main's #569 cut of an
over-long Magic Compose title or description; and PR 2's second owner
round (the share message's date and time line, Copy message's widths,
Email it's body without the title, one message label, the growing message
box, the title under the QR code in its picture, the phone's large QR
code with Save image, the new QR alt, and qr_code once per opening).

docs/telemetry.md conflicted on the publish, copy and share rows: the
publish_refused and link_copied rows keep this branch's words (the check's
reasons, and that a draft has no link to copy), and link_shared takes
PR 2's qr_code wording. CHANGELOG, the help article, its UI list and the
walkthrough merged on their own; each fact is in them once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Since the last change, a value was left out of the warning's slot name
only when, trimmed, it was the word, so "(TBD)", "TBD." or "TBD?" stayed
in: "Fruit and water · Sat, Apr 6 · (TBD) still says “TBD”." The value
is now compared with its punctuation turned to spaces, so those read
"Fruit and water · Sat, Apr 6 still says “TBD”." again, and "Cashier
TBD" still names the slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
With the answer at about 400 ms, the check's panel was open while the
button kept its spinner until the 400 ms minimum ran out, and by then
aria-busy was already false, so it looked busy while it worked. The busy
look now ends in the same render as the press's answer shows: the check's
panel, a refusal, or the line saying a change didn't save. PublishState
keeps the result Publish had when pressed, and a different one (or that
line) is this press's answer; it stays an answer after the panel is
closed, so the spinner doesn't come back. The minimum is also let go then,
so a press soon after waits its 300 ms again. It still holds 400 ms when
the press changes nothing else on the page, so the button never flickers.

The check's panel used to open from an effect, a render after its answer
came, which with the spinner gone at once left a frame or two of the
plain button first. It now opens in the same render: PublishState keeps
the answer whose panel was closed, in place of an open flag set by an
effect, and a check that isn't that one is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
The button went from about 160 to 120 px wide while it said Checking…,
moving Preview 39 px and back. Both labels, each with its icon, now sit
in one grid cell, the one not shown invisible and aria-hidden, so the
button is as wide as the wider label in any language and its name is the
label shown. Each icon stays beside its own words, centred with them, so
the spinner doesn't sit apart from a shorter "Checking…". Both buttons,
the header's and the phone bar's, do it. The header's test reads each
button's name as a screen reader would, leaving out the hidden label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
…o the publish check

Brings in, by way of PR 3: PR 2's fixes from the independent check (the
QR picture's right-to-left ellipsis and whole characters, Save image
taking no second tap, the share message's days and time from the open
slots, the message box sized as the browser measures it, and the help's
"Most phones then open their share menu…"), and PR 1's KEEPS_THEIR_SPOT,
shared by the closed banner and the close question.

layout.tsx conflicted where this branch wraps the page in BuildBridge:
it keeps that wrapping, with PR 1's import of KEEPS_THEIR_SPOT and the
banner's body built from it. CHANGELOG and the help article merged on
their own; each entry is there once, as its branch has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
A value was left out of the warning's slot name only when, less its
punctuation, it was one placeholder word, so "TBD/TBC" or "TBD TBD"
stayed in: "Fruit and water · Sat, Apr 6 · TBD/TBC still says “TBD”."
Now a value counts as naming nothing when every word in it is TBD, TBC or
TBA, in any case, so those read "Fruit and water · Sat, Apr 6 still says
“TBD”.", and "Cashier TBD" still names the slot. Words split where the
check's own match sees them end, so "TBD_TBC", which it doesn't count as
a placeholder, stays in the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings PR 2's ff75578 and 7a019bb through 21eb67c: a QR picture's title
read in one direction from its first letter, and a full slot left out
of the share message's days and time. Merged without conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H

Copy link
Copy Markdown
Owner Author

Both are done.

1. The spinner flash

In 666ffd0, then 233333f and 4f0c7a0 from the checks.

When the spinner shows:

  • After about 300 ms, through a small useDelayedFlag hook. Once shown, it stays at least 400 ms, unless an answer shows first: the spinner goes the moment the check's panel, the share panel or a refusal appears, so it never sits beside an open panel.
  • The label is "Checking…" while it shows, true whether it then publishes or stops at a check.
  • A second press is still refused at once (aria-busy and aria-disabled as before), even before the spinner shows.

Keeping the header still: both labels sit in one grid cell, so the button keeps its width and Preview doesn't move. Its accessible name is always the visible label.

Checked: an independent agent timed it frame by frame at 1280 and 390:

  • An empty draft opened its check in about 120–250 ms, with no spinner.
  • With the answer slowed to 400 ms, "Checking…" showed from about 300 ms until the panel's frame.
  • The button stayed 159.9px wide in every frame, 358px in the phone bar.
  • No frame showed both the spinner and the panel.
  • After Escape, Not now, a refused save, or three quick presses (which sent one request), it neither stuck nor came back.

2. The TBD in the slot's name

In 6962a50, then 5d619eb and b3316f9 from the checks.

The rule: a field is left out of the label only when its whole value is the placeholder word, give or take punctuation, or only such words.

  • "Cashier TBD · Sat, Nov 14" now reads "Cashier TBD · Sat, Nov 14 still says “TBD”."
  • "(TBD)", "TBD.", "TBD/TBC" and " tbd " are still left out, so "Fruit and water · Sat, Apr 6 still says “TBA”" doesn't change.
  • The tests cover each.

Checks:

  • Lint, typecheck, knip and unit tests pass (1675 on f95e870).
  • actions.db.test.ts and the e2e for organizer.spec.ts and the publish walkthrough pass.
  • Budgets: each signup tab grows about 220 bytes (the delayed flag, the answer tracking and the stacked label). Build is 157.98 of 158 kB, with no budget raised.
  • CI is green on 84f146e, and running on the head.

Generated by Claude Code

Brings PR 2's d33a05a, 4e0e3fd and 1c178c4 through 64f5f93 (on a
phone, QR code looks pressed and says Hide QR code while the code
shows; the share sheet's button rows are two-column grids), and main's
#614, #613 and #617 (next 15.5.28). Merged without conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Carries main's ee0f0d5 (vite 7.3.5 to 7.3.7) and 740e98d (nodemailer
10.0.9 to 10.1.4) up the stack. Merged without conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings PR 3's merge of main's 24f8689 and bcb82b3, the squashed #571
and #574. Their content was already on this branch, so the tree doesn't
change; this moves the branch's merge base with main up to bcb82b3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
@richshaw
richshaw enabled stacked auto-merge (squash) October 25, 2026 18:35
stacked auto-merge was automatically disabled October 25, 2026 18:35

Replaced by a newer auto-merge request

@richshaw
richshaw enabled stacked auto-merge (squash) October 25, 2026 18:35
@richshaw
richshaw merged commit 8784f52 into main Oct 25, 2026
5 checks passed
@richshaw
richshaw deleted the claude/relaxed-dijkstra-cpx7zo-4-publish-check branch October 25, 2026 18:38
richshaw pushed a commit that referenced this pull request Oct 25, 2026
…he 5-prompt branch

Through the server, telemetry and screen branches. rate-limit.ts keeps
this branch's 12 drafts an hour and main's reworded link-beacon
comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW9ZpEu4MWkeqTv2Qe14ks
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.

2 participants