Uh oh!
There was an error while loading. Please reload this page.
Unified invite landing for collection and document invites - #638
Unified invite landing for collection and document invites#638icarusz wants to merge 29 commits into
Conversation
Both invite entry paths (#/join-collection/... and #/share/...) now land on
one InviteLanding card: kicker, inviter line, title, display-only payload
preview, what-is-QuartoHub explainer, and a single CTA. Signed-out users see
the card (not the login wall) with a GIS "Continue with Google" CTA that
round-trips back to the invite via the pre-auth hash; signed-in users get
one-click join/open. Share routes no longer auto-connect on load: the URL is
still scrubbed on mount, but connection happens on the CTA click. After
joining, the user lands in the editor on the intended file with a one-time
dismissible welcome banner (per-target localStorage dismissal).
- routing.ts: ShareRoute gains from=/preview=; JoinCollectionRoute gains
preview=/start= (base64url JSON codec in utils/invitePreview.ts, versioned,
capped at 3 projects x 2 files, display-only). Legacy URLs parse unchanged;
malformed payloads degrade to undefined. savePreAuthHash takes an explicit
hash; clearPreAuthHash drops it once an invite is consumed.
- App.tsx: pendingShare captured at boot; connectToSharedProject hoisted with
{quiet, addToSet} opts; collection CTA subscribes then opens the start=
target (else home); invite-first silent root creation now covers share
invitees; ephemeral preview boots keep eager connect.
- JoinCollectionLanding (name/color form) deleted; identity comes from the
Google-account name upgrade with anonymous fallback.
- Sender side: buildInviteUrl embeds preview from cached peek summaries and
start=first project's first file; buildShareableUrl gains {from, preview},
wired at both call sites (ProjectsHome menu, Editor ShareDialog).
- AuthProvider SignInButtonProps gains text variant ('continue_with').
- Dev harness pages for all landing variants + welcome banner.
Tests: TDD throughout (routing/codec/component/banner suites written first,
verified red). 476 unit + 118 integration + 133 wasm green except the
pre-existing main breakage tracked in bd-qvjc6gdp (useAutomergeSync mock,
BranchBar). e2e share-link specs updated to click through the landing; suite
71/72 (BranchBar again). Verified end-to-end in a real browser against a
local hub: share link -> landing -> CTA -> editor on file -> banner, and
collection invite -> join -> editor on start target -> banner.
Design handoff bundle (spec + authoritative mocks) committed at
design_handoff_invite_landing/; plan at
claude-notes/plans/2026-09-01-unified-invite-landing.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…xdcxbpq)
Andrew's manual testing surfaced invite previews showing "0 files" for
populated projects. Cause: two layers dropped the cached summary when an
entry was copied into a collection. ProjectsHome's entryFor projected only
{indexDocId, syncServer, description}, and addProjectToSet discarded
entry.summary on write. The collection copy therefore had no file counts
until the project's next open (updateProjectSummaryEverywhere then healed
every copy — which is why reopening "fixed" it).
Both layers now carry the summary: entryFor includes it when the source
entry has one, and addProjectToSet persists it on create and adopts it on
re-add only when the existing entry has none (freshness stays owned by
updateProjectSummaryInSet, so a stale re-add can never clobber a live
summary).
TDD: three new schema tests written first and verified red; 75/75 green
after. Verified live against a local hub: adding a project to a brand-new
collection immediately shows "2 files" on the copy, and the collection's
invite preview payload decodes with the real fileCount without reopening.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-fxdcxbpq) Direction change from Andrew's design review: the invite is to a collection, so joining should land on the QuartoHub home screen with that collection's section promoted to the top — not cold-open the first document's editor. - handleCollectionCta: subscribe, then navigate home; the start-target editor open is gone. ProjectsHome gains promoteCollectionId (session- scoped, set on join) and sorts that section first. - InviteLanding: startName prop and "Join and open X" CTA removed; the collection CTA reads "Join <name>" (or "Join collection" on legacy links). - buildInviteUrl no longer emits start= (the parser still tolerates it on links already in the wild — verified live against one). - Welcome banner: now scoped to the invite target — a document invite's banner shows only on that project; a collection invite's banner shows on the first project opened from that collection, reworded to "Carlos invited you." (the "suggested starting here" claim died with start=). TDD: banner-copy test updated first and verified red; 235 affected tests green after. Verified live against a local hub: join CTA lands on home with the joined collection sorted above an older one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Andrew's rule from design review: if the user is about to go through the
Google sign-in flow for the first time, frame the invite as joining; if
they are already signed in, one click just opens the thing.
- Signed out: a "Join to collaborate on <target>" lead-in renders above
the Google button (whose label GIS locks to "Continue with Google").
The target is the file name for document invites (from the preview,
falling back to the title) and the collection name otherwise.
- Signed in: the CTA is "Open <name>" for both kinds ("Open collection"/
"Open document" on legacy links); the busy label is "Opening…". The
collection join still happens on click — only the verb changed.
TDD: CTA-matrix tests updated first; 18/18 green, CSS lint at baseline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header's hand-drawn stacked-bars mark is replaced with the app's actual quarto-icon.svg (the segmented circle), tinted for dark mode via --logo-filter like the other uses of the asset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Andrew's manual testing: clicking "People & invite…" in a collection's ⋯ menu did nothing. The activation click bubbled from the MenuItem to the Menu root's closer, whose onClose (closeAllMenus) also resets membersFor — cancelling the popover in the same React batch that requested it. Marked the item keepOpen (its onSelect already closes the menu explicitly), matching the component's documented escape hatch. TDD: new ProjectsHome.integration.test.tsx drives the real menu path (actions button -> menu item -> popover role=dialog), verified red then green; full integration suite 119/119. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captured from the dev harness pages so reviewers can assess the revised invite screens next to the design mocks without running the branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
Implemented screensEvery card state, light and dark. Updated 2026-09-08. Collection invite — signed out / signed in
Project invite — signed out / signed in / single-file project
AnatomyThe top row names both the kind of invitation and where it leads — the card previously mentioned Quarto Hub only in a footnote, so a recipient could not tell what they were being invited into. Then the inviter line, the title, a display-only contents row, one CTA, and a small right-aligned footnote. Signed out, the provider's Google button is the entire CTA: the inviter line already says what you are joining, so a "Join to collaborate on …" line above it was both redundant and easy to mistake for a second button. On the empty-looking statesWorth knowing what does and doesn't produce them, since they look alike:
No capture yet for the welcome banner or the post-join home: the harness fixtures needed to show them honestly don't exist (projects-home has no collections), and earlier attempts implied the wrong thing. 🤖 Generated with Claude Code |
icarusz
commented
Sep 2, 2026
I think I will spend some time (maybe early next week) harmonizing the different styles a bit. The new editor updates give it a different feel. |
The welcome-banner capture was a 40px bar above 900px of blank page (the harness renders the banner alone, and it was captured fullPage), and it did not show the inline rename its caption advertised. The projects-home capture could not show the behavior the PR describes: the harness fixture FAKE_COLLECTIONS holds only the root set, so the page renders 'Everything else' with no collection sections — nothing of the promoted-collection landing. Replacements need harness pages built for the purpose; removing them beats leaving reviewers with artifacts that imply the wrong thing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dcxbpq) Reviewing the PR screenshots turned up two theming defects in the new components. Both were invisible to the existing tests — jsdom does not resolve color-mix, and every capture was light-mode. 1. The payload preview box drew its border, row dividers, thumbnail frame, ruled texture, and filename chip from the brand *primitive* --posit-blue-light-1. Primitives do not flip with the theme, so in dark mode they resolved to #D1DBE5 on a #213D4F card — near-white grid lines. theme.css documents the layering (primitives → semantic → component); these now derive from the semantic --border-color via opaque color-mix. At 50% that reproduces the handoff's #D1DBE5 in light mode to within one channel, and gives a hairline in dark. 2. Surfaces were painted with --bg-subtle, which is translucent in *both* themes (rgba(0,0,0,.03) / rgba(255,255,255,.05)), against .claude/rules/hub-client-theme.md. The landing's page surface is now an opaque mix, the filename chip sits on --bg-modal, and the banner's tint mixes toward --editor-bg, the opaque surface it sits above. Adds e2e/invite-landing-theme.harness.spec.ts, which runs both themes (harness config runs in hub-client-e2e.yml) and pins the two invariants theme-neutrally: dividers stay under 2.5:1 against the surface behind them, and every painted surface is fully opaque. Verified as a real guard by reintroducing both defects: the dark-divider test and both banner tests fail, and — as with the original bug — the light-divider test still passes, which is exactly why this hid. Note the first version of the spec passed for the wrong reason: Chromium reports color-mix results as `color(srgb 0.81 …)` with 0-1 floats, which the colour parser read as 0-255 channels. Fixed before relying on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-fix captures, clipped to the landing surface (no dead page space) at 2x, with a dark-mode counterpart for each of the four card states — the theme the earlier captures never covered, and where the defects fixed in ea9b46e were hiding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ding # Conflicts: # hub-client/changelog.md
…(bd-fxdcxbpq)
Andrew's design review: a #/share/… link grants access to a whole
project (the index document) and merely opens at one file, so calling it
a DOCUMENT INVITATION mislabels it and leading with file-level detail
puts the emphasis in the wrong place.
- Kicker is PROJECT INVITATION; the invite kind is 'project' throughout
(component, banner, App, preview payload). The signed-out lead-in and
the CTA name the project, never the file the invite happens to open at
("Join to collaborate on Quarterly report", "Open project" on legacy
links). The welcome banner names the project too, rather than saying
"shared this document with you".
- The ruled-paper thumbnail is gone. It could only ever draw an empty
page — `preview=` deliberately carries no document content — so at
74px it read as a big empty box, worst of all on a single-file
project. The payload is now one mono row listing the project's
contents (opened file first, then other paths, then the total), which
is the project-level analogue of the collection card listing its
projects.
- With one file there is nothing to list, so the payload box is omitted
entirely: the title already names the project, and a box holding a
lone count is just a placeholder. New harness page
`invite-landing-project-single-file` shows that state.
- Fixes a pluralization bug this surfaced: fileSummary hardcoded
"${n} files", so a one-file project read "1 files" — on project cards
and inside collection rows.
- Wire format: the project marker is 'p'; the decoder still accepts the
'd' spelling from this feature's own development so links generated
while dogfooding keep resolving. Covered by a test.
Screenshots recaptured (the four document captures showed the removed
thumbnail); the single-file state is now among them, light and dark.
Green: typecheck, 1127 unit, 120 integration, 133 wasm, 6/6 theme
harness, 72/72 e2e, lint:css clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The commit was amended to drop stray package-lock churn, which changed its hash after the changelog entry was written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-fxdcxbpq) Andrew's design pass while trying the flow locally: - The inviter line reads "<name> invites you to collaborate on" for both invite kinds, replacing "invited you to" / "invited you to edit". The two cards now share one sentence pattern; only the kicker and the payload differ. - The explainer moves *below* the CTA and shrinks to one quiet line: "New to Quarto Hub? Learn more." The long "It's where teams write Quarto documents together…" pitch and the tinted panel are gone, so the card leads with who invited you, what to, and the single action. This reverses the 3a mock's "CTA is the last element, nothing below it" rule — deliberately, and the test that pinned it now pins the new order instead. - "Learn more" links to LEARN_MORE_URL, provisionally quarto.org until a Quarto Hub introduction page exists (bd-rh2n4d7q). It uses --accent-secondary: --accent-primary is the brand orange, and the global anchor colour (--posit-blue) is nearly invisible on the dark card. Screenshots recaptured in both themes. Green: typecheck, 1128 unit, 120 integration, 6/6 theme harness, 72/72 e2e, lint:css clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dcxbpq) Andrew's design pass, from trying the flow locally: - The card never said where you were being invited to. Quarto Hub was named only in a footnote pitched at newcomers, so a recipient got "Carlos invites you to collaborate on Quarterly report" and a Google button. The top row now carries both facts: the kicker on the left, a Quarto Hub lockup opposite it. - Dropped the "Join to collaborate on <name>" line above the sign-in button. The inviter line already says what you are joining, and sitting directly above a real button it read like a second one. The signed-out CTA is now the provider's button alone. - The footnote is right-aligned and keeps its full sentence: a bare "Learn more" had nothing to attach to. And the state that prompted all of it: an invite card with no contents row. A genuine single-file project turns out to be nearly unreachable — the scaffolds floor at two files (default), four (website), nine (blog) — but the same bare card showed whenever the *sender* had no cached peek summary, which is any project never opened in that browser (one that arrived through a collection, say). "Share link…" now fetches a summary on demand in exactly that case; the cached path stays synchronous and does no network call. Two things that needed care there: - refreshPeek's write travels up through onUpdateProjectSummary and back down as props, so `item.summary` is still stale when the await resolves. It now returns the summary it computed. - navigator.clipboard.writeText needs the click's transient activation, which an awaited round trip can outlive (Safari is strictest). The old copyToClipboard only logged a rejection, which looked identical to success — the label just never changed. It now reports "Copy failed — try again", and shows "Preparing link…" while fetching. Only the previously-degraded path takes the async route. Screenshots recaptured in both themes. Green: typecheck, 1128 unit, 122 integration, 133 wasm, 72/72 e2e, build:all, lint:css. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ding # Conflicts: # hub-client/changelog.md
…xbpq) Andrew opened a collection invite that showed no file information and asked whether the earlier fix had missed. Two separate things: The link itself carried no `preview=` at all, so it took the legacy no-payload path — it was minted by JS already loaded in the sender tab, from before the bundle was rebuilt. buildInviteUrl always emits a preview; there is now a test that copies a real collection invite through the ⋯ → People & invite → Copy link path and asserts the payload is present, because "no preview param" and "broken preview" look identical from the card. But the question surfaced a real defect beside it. buildInviteUrl fills each row with `it.summary?.fileCount ?? 0`, and InviteLanding rendered that literally: a collection containing a project the sender had never opened locally showed a row claiming "0 files". Zero there means "no cached summary", never "this project is empty" — every project is scaffolded with at least two files. Such rows now show the project name alone; rows that do have a summary are unchanged. Not fixed the same way as the project-share path: buildInviteUrl runs during render to display the truncated URL in the popover, so it cannot await. Populating those counts would mean prefetching on popover open and letting the link update when it lands — deliberately left out. Green: typecheck, 1129 unit, 124 integration, 133 wasm, build:all, lint:css. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>










Summary
One invite landing card for both entry paths (bd-fxdcxbpq), per the handoff in
design_handoff_invite_landing/:JoinCollectionLanding— no name/color form, identity comes from the Google account.from=and a display-onlypreview=(base64url, capped, never doc ids). Copying a project's share link fetches its file summary first when one isn't cached, so invites for projects you haven't opened locally arrive with a real preview. Legacy links parse unchanged.Also fixed while dogfooding: peek summaries weren't carried into collections, a "People & invite…" menu item that did nothing, "1 files", collection rows claiming "0 files" for projects the sender never opened, the placeholder header logo, and two dark-mode theming defects.
Out of scope per the handoff: zero-setup cold start, seeded samples, revocable links, sender UI redesign.
Verification
origin/mainmerged atcaf3317a. Every tier green: typecheck, 1129 unit, 124 integration, 133 wasm, 72/72 e2e, 6/6 theme harness,build:all,lint:css. TDD throughout. CI on this branch is 10/10.Walked end-to-end in a browser against a local hub as a fresh invitee (storage cleared): share link → card → CTA → editor + banner, and collection invite → join → home with the collection on top. Legacy links, including one carrying an obsolete
start=, still work.If you pull this branch, run
npm run build:wasmbeforetest:wasm— main's include-failure fixtures fail against a stale artifact.Screenshots of every state, light and dark, plus the four deliberate departures from the mocks, are in the comment below. Plan:
claude-notes/plans/2026-09-01-unified-invite-landing.md.Known gap
Collection invites can't populate file counts on demand the way project shares now do:
buildInviteUrlruns during render (the popover displays the URL), so it can't await. Rows without a cached summary show the project name alone rather than a wrong count. Closing it properly means prefetching when the popover opens and updating the link when the data lands.🤖 Generated with Claude Code