Skip to content

Unified invite landing for collection and document invites - #638

Open
icarusz wants to merge 29 commits into
mainfrom
onboarding/invite-landing
Open

Unified invite landing for collection and document invites#638
icarusz wants to merge 29 commits into
mainfrom
onboarding/invite-landing

Conversation

@icarusz

@icaruszicarusz commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

One invite landing card for both entry paths (bd-fxdcxbpq), per the handoff in design_handoff_invite_landing/:

  • Both invites land on the same card: a top row naming the invitation kind and Quarto Hub, then the inviter line, the title, a display-only contents preview, one CTA, and a small "New to Quarto Hub? Learn more." footnote. Replaces JoinCollectionLanding — no name/color form, identity comes from the Google account.
  • Share links no longer cold-open the editor. The URL is still scrubbed on mount, but connecting happens on the CTA click. Signed-out invitees see the card, not the login wall, and round-trip back to it through Google.
  • A share link invites you to a project, not a document: it grants the whole index document and merely opens at one file.
  • Collection joins land on the projects home with the joined collection promoted to the top.
  • One-time welcome banner in the editor, dismissal persisted per target.
  • Sender side: invite URLs carry from= and a display-only preview= (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/main merged at caf3317a. 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:wasm before test: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: buildInviteUrl runs 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

icaruszand others added 13 commits September 1, 2026 13:50
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>
@posit-snyk-bot

posit-snyk-bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

StatusScan Engine Critical High Medium LowTotal (0)
Open Source Security0000 0 issues
Licenses0000 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@icarusz

icarusz commented Sep 1, 2026

Copy link
Copy Markdown
MemberAuthor

Implemented screens

Every card state, light and dark. Updated 2026-09-08.

Collection invite — signed out / signed in

LightDark
collection, signed out, lightcollection, signed out, dark
collection, signed in, lightcollection, signed in, dark

Project invite — signed out / signed in / single-file project

LightDark
project, signed out, lightproject, signed out, dark
project, signed in, lightproject, signed in, dark
single-file project, lightsingle-file project, dark

Anatomy

The 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 states

Worth knowing what does and doesn't produce them, since they look alike:

  • A genuine single-file project (the last row above) is nearly unreachable — scaffolds floor at two files for default, four for website, nine for blog.
  • The same bare card used to appear whenever the sender had no cached peek summary — any project never opened in that browser. "Share link…" now fetches one on demand, so that case is gone for project invites.
  • Collection rows in that situation carry a zero count; they now render the project name alone rather than claiming "0 files". Populating them needs a prefetch on popover open (see the PR body's known gap).
  • Legacy links carry no preview= at all and still show the bare card by design.

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

Copy link
Copy Markdown
MemberAuthor

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.

icaruszand others added 4 commits September 2, 2026 14:56
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>
icaruszand others added 7 commits September 3, 2026 14:40
…(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>
icaruszand others added 5 commits September 8, 2026 17:13
…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>
…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>
Sign up for freeto 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

@icarusz@posit-snyk-bot