Skip to content

One Publish and share button, in a bar on phones; drafts show no public link - #578

Merged
richshaw merged 87 commits into
mainfrom
claude/relaxed-dijkstra-cpx7zo-3-publish-button
Oct 25, 2026
Merged

richshaw merged 87 commits into
mainfrom
claude/relaxed-dijkstra-cpx7zo-3-publish-button

Conversation

@richshaw

@richshaw richshaw commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Closes #499. This is PR 3 of 5 for the "Publish and share" redesign in #498 (row I), stacked on #574 (the share panel), which is stacked on #571. Review it against claude/relaxed-dijkstra-cpx7zo-2-share-panel.

The problem

  • Publish was easy to miss. On a computer it was a small text-xs button, the same size as Preview. On a phone it sat inside the ⋯ sheet.
  • A draft showed a public link. Anyone who opened it saw "This signup isn't ready yet".
  • Nothing showed whether the link was working once it had been sent.

The fix

One primary button, Publish and share

  • On a computer it sits top right. The header's buttons are now 36px tall with 13px text; Publish and share is the blue primary.
  • On a phone it fills a bar fixed to the bottom of the screen, 48px tall, while the signup is a draft or open.
    • Open signups show the count and Share in the bar instead. After Publish, a share panel to send the link #574's temporary Share under the link moved here.
    • Closed and archived signups have no bar.
    • The bar sits under the sheets' z-50 overlays.
    • It measures itself (--phone-bar-h), so the page always leaves room for it, including when a refusal makes it taller. Until it has measured, it falls back to 4.5rem + 1px plus the safe-area inset. scroll-padding-bottom keeps a focused control above it (WCAG 2.2, 2.4.11).
  • Pressing it publishes and opens After Publish, a share panel to send the link #574's share panel, staying on the tab. The same spot then says Share.
  • On a phone, a refused publish shows its reason just above the button in the bar.

A draft shows no public link. It says "Only you can see this draft until you publish it." instead, on both sizes.

The count, by the link

  • "0 of 21 spots taken", or "3 spots taken" when a slot has no limit.
  • "Closed · 3 of 21 spots taken" and "Archived · …" for those states.
  • On a phone, an open signup's count is in the bar and a closed one's is under the link.
  • The words come from spotsTakenLine in src/lib/spot-counts.ts, beside Ask before closing a signup, and move Close signup into the ⋯ menu #571's question.

The phone's ⋯ sheet

  • It no longer holds Publish signup.
  • It now holds Preview, which opens in a new tab and closes the sheet, then Close signup when the signup is open.
  • A closed or archived signup still has no ⋯.

The Responses tab of a signup nobody has signed up to now says something true, and no longer uses "signups" for what people sign up for (docs/writing-help.md):

  • Draft: "Nobody has signed up yet. People can sign up once you publish and share it."
  • Open: "Nobody has signed up yet. Share the public link to start collecting."
  • Closed: "Nobody signed up." The closed banner already says why.
  • Archived: "Nobody signed up. People can’t sign up now."

For the budget, the draft line, the phone's link and closed count, and the sheet's Preview are rendered on the server (HeaderParts.tsx) and passed to the phone header as nodes.

From #499's outline:

Choices this makes

  • On a phone, an open signup's count shows only in the bar, beside Share, as in the mock-up. It isn't repeated under the link.
  • No viewport-fit=cover. env(safe-area-inset-bottom) stays 0 on iPhones without it, and the bar still sits above Safari's toolbar.
  • The CHANGELOG reads as the stack leaves things. Ask before closing a signup, and move Close signup into the ⋯ menu #571's and After Publish, a share panel to send the link #574's lines and the A viewer who chooses Publish, Close or Delete gets the error page #390 line now name Publish and share and say where its refusal shows. They no longer describe the intermediate states.
  • Left as is, for follow-ups:
    • The header's count goes stale while the organizer edits on Build. Build saves don't re-render the layout. The tab counts and the share message already go stale the same way, so they want one fix together.
    • The draft line differs from docs/connect-ai-assistant.md. The line ("Only you can see this draft…") is the mock-up's wording. The doc says other members of the same workspace can see drafts. There's no way to invite anyone today.
    • Not checked on iOS Safari: whether WebKit uses the root scroll-padding-bottom when it scrolls a focused control into view.

Tests

  • Unit:
    • spot-counts.test.ts: spotsTakenLine, covering no slots, unlimited slots, one spot, over-full, closed and archived.
    • responses-summary: noResponsesLine by status, and a test that keeps "signup" out of all four lines.
  • Component:
    • SignupHeader.test.tsx: the buttons, draft line and chip for each status.
    • MobileSignupHeader.test.tsx: the bar for each status; the ⋯ sheet's contents for each status; publishing from the bar opens the share sheet; Escape returns focus to the bar's Share; a refusal shows above the button; the bar reports its height and clears it when there's no bar.
  • e2e:
    • The publish help walkthrough now checks:
      • the draft line, and that there's no link;
      • Preview from ⋯ on a phone, opening a new tab;
      • the bar's position and size;
      • focus on the bar's Share, beside "0 of 1 spot taken".
    • organizer.spec.ts and the run skill's sample now press Publish and share.
  • Brought up to date with main and After Publish, a share panel to send the link #574:
  • Passed on 31f97ec: pnpm lint && pnpm typecheck && pnpm knip && pnpm test (1506), pnpm build && pnpm budgets, and the full chromium e2e suite with CI=1 (65). pnpm test:db wasn't run: no actions, services or schema changed. HELP_SCREENSHOTS=1 ran on b45de83.
  • The independent tophat and code review are below, with an independent re-check of the fix round in the comments.

Budgets

Every /app/signups/[id] tab's first-load JavaScript grows by 0.33 kB gzip against #574. That covers the phone's bar at the bottom (Publish and share, or the count and Share), the bar measuring its own height, and the phone header's new props. Rendering the static parts on the server keeps another 0.2 kB out of the first load.

Two budgets are raised, each for code that reached this branch through its base, not for this PR's own growth:

Page #574 (cbfc37e) This PR (31f97ec) Budget
/app/signups/[id]/build 154.97 kB 155.29 kB 155 → 156
/app/signups/[id]/responses 127.8 kB 128.1 kB 128 → 129
/app/signups/[id]/settings 127.3 kB 127.7 kB 128
/app/signups/[id]/edit, /app/signups/[id] 126.4 kB 126.7 kB 127
/app/signups/[id]/preview 131.7 kB 131.7 kB 132

Docs

  • Help. create-and-publish-a-signup now covers:

    • the draft line, and that there's no link to send yet;
    • Preview, which on a phone is under the three dots;
    • Publish and share: at the top on a computer, at the bottom of the screen on a phone;
    • Share, in the same places;
    • the count next to the link, or beside Share on a phone.

    The "isn't ready yet" paragraph and publishOnPhone are gone, and published.png was re-taken with the count (605×110). docs/writing-help.md's phone example named Publish signup and now names the bar.

  • CHANGELOG: under Changed, with the earlier stack lines corrected as above.

  • Legal: no change. Nothing stored, sent or set as a cookie changes.

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, after Publish and from a Share button, with copies and shares recorded (After publishing, help the organizer share the link #505).
  3. This PR: 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. Check the page when Publish and share is pressed #580: the check when Publish and share is pressed (the narrowed part of Find fixable problems in a draft and offer one-tap fixes #503).
  5. 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

claude added 29 commits October 24, 2026 01:23
Closes #457. An open signup's header put a one-click Close signup where
Publish had just been, and closing can't be undone.

- Desktop: Preview and a ⋯ (More actions) button, outlined like Preview.
  Its panel holds Close signup, which swaps the panel to a question:
  where the signup stands ("Nobody has signed up yet.", "18 of 21 spots
  are still open.", "3 spots are taken."), then "People can't sign up
  after this, and you can't reopen it." Not yet has focus and hands it
  back to ⋯; the red Close signup submits through StatusActionButtonForm,
  so closeAction and statusRefusal are unchanged. A refusal shows in the
  panel, which stays open while the close is in flight.
- Phone: the More actions sheet asks the same question, with 44px+
  buttons, and closes once the signup has closed instead of staying
  open and empty.
- After closing, one sr-only role="status" region says "Closed. People
  can't sign up now." and focus moves to the signup's title, since the
  ⋯ that had it is gone.
- New src/components/ui/Popover.tsx: a small non-modal anchored dialog
  (Escape, outside mousedown and focus leaving close it; focus returns
  to the trigger on Escape or a close from inside). useOutsideClose
  moves to src/hooks and gains an opt-in focus-leaving close; the
  builder's pickers keep their behaviour.
- The layout loads loadCommittedBySlotForOrganizer (cached; the Build
  tab already calls it) and passes the header spotCounts(), a pure
  helper in src/lib/spot-counts.ts beside closeSignupQuestion().
- The Settings e2e test scopes its "Saved" status to the form, since
  the header now has a live region of its own.

Budgets: every signup tab's first-load JS grows by 1.3-1.4 kB gzip
(the ⋯ menu, the popover, the question and the live region, all in
the shared layout chunk), so the five [id] budgets go up by 1 kB each:
build 151.1 -> 152.5 (152 -> 153), responses 124.5 -> 125.9
(125 -> 126), settings 124.1 -> 125.4 (125 -> 126), edit and [id]
123.1 -> 124.4 (124 -> 125).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
…is page's close

Review fixes for the Close signup change.

- A closed signup shows the existing Banner on every tab: "Signup
  closed" / "People can’t sign up now. Everyone already signed up keeps
  their spot." It is not a live region (Banner gains `live`, default
  true), so a screen reader hears the header's "Closed. People can’t
  sign up now." once rather than both.
- Opening ⋯ (or the phone sheet) on an open signup calls
  router.refresh(), so the question counts sign-ups made since the page
  loaded. Build's client state keeps its edits through the refresh.
- spotCounts gains `open`: each open slot's free spots, never below
  none, null when an open slot has no limit. A closed slot's free spots
  no longer count as open, and an over-full slot no longer cancels free
  spots elsewhere. The question goes by it: "4 of 16 spots are still
  open.", "No spots are still open." when none are open but not every
  spot is taken. `taken` and `total` are unchanged.
- The announcement and the focus move happen only when this page's
  Close signup closed the signup: useAnnouncedClose wraps closeAction
  and marks it on success, and HeaderAnnouncer, now holding both
  headers, reads the mark on the next status change. A signup closed by
  another tab or an AI assistant shows the banner without either.
- The desktop menu runs Close signup in its own scope, and the question
  shows a refusal only for a Close signup pressed in that asking, so an
  earlier refusal doesn't come back.
- Not yet and the red Close signup are the same height (both bordered).
- The phone ⋯ has aria-haspopup="dialog" and aria-expanded.
- The layout skips the spots-taken read for drafts.
- Tests: the held-open guard (Escape and an outside click wait while the
  close is in flight), a clean second asking, the announcer's gating,
  open spots with a closed or over-full slot, and e2e checks for the
  banner, fresh counts and a signup closed elsewhere.

Budgets: /app/signups/[id]/responses goes from 125.9 to 126.1 kB, over
its 126 kB by 0.1 kB, so it rises to 127. The banner is rendered on the
server and adds no JavaScript; the 0.2 kB is router.refresh() in both
headers, the close-from-here context and the per-asking refusal state.
Build is 152.9 of 153 kB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
router.refresh() on every ⋯ open rendered the whole route again, which
re-ran the page's reads and its after() telemetry, so each opening wrote
another signup.viewed (Build) or signup.editor_opened row.

- closeQuestionAction(signupId) in the signup actions reads the slots
  and spots taken through getSignupForOrganizer (policy guard included)
  and returns the question, or a refusal through refusalAsResult. It
  revalidates nothing.
- useCloseQuestion asks it when ⋯ opens, on a computer and a phone; the
  page's question shows until the answer comes, and stays if none does.
- Wording: "No spots are open." when no spot is open, and "Every spot is
  taken." only when every slot is open and full (SpotCounts gains
  allSlotsOpen). An over-full slot beside a closed slot with free spots
  no longer reads as every spot taken.
- Tests: db tests for the action (fresh counts, a refusal comes back as
  one, nothing revalidated), the menu asking again as it opens, and the
  new wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
A pure builder for the message the share panel offers to send with the
link: the title, then the dates from the reminder anchor (one day, or the
earliest to the latest), the number of slots and, when every slot has a
limit, the spots, then "Sign up here: <link>". Each part is left out when
it doesn't apply, and the middle line too when nothing is left.
`withLink` puts the link back on its own line if an edit took it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
lean-qr 2.7.4 (MIT, no dependencies), imported from `lean-qr/nano` only
when the code mounts, so it stays out of every page's first load. The
preview is an inline SVG; the PNG for a flyer is drawn on a canvas only
when Download is chosen, and nothing is sent anywhere. If the encoder
can't load, or the browser can't draw the PNG, the QR code is left out
rather than breaking what's around it.

Ported from the share step in #531, without its telemetry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
`useClipboardCopy` copies, says so for a moment, and resolves to whether
the clipboard took it. The public link chip uses it, with the same 1.4 s
it had, so there is one copy implementation for the chip and the share
panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
What the header will open after Publish and from Share: the link with
Copy link, an editable message with Copy message and Email it (a
mailto: link), and a QR code. On a phone with a share menu, Share… comes
first and stands in for Copy message and Email it. The first time it says
Published and "Now send people the link"; later "Share the link". The
owner keeps the message, so an edit survives the panel closing.

`useMediaQuery` will pick the popover or the sheet once the header uses
it; it is null until the window can be asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
PR 1's spotCounts already adds up a signup's capacities, null when a
slot has no limit, so the message uses it rather than a second loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Part of #505: recording copies and shares waits for #525.

- publishAction returns { published: true } after revalidating the
  layout instead of redirecting to /build?published=1, the way
  closeAction stays put. Publish from Responses or Settings stays on
  that tab, and a reload or a shared address never reopens the panel.
- One StatusActionScope runs Publish for both headers, so it outlives
  the phone's More actions sheet, which closes as the answer lands;
  Close signup keeps PR 1's scopes in the ⋯ panel and the sheet.
  ShareScope, inside it, opens the panel in its first-time form when
  Publish answers that it published, and keeps the message: the
  server's (built in the layout by shareMessage) until the organizer
  edits it, then the edit while the page is open.
- An open signup's header has Share (Share2): on a computer in the
  blue spot right of Preview, before ⋯, opening PR 1's Popover (420px,
  aria-labelledby its heading, aria-haspopup and aria-expanded on the
  button); on a phone a full-width button under the public link,
  opening a BottomSheet titled by the heading, with Published as its
  description the first time. useMediaQuery picks one after mount, so
  the portalled sheet never opens on a computer.
- The panel's body is next/dynamic with ssr: false and renders only
  while open; the QR encoder loads inside it.
- On a phone the More actions sheet closes once the status changes
  (it remembers the status it was opened on), so after Publish the
  share sheet takes its place rather than Close signup showing.
- Escape or the ✕ puts focus back on Share. The Popover no longer
  takes focus from a child that put it inside as it mounted, and
  BottomSheet passes onCloseAutoFocus through.
- The header says "Published. Anyone with the link can sign up now."
  only when this page's Publish answered that it published
  (useAnnouncePublished, which ShareScope calls), as PR 1 does for a
  close made here.
- PublishedBanner and the Banner's published kind are gone;
  useDismissibleQueryFlag stays for AiDraftBanner.
- The organizer e2e and the run skill's sample script wait for the
  panel instead of "Signup published".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
"Create and publish your first signup" now says that Publish opens
"Now send people the link", and what it offers: Copy link, a message
built from the signup that can be changed, Copy message and Email it,
Share… on most phones in place of those two, and a QR code to
download. Share at the top opens it again. The UI list gains those
names in place of "Signup published"; Share…, which the walkthrough's
browser can't show, is checked by a unit test.

The walkthrough publishes, checks the panel (focus on Copy link, the
message's dates, slots and spots, the copies, the mailto: link, the
QR code's download), closes it with Escape before the header picture,
and reopens it from Share. The phone test checks that the More actions
sheet gives way to the share sheet. With HELP_SCREENSHOTS=1 it also
takes a picture of the panel, for the article once it's taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Shared again after people have signed up, the message said every spot
the signup ever had. It now goes by spotCounts' open spots: "21 spots"
before anyone signs up, "18 spots left" after, and nothing about spots
once none are left or an open slot has no limit. A closed slot's spots
don't count. The layout passes the spots taken it already reads (none
for a draft), and the help page says "how many spots are left".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
…hen a copy is refused

Review fixes.

- In the App Router, next/dynamic is React.lazy: a panel chunk that
  fails to load (a weak signal, a tab open across a deploy) threw to an
  error boundary there isn't, so the page broke right after Publish.
  loadSharePanel now resolves to ShareUnavailable instead: the link,
  Copy link, and "Couldn’t load the other ways to share. Reload to try
  again."
- The heading, "Published" and the ✕ are ShareButton's on a computer
  too, as the phone's sheet already had them, so the popover is named
  and can be closed while the body loads. SharePanel loses onClose and
  headingId.
- Pressing Publish starts loading the panel (ShareScope now runs
  Publish's scope and wraps the action), so it's usually in by the
  time the answer lands.
- One live region says the last copy that worked, so a second copy is
  announced too.
- A copy the clipboard refuses (plain HTTP, a denied permission) says
  "Couldn’t copy. Select the link and copy it." (or the message) and
  selects it; on a phone the link's box appears for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review fix. A browser that couldn't draw the PNG kept a Download that
did nothing, and the row showed an inert Download while the encoder
loaded. Now the QR code shows once the encoder is in and the browser
has a canvas to draw on; if the canvas then won't give the picture
back, the click is cancelled and the QR code goes away. The panel goes
on without it in each case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review nits. useMediaQuery made a MediaQueryList on every snapshot; it
now makes one per query. The public link chip copies straight from its
button, without a wrapper that only awaited. Prettier on the files this
change touched, where they were short (SignupHeader keeps PR 1's flat
nesting, and the help article its line breaks, to keep the diff to what
changed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review fixes. The walkthrough read the clipboard straight after a
click, before the copy had landed; it now polls, also for the public
link chip's copy that follows the panel's. The CHANGELOG entry says
where focus goes after an outside click (a computer leaves it where it
landed), that only this page's Publish is announced, and what the
review round added: spots left, refused copies, the QR code only where
it can be drawn, and the fallback when the panel can't load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
The Share button's code counts against every signup tab's first load,
and next/dynamic and the fallback had put about 1.2 kB gzip into it.

- React's lazy and Suspense in place of next/dynamic, which is React's
  lazy plus a runtime of its own (LoadableComponent, BailoutToCSR,
  PreloadChunks): the panel renders only once opened, never on the
  server, so ssr: false bought nothing. loadSharePanel still never
  rejects, so a failed load still shows the fallback.
- ShareUnavailable is the public link chip, with its copy button, and
  "Couldn’t load the other ways to share. Reload to try again.": the
  chip is in the first load already, where a link box, a Copy link
  button and its refusal handling of the fallback's own were not. The
  panel's link box moves back into the panel, its only user.
- The phone header test waits, in its act, for the panel that loads as
  the sheet mounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Every /app/signups/[id] tab shares the header's JavaScript, and Share
adds 1.4-1.5 kB gzip to it (measured against PR 1 at 5da0166):

  build      153.0 -> 154.4 kB (153 -> 155)
  responses  126.1 -> 127.6 kB (127 -> 128)
  settings   125.7 -> 127.1 kB (126 -> 128)
  edit       124.7 -> 126.1 kB (125 -> 127)
  [id]       124.7 -> 126.1 kB (125 -> 127)

What it is: ShareButton with the panel's heading, Published mark and ✕
and its Popover and BottomSheet wiring, plus the Share2 icon (about
1.3 kB alone, before shared compression); ShareScope, which runs
Publish's scope and keeps the panel's state, with useMediaQuery (0.7);
and the loader with its fallback, the public link chip and one line
(0.3). The panel itself (3.8 kB gzip) and the QR encoder (2.0 kB) load
only when the panel first opens or Publish is pressed. React's lazy in
place of next/dynamic, and the chip as the fallback, already took the
first-load cost down from 2.6 kB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
The walkthrough's picture of the panel, taken with the stand-in
address, goes in the article under "Now send people the link". The
walkthrough allows the suffix a slug gets when an earlier run made a
signup with the same title, in the message, the clipboard and the QR
code's file name. published.png came out the same, so it stays.

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

- On a phone, the focused Copy link shrank from the full width to half
  50-200 ms after the sheet opened, when the QR code arrived beside it.
  The QR code now keeps its place while the encoder loads, as an inert
  stand-in hidden from screen readers, and goes away only if the
  encoder can't load or the browser can't draw the PNG. On a computer
  its row is there from the start in the same way.
- loadSharePanel starts loading the encoder beside the panel's own code
  (and so as Publish is pressed), rather than once the panel asks.
- Download is as tall as Copy message and Email it, with padding, so
  its focus ring no longer hugs the word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Tophat fix. The refusal showed below Email it, about 280px under Copy
link on a phone. Now a refused link copy shows under the link's row (on
a phone, beside the link box that appears, above Copy link), and a
refused message copy under Copy message and Email it. Each place is a
live region from the start, so what it says is announced; the link's
also says, unseen, each copy that worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Tophat fix: two pieces the review round added had no test. Publish
signup's refusal shows for a press in this opening of the sheet and is
gone when it opens again; a signup that turns open without this page's
Publish opens no panel and announces nothing.

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

CI's e2e failed the publish walkthrough twice in a row: the share
panel's message read "Sat, Apr 6 · 2 slots · 4 spots" where the steps
had moved the copy to Apr 13. The walkthrough changes the copy's date
and goes straight on (Done, delete the empty slot, the Fields dialog,
Preview, Publish). A cell saves 800 ms after the last keystroke, and
"Saved" can be the delete's; on a slow runner the date's PATCH hadn't
reached the database when Publish's re-render built the message, and
nothing re-renders the layout after a Build save, so the panel kept
the duplicate's old date. Delaying slot PATCHes by 2.5 s locally
reproduces the failure exactly; with this change the walkthrough
passes under that delay.

The walkthrough now waits for that PATCH to land, as an organizer sees
Saved, before going on. The race itself, Publish while a Build save is
pending or in flight, is fixed in the app by the publish check's
flushPendingSaves(), in a later PR of this stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
spotsTakenLine, beside closeSignupQuestion: "0 of 21 spots taken" when
every slot has a limit, "3 spots taken" (and "1 spot taken") when one
has none or there are no slots to count against, and "0 of 1 spot
taken" for a total of one. A closed signup's says so first, "Closed ·
3 of 21 spots taken", and an archived one's "Archived · …": its count
won't change now. It goes by spotCounts' taken and total, so it counts
confirmed and tentative sign-ups as the public page does.

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

Closes #499. Publish was a small text-xs button on a computer and, on a
phone, an item inside the More actions sheet, and a draft showed a
public link whose page only said it wasn't ready yet.

- Computer: Preview and Publish and share (Send icon, "Publishing…"
  while it runs) at the header's normal size, 36px tall, 13px; Share
  and ⋯ match on an open signup. A draft has no ⋯. Closed and archived
  show Preview only.
- Phone: a bar fixed to the bottom of the screen while the signup is a
  draft or open, over the page and under Radix's z-50 sheets, with a
  top border and the home indicator's inset. A draft's bar is Publish
  and share, 48px tall, with a refusal just above it; an open signup's
  is the count and Share, which opens PR 2's share sheet and gets focus
  back when it closes. PR 2's full-width Share under the link moves
  into it. Closed and archived have no bar.
- The page leaves room for the bar on phones only, and only while it
  shows (PHONE_BAR_ROOM in the layout: the bar's 73px and the home
  indicator's inset), not in the Build tab as well. In the browser at
  390x844 the Build tab's grey runs to the bar, the last slot, Add a
  slot and an open slot editor stay clear of it, and so do the ends of
  Responses and Settings.
- The More actions sheet loses Publish signup. It holds Preview (a new
  tab, as on a computer; choosing it closes the sheet) for a draft, and
  Preview then Close signup for an open signup; a closed or archived
  signup still has no ⋯.
- Under the title, a draft says "Only you can see this draft until you
  publish it." in place of the public link. Open and closed signups
  show the link and, beside it on a computer, spotsTakenLine from the
  layout's counts; on a phone an open signup's count is in the bar and
  a closed one's under the link.
- Publish behaves as in PR 2: no navigation, the share panel opens once
  and only for this page's Publish, and the same spot then says Share.
- The header's button classes live in header-buttons.ts, so Preview,
  Publish and share, Share and ⋯ stay the same size.
- What never changes on the page (the draft line, the phone's link and
  closed count, the sheet's Preview with its icon) is rendered on the
  server in HeaderParts.tsx and handed to the phone's header as nodes,
  which keeps about 0.2 kB out of every signup tab's first load.
- Tests: both headers per status, the bar per status, the ⋯ sheet's
  items per status, a refusal above the bar's button, and Publish from
  the bar handing focus to its Share. PR 2's test of Publish signup's
  refusal in the sheet goes with the item. The organizer e2e and the
  run skill's sample script press Publish and share.
- CHANGELOG: PR 2's line says Share is in the bar on a phone, since
  both ship together.

Budgets: every /app/signups/[id] tab grows by 0.2 kB gzip (PhoneBar,
the bar's Share and the phone header's props), all within budget:
build 154.4 -> 154.6 (155), responses 127.6 -> 127.8 (128), settings
127.1 -> 127.3 (128), edit and [id] 126.2 -> 126.4 (127).

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" follows the new header.

- Create: the signup is a draft, and under its title it says "Only you
  can see this draft until you publish it."; there's no link to send
  yet. The paragraph saying a draft's link only says it isn't ready
  goes, and so do the walkthrough steps that opened it.
- Preview: at the top on a computer, and on a phone under the three
  dots, which phones didn't have before.
- Publish: Publish and share, at the top of the page or at the bottom
  of the screen on a phone. publishOnPhone ("Publish signup") goes from
  the UI list.
- Share again later: Share is at the top, or at the bottom on a phone,
  and how many spots are taken is next to the link (beside Share on a
  phone). The header picture's crop takes in the count, and
  published.png is re-taken with it (605x110 in the article). The
  other pictures came out the same apart from text rendering, so they
  stay.
- The phone walkthrough opens Preview from the three dots, checks that
  Publish and share fills the bar at the bottom, publishes from it, and
  finds focus back on the bar's Share beside "0 of 1 spot taken".
- docs/writing-help.md's example of a phone showing a button somewhere
  else is the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review fixes. ShareScope and StatusActionScope said Publish's scope had
to outlive the phone's More actions sheet, "which closes as Publish
lands"; Publish is in the bar now, and the scope serves the computer's
header and the phone's bar. Nothing else existed only for that: the
phone header still tracks the status its sheet was opened on, because
Close signup in the sheet changes it. The ⋯ shows when hasPhoneBar
says the bar does (a draft or an open signup), instead of repeating the
rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review fix. With nobody signed up, the tab said "Share the public link
to start collecting." whatever the status, but a draft shows no public
link now, and a closed or archived signup takes no more sign-ups.
noResponsesLine, beside summarizeResponses and tested with it:
- draft: "No signups yet. People can sign up once you publish and
  share it."
- open: "No signups yet. Share the public link to start collecting.",
  as before.
- closed or archived: "No signups. People can’t sign up now."

No help page names it, and the responses e2e reads only "No signups
yet." on an open signup.

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

- A refusal makes the bar taller (141px with a two-line one, against
  the 73px the page left), and it stays on every tab until the next
  press, so the bottom of Settings' Delete signup could end up under
  it. PhoneBar now measures itself with a ResizeObserver and puts its
  height on the page as --phone-bar-h, from a ref callback whose
  cleanup removes it with the bar. PHONE_BAR_ROOM is that height, or
  73px and the home indicator's inset until it's measured.
- Tab could scroll a focused control to the bottom edge, behind the
  fixed bar (WCAG 2.2, 2.4.11 Focus Not Obscured). html now has
  scroll-padding-bottom: var(--phone-bar-h, 0px), so a control brought
  into view stops above the bar. On a computer the bar is hidden, so
  its height, and so the padding, is 0; no :has() needed.
- Test: the bar reports its height to the page and clears it once a
  closed signup has no bar.

Checked at 390x844 in the browser: with "This signup changed since the
page loaded. Reload to see the latest." the bar is 141px and the room
and scroll padding follow it; the ends of Build and Settings, Delete
signup included, stay clear. Tabbing through Responses (8 sign-ups),
Build, and Settings with the refusal showing leaves no focused control
behind the bar; with the padding switched off, 6 of Responses' Remove
buttons and Build's Add a slot were behind it.

Budgets: the signup tabs grow by another 0.13 kB gzip, within budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Review fix. Two Unreleased entries still described a step on the way:
#457's ended "A draft still shows Preview and **Publish**.", now
**Publish and share**; #390's put Publish's refusal "on a phone under
the button in the More actions sheet", now just above Publish and share
in the bar, with Close signup's under its buttons in the ⋯ panel or
the More actions sheet. #499's line adds the bar's measured room, the
scroll padding that keeps focus above the bar, and the Responses tab's
new wording with nobody signed up. The other mentions of Publish, More
actions and the public link in Unreleased are still true.

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

richshaw commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner Author

Tophat: passed

The full run was on b45de83 with pnpm dev, Postgres 16, and Chromium at 1280×900 and 390×844. After the review round, the behaviour the fixes changed was checked again on 344cd6c (the head). The SHA column says which commit each check ran on.

Check SHA Result What I saw
Draft, computer b45de83 ✅ Preview and Publish and share are both 36px tall, with 13px text. "Only you can see this draft until you publish it." sits under the title. There's no public link and no ⋯.
Draft, phone b45de83 ✅ The same line, with no link. A fixed bar (73px) holds a full-width Publish and share (358×48). The ⋯ sheet holds only Preview, which opens /preview in a new tab and closes the sheet.
Publish from the header (computer, keyboard) b45de83 ✅ Enter opened "Now send people the link" with focus on Copy link, and the URL stayed on /build. Escape put focus on Share, in the spot where Publish and share was, followed by ⋯. "0 of 21 spots taken" shows beside the link.
Publish from the bar (phone) b45de83 ✅ From Build and from Settings, which stayed on /settings. The share sheet opened over the bar. ✕, Escape and a tap outside each returned focus to the bar's Share. The bar then shows "0 of 21 spots taken" beside Share.
The share panel (#574) and Close signup (#571) b45de83 ✅ Copy link, Copy message, Email it and the QR download all work, and on a phone Share… does too. Close signup asks first. Once closed: the banner, Preview only, "Closed · 1 of 21 spots taken", and focus on the title.
Counts after sign-ups b45de83 ✅ After 1 and then 8 sign-ups on the public page, the count read "1 of 21" and then "8 of 21", both in the bar and beside the link. On a signup with no limit: "0 spots taken", then "3 spots taken".
Closed and archived b45de83 ✅ Computer: Preview only, "Closed · …" or "Archived · …". Phone: no bar and no ⋯, with the count under the link.
The bar against the end of the page b45de83, 344cd6c ✅ The room left is 73px on a phone for a draft or open signup, and 0 when closed, when archived, and on a computer. Build with 7 slots, with the last slot's editor open, Responses with 8 rows, and Settings all end clear of the bar. On 344cd6c, the two-line refusal ("This signup changed since the page loaded. Reload to see the latest.") makes the bar 141px. The room and scroll padding follow it, and Delete signup on Settings and Add a slot on Build stay clear.
Focus not hidden behind the bar 344cd6c ✅ Tabbing through Responses (8 sign-ups), Build, and Settings with the refusal showing leaves no focused control behind the bar. As a control, with the padding switched off, 6 Remove buttons and Add a slot ended up behind it.
Refused publish, phone b45de83 ✅ "Add a slot before publishing." shows just above the button in the bar. No sheet opens, and the signup stays a draft.
Keyboard order (computer) b45de83 ✅ Draft: Preview → Publish and share → Build tab. Open: Copy public link → Open public page → Preview → Share → More actions. The phone header and bar never take focus on a computer.
No sideways scroll (phone) b45de83 ✅ scrollWidth is 390 on every state and on the help page.
A draft's Responses tab 344cd6c ✅ "No signups yet. People can sign up once you publish and share it."
Help page b45de83 ✅ The steps match the screens. published.png (1210×220, shown at 605×110) now shows the count beside the link.
Console and server log all ✅ No browser console errors. Nothing in the server log comes from this change.

What changes for organizers: a draft now has one blue Publish and share button: top right on a computer, in a bar along the bottom of a phone screen. On a phone, Publish was hidden in the ⋯ sheet before. A draft no longer shows a public link that only says it isn't ready. It says "Only you can see this draft until you publish it." instead. Once published, the same spot says Share, and the link shows how many spots are taken (in the bar on a phone). On a phone, Preview is now in the ⋯ sheet.

The screenshots were sent to the session linked below. They include the draft and open headers at both sizes, the phone bar, the refusal above the bar, the ends of Build and Settings with the bar showing, and the closed and archived states.

https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H


Generated by Claude Code

The signup layout's imports conflicted: it keeps shareMessage and takes
KEEPS_THEIR_SPOT beside spotCounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings in the QR picture's right-to-left ellipsis and whole emoji, a
second tap on Save image ignored, the share message's days and time
taken from the open slots, the message box sized from what the browser
measures, the help's "Most phones then open their share menu…", and
PR 1's KEEPS_THEIR_SPOT, which the closed banner in the signup layout
now uses.

Conflicts:
- CHANGELOG #505: PR 2's line with its new sentence on the open slots'
  days and time, keeping this branch's final state: "After **Publish
  and share**", a sheet on a phone with no More actions sheet closing
  first, and Share "on a phone in the bar at the bottom of the screen".

layout.tsx merged on its own, with KEEPS_THEIR_SPOT in the banner and
this branch's room for the phone bar; so did the help article's new
sentence and spot-counts.ts, which keeps spotsTakenLine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Each title line was a first-strong isolate, so it took its direction
from its own first letter: a Hebrew title whose second line starts with
"Zoom" drew that line left to right, with its "…" on the right. The
direction now comes once from the whole title's first letter
(textDirection in qr.ts: digits, spaces and punctuation don't decide
it, as in the bidi algorithm), and every line is isolated in it, with
U+2067 for right to left or U+2066 for left to right. Unit tests cover
Hebrew, Arabic, English, Hebrew after a number, and a Hebrew title
whose second line starts with "Zoom".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
With Nov 7 at 9:00 full and Nov 14 at 11:00 with room, the message read
"Sat, Nov 7 and Sat, Nov 14 · 3 spots left": the full slot added a day
nobody can sign up for and hid the time the other has, though the spots
already skip it. dateSpan now takes committedBySlot and reads the slots
people can still take, open with a spot left or no limit; when none has
room, the open slots; when none is open, every slot. Tests cover a full
slot, no limit as room, and every open slot full. The CHANGELOG says
so, and a doubled blank line in the doc comment is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings ff75578 and 7a019bb. CHANGELOG.md conflicted in the #505 entry,
which this branch had rewritten: kept this branch's entry and replayed
PR 2's one changed sentence, on which slots the days and time come from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings 840c0c8 (drop the unused insufficientScopeResponse), 35288fa
(count swept rows from the delete itself) and fe290df (next 15.5.27 to
15.5.28). Merged without conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Opening the code pushes the message and its buttons below a phone's
screen (Copy message ends 167px below a 375x667 one), and the button
looked the same open or not, so testers lost Copy message and Email it.
While the code shows, the button now has the app's pressed look, a
brand border on the brand tint (ink text, as brand on the tint is under
4.5:1), and says Hide QR code, so one tap brings the message back. Its
name follows the label and aria-expanded stays. It is half the row
either way, so the row doesn't move, and the code stays under the Copy
link row. The tests, the walkthrough's phone steps (which now hide the
code and find Email it back on screen), the help article, its UI list
(hideQrCode) and the CHANGELOG follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
On a phone without a share menu, Copy message and Email it were 148px
each in a wrapping row, so the pair stopped at x=320 while Copy link
and QR code above filled the row to x=374. They are now a two-column
grid, half the row each, lined up with the row above, which also keeps
Email it still when Copy message says Copied. A computer keeps its row
and its minimum widths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
rate-limit.ts merged cleanly: it keeps this branch's comment on the
link beacon's limit and takes #613's sweep counting from the delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
The Copy link row was a flex row of two flex-basis-0 buttons, and QR
code's border made it 2px wider than Copy link, so the row above sat
1px off the new Copy message and Email it grid (174 and 176px against
175 and 175 at 390 wide). It is now the same two-column grid, the large
code spanning both columns, so both rows line up exactly and QR code
keeps its width as it says Hide QR code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings d33a05a, 4e0e3fd and 1c178c4 (on a phone, QR code looks pressed
and says Hide QR code while the code shows; the message's buttons and
the Copy link row are two-column grids), and through PR 1, main's #614,
#613 and #617 (next 15.5.28). CHANGELOG.md conflicted in the #505
entry, which this branch had rewritten: kept this branch's entry and
replayed PR 2's one changed sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings ee0f0d5 (vite 7.3.5 to 7.3.7, a dev dependency). Merged without
conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings 0923e7e, which merges main's ee0f0d5 (vite 7.3.5 to 7.3.7) and
740e98d (nodemailer 10.0.9 to 10.1.4). package.json and pnpm-lock.yaml
merged without conflict beside this branch's lean-qr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Brings 394f323, which carries main's ee0f0d5 (vite 7.3.5 to 7.3.7) and
740e98d (nodemailer 10.0.9 to 10.1.4) up from PR 1. Merged without
conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoJtwsyrp6YC4DU9JAnc7H
Base automatically changed from claude/relaxed-dijkstra-cpx7zo-2-share-panel to main October 25, 2026 18:32
main's bcb82b3 is #574 squashed onto #571's 24f8689, and its tree is
exactly #574's last head, whose tree this branch already has through
394f323. So every change main has is already here, and this merge
keeps this branch's tree as it is (the conflicts were #571's and #574's
lines that this branch later changed).

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
stacked auto-merge was automatically disabled October 25, 2026 18:35

Auto-merge was disabled for another pull request in the stack

@richshaw
richshaw enabled stacked auto-merge (squash) October 25, 2026 18:35
@richshaw
richshaw merged commit 4dff92f into main Oct 25, 2026
5 checks passed
@richshaw
richshaw deleted the claude/relaxed-dijkstra-cpx7zo-3-publish-button 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
richshaw pushed a commit that referenced this pull request Oct 25, 2026
…ains an empty closed signup's Responses tab

The header now records every status change it shows, with whether a
button on this page made it. Before, a change from elsewhere left the
last one made here in place, so after a close here, a reopen and a close
from somewhere else showed the box again. The box now also needs the
last change to be this page's.

A closed signup's empty Responses tab says only "Nobody signed up.",
which counted on the box being there on every visit. It still says that:
"closed" in the header already means nobody can sign up. The comment,
the test's comment and #578's CHANGELOG line now say so.

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

Make Publish hard to miss, and say when it fails

2 participants