diff --git a/claude-notes/plans/2026-09-01-unified-invite-landing.md b/claude-notes/plans/2026-09-01-unified-invite-landing.md new file mode 100644 index 000000000..aeecab630 --- /dev/null +++ b/claude-notes/plans/2026-09-01-unified-invite-landing.md @@ -0,0 +1,222 @@ +# Unified InviteLanding for collection and document invites + +**Strand:** bd-fxdcxbpq · **Branch:** `onboarding/invite-landing` · **Scope:** `hub-client/` +**Design handoff:** `design_handoff_invite_landing/` (README.md is the spec; `screenshots/3a-unified-invite-landings.png` is the authoritative mock) + +## Overview + +Both invite entry paths — `#/join-collection/…` and `#/share/…` — get one shared +**InviteLanding** card: kicker, inviter line, title, display-only payload +preview, what-is-QuartoHub explainer, single CTA. Signed-out users see a Google +CTA and round-trip back to the same invite URL via the existing pre-auth hash +save/restore; signed-in users get one-click join/open. After joining, the user +lands in the editor on the intended file (not the home screen) with a one-time +dismissible welcome banner. Share routes stop auto-connecting; connection +happens on CTA click. + +Out of scope (later PRs): zero-setup cold start (ProjectSetSetup removal), +seeded first-run samples, revocable/role-scoped links, sender UI redesign +(only the URL builders change). + +## Current-state seams (from 2026-09-01 exploration) + +| Concern | Location | +|---|---| +| Route types / parse / build | `hub-client/src/utils/routing.ts:99-178`, share parse `236-251`, invite parse `266-286`, builders `367-393`, `433-451` | +| Pre-auth hash save/restore | `routing.ts:566-602`, driven from `src/main.tsx:29` — already round-trips invite URLs unchanged | +| Share auto-connect | `App.tsx:460-507` (`connectToSharedProject`), called at `510-532`; **second caller** at `551-567` (ephemeral preview recovery) must keep connecting eagerly | +| Signed-out gate to bypass | `App.tsx:785-792` (`AUTH_ENABLED && !auth → LoginScreen`) | +| Invite render site / post-join landing | `App.tsx:815-824`; `onDone` → `navigateToProjectSelector` at `821` (the "dumped on home" behavior) | +| Join call | `JoinCollectionLanding.tsx:80-106` → `onSubscribe` → `useCollectionSets.ts:414-418` → `projectSetService.connectCollection` | +| Preview payload source (cached peeks) | `ProjectSetEntrySummary` (`ts-packages/quarto-automerge-schema/src/index.ts:138-147`); written `App.tsx:310-325`, `ProjectsHome.tsx:1178-1195`; read via `collectionItemsOf`/`peopleOn` `ProjectsHome.tsx:1254-1289` | +| Sender URL builders | `buildInviteUrl` `ProjectsHome.tsx:1265-1273`; `buildShareableUrl` `routing.ts:433-451` (call sites `ProjectsHome.tsx:1144`, `Editor.tsx:849-855`) | +| Identity | `src/services/userSettings.ts`; Google-name upgrade `App.tsx:222-235`; facepile/initials `ProjectsHome.tsx:162,1043-1062`, `src/utils/facepile.ts` | +| Card CSS to reuse | `.qh-join-card` `ProjectsHome.css:452-521` (~90% of the card spec already); peek styles `.qh-peek*`; tokens in `theme.css` map 1:1 to the handoff hexes | +| Dismissal pattern | keyed localStorage flag à la `MOVE_WARNING_KEY` (`ProjectsHome.tsx:126,572,2024`) | +| Dev harness | `DEV_PAGES` registry `DevHarness.tsx:686`, route `#/dev/` | + +## Design decisions + +1. **Token mapping** (prefer hub-client tokens over mock hexes, per handoff): + card border `--border-color`; dividers `--posit-blue-light-1`; title + `--text-primary`; secondary `--text-secondary`; kicker + primary CTA + `--accent-action-bg`/`--accent-action-text`; radii `--radius-xl/lg/md`; + shadow `--shadow-3`; mono `--font-mono`. Explainer tint: **opaque** + `color-mix(in srgb, var(--posit-blue) 8%, var(--bg-modal))` — the + hub-client-theme rule forbids new alpha tokens (do not reuse + `--posit-blue-alpha-08`). +2. **`preview=` / `start=` / `from=` encoding**: base64url(JSON), compact keys + (follow the existing `entries={d,s,n}` precedent). Display-only — never doc + ids. Parser accepts absent/malformed payloads (legacy links render the + card without the preview block, generic CTA text). +3. **Preview payload shape** (v1, versioned `{v:1,…}`): + - collection: `{v, p:[{n,f,c,i:[initials]}...], t:totalProjects, m:[firstNames]}` capped at 3 projects × 2 files + - document: `{v, f:filename, s:[topFiles], c:fileCount, i:[initials]}` +4. **Google CTA — resolved gap.** There is no local Google asset; the current + button is rendered by Google Identity Services (``), whose + label/styling cannot read "Continue with Google to join". Plan: add a small + inline Google "G" SVG and a custom `.qh-btn.outline` CTA that submits the + same nonce-gated redirect form the GIS button drives (must preserve the + nonce gating in `GoogleAuthProvider.tsx:76-78` — render disabled until the + nonce resolves). The `AuthProvider` interface grows a `SignInCta` + (labelled) alongside the existing `SignInButton`; `noopAuthProvider` gets a + matching stub. **Fallback if the redirect can't be reproduced reliably:** + keep `` inside the card with the handoff copy above it — flag + to Andrew before taking the fallback. +5. **Share connect-on-CTA**: the `route.type === 'share'` arm in the initial + navigation effect stops calling `connectToSharedProject`; instead it stores + parsed invite state and renders InviteLanding. `connectToSharedProject` + itself is unchanged (the ephemeral-preview recovery caller keeps eager + behavior). The security hash-scrub (`navigateToProjectSelector({replace:true})` + before connect) moves to CTA click time; the landing itself must not keep + the doc id in the visible URL longer than today — scrub on mount, keep + parsed route in state. +6. **Post-join `start` target**: after `onSubscribe` resolves, resolve the + `start` project within the joined collection's entries after sync; if + resolvable, connect + `navigateToFile`; else home with the collection + visible (today's behavior). +7. **Welcome banner dismissal**: keyed localStorage flag + `qh-invite-welcome-dismissed:` (the + `usePreference` record shape doesn't fit per-id keys). +8. **Identity**: name/color form is deleted; identity comes from the existing + Google-name upgrade (`App.tsx:222-235`) falling back to + `userSettingsService` defaults. The banner's "Change name" opens the + existing avatar-menu rename affordance. + +## Phase 1 — Test specifications (write first, red) + +- [x] `routing.test.ts` additions: parse/build round-trips for `from=`, + `preview=`, `start=` on both routes; legacy URLs (no new params) parse + to the same route objects as today; malformed/oversized `preview=` + degrades to `undefined`; payload encoder caps at 3 projects × 2 files. +- [x] New `invitePreview.test.ts`: base64url JSON encode/decode unit tests + (pure functions, no DOM). +- [x] `InviteLanding.test.tsx` (jsdom pragma): renders both `kind`s × + {signed-in, signed-out}; exact kicker/CTA copy per matrix (incl. + "Join and open " / generic legacy text); payload block + skipped when preview absent; no name input, no color swatches, no + footnote below CTA; CTA busy state while joining. +- [x] `App`-level "share does not connect on load, connects on CTA" — + **deferred to a Phase 4 harness e2e**: App's initial-navigation effect + is too entangled for a unit test; the harness route + Playwright spec is + the honest surface for it. +- [x] Welcome banner test: shows once, dismissal persists per id, "Change + name" invokes the rename affordance callback. +- [x] Run all new tests, confirm they fail for the right reason before Phase 2. + (2026-09-01: 4 files red — 3 missing-module import failures, 2 routing + round-trip assertion failures; all 95 pre-existing routing tests green.) + +`start=` encoding note: the start target is `{d: indexDocId, f: filePath}` +base64url JSON — a doc id is acceptable here (unlike `preview=`, which stays +display-only) because the collection invite already grants access to every +entry via the collection doc; name-based matching would break on renames. + +## Phase 2 — Routing + payload plumbing + +- [x] `routing.ts`: extend `ShareRoute` (`from?`, `preview?`) and + `JoinCollectionRoute` (`preview?`, `start?`); parse + build; keep all + params optional. +- [x] New `src/utils/invitePreview.ts`: payload types, base64url + encode/decode, size cap, version check. +- [x] Phase 1 routing/payload tests green (106/106 incl. all pre-existing). + +## Phase 3 — InviteLanding component + +- [x] New `src/components/InviteLanding.tsx` (+ `InviteLanding.css`): props + `{kind, inviter, title, preview, signedIn, startName, joinState, + ctaDisabled, error, onCta}`; card anatomy per handoff §"Shared Card + Anatomy"; base styles adapted from `.qh-join-card`; quarto icon from + `/quarto-icon.svg` (with `--logo-filter`); CSS lint clean (logical + box props). +- [x] Google CTA — **decision 4 revised (Andrew, 2026-09-01)**: the hub's + /auth/callback (crates/quarto-hub/src/auth.rs) only accepts GIS-minted + `credential=` form POSTs, so a custom button cannot legitimately drive + the flow. Resolution: the provider's own `` with + `text="continue_with"` ("Continue with Google"), passed into + InviteLanding as the `signInCta` node; `SignInButtonProps` gained a + `text` variant. The "to join"/"to open" suffix from the mock is + dropped (GIS controls the label). +- [x] Dev harness pages: `invite-landing-collection{,-signed-in,-legacy}`, + `invite-landing-document{,-signed-in}`, `invite-welcome-banner`. +- [x] Component tests green (17/17); harness pages visually checked against + `3a-unified-invite-landings.png` in the dev server (collection, + document, and signed-in variants all faithful). + +## Phase 4 — App wiring + +- [x] Signed-out invite routes render InviteLanding above the LoginScreen + gate; the GIS CTA round-trips via pre-auth hash save/restore (share + hashes re-saved explicitly since the URL is scrubbed on mount — + `savePreAuthHash` gained an optional hash param). +- [x] Share arm: auto-connect removed for non-ephemeral links (hash still + scrubbed on mount; route captured into `pendingShare` at boot); + `connectToSharedProject` hoisted to component scope with + `{quiet, addToSet}` opts; ephemeral preview boot/reload keep eager + connect. +- [x] Collection CTA: `subscribeCollection` with account identity (no form) → + `start` target opened via `connectToSharedProject` (addToSet: false) → + editor on file, else home. `JoinCollectionLanding` deleted (component, + test, and its `.qh-join*` CSS). The invite-first silent root creation + effect now also covers share invitees. Known trade-off: the + auth-expired "Sign in again" secondary action from the old landing is + not carried over (error text still shows). +- [x] Broken/incomplete links: error copy rendered inside the landing card, + CTA disabled. +- [x] Integration tests green (118/118); `share-link-project-set.spec.ts` + updated to click through the landing (this is the "no connect on load, + connect on CTA" e2e coverage). + +## Phase 5 — Welcome banner + +- [x] `src/components/EditorWelcomeBanner.tsx`: tinted bar rendered via a new + optional `banner` prop on Editor (next to EphemeralSessionBanner); + collection + document copy variants; × dismiss persists per decision 7. + **Deviation from handoff**: there is no existing in-editor rename + affordance (the avatar menu lives on the home screen), so "Change name" + opens a small inline rename in the banner itself (input + Save → + `updateUserName`). Flagged for Andrew's review. +- [x] Banner tests green (8/8). + +## Phase 6 — Sender side + +- [x] `buildInviteUrl` (`ProjectsHome.tsx`): embeds `preview=` from cached + peek summaries (3 projects × 1 file + facepile initials + member first + names) and `start=` = first project + its first cached top file (per + Andrew's default-to-first decision). +- [x] `buildShareableUrl` gained `opts {from, preview}`; both call sites + embed them (ProjectsHome card menu from the peek summary; Editor's + ShareDialog from live files + identities, via a new `userName` prop on + Editor). `initialsFor` promoted to `utils/facepile.ts`. +- [x] URL-length sanity test with max payload (maximal invite hash < 1500 + chars, round-trips). + +## Phase 7 — Verification + ship prep + +- [ ] `cd hub-client && npm run preflight` and `npm run test:ci`. +- [ ] `npm run build:all` (stricter than typecheck — required for hub-client). +- [ ] End-to-end per CLAUDE.md: real browser against a running hub — generate + an invite link from one profile, open signed-out in another, complete + Google round-trip (or `--allow-insecure-auth` local equivalent), verify + landing → join → editor-on-file → banner → dismissal. Record invocation + + observed output here. +- [ ] Legacy-link e2e: pre-change share + join-collection URLs still work + (`e2e/share-link-project-set.spec.ts` still green). +- [ ] Two-commit changelog workflow (`hub-client/changelog.md`). +- [ ] Review checklist (`claude-notes/instructions/review.md`) before each + commit; no push without Andrew's approval. + +## Resolved questions (Andrew, 2026-09-01) + +1. Google CTA: build the custom nonce-gated button with an inline "G" SVG + (decision 4 as written; fallback only if the redirect can't be reproduced, + flagged before taking it). +2. `start=`: default to the collection's first project + its first cached top + file; sender override comes with the later sender-UI PR. + +## Notes / gotchas + +- Test files are excluded from `tsconfig.app.json`; route-type tightening only + surfaces in vitest, not the app build. +- `JoinCollectionLanding.test.tsx`'s route literal omits `entries` — dies with + the component in Phase 4. +- CSS is linted for token usage (`scripts/lint-css.mjs`); no new alpha colors. diff --git a/design_handoff_invite_landing/QuartoHub Onboarding.dc.html b/design_handoff_invite_landing/QuartoHub Onboarding.dc.html new file mode 100644 index 000000000..0234a780a --- /dev/null +++ b/design_handoff_invite_landing/QuartoHub Onboarding.dc.html @@ -0,0 +1,392 @@ + + + + + + + + + + + + + + + + +
+
3One invite pattern, two payloads — collection invites and document invites share the same landing; PR scoped to just these
+
+ +
3aUnified invite landing — same anatomy, the payload block is the only difference
+
+ +
+
INVITE TO A COLLECTION · #/join-collection/…
+
+
quartohub.com/#/join-collection/4XyZ…
+
+
+
COLLECTION INVITATION
+
CSCarlos Scheidegger invited you to
+

Team docs

+
+
Quarterly report
report.qmd · 12 files
CSJL
+
Methods paper
paper.qmd · 7 files
JL
+
+ 2 more projects · Carlos, Jenny and Mine work here
+
+
New to Quarto Hub? It’s where teams write Quarto documents together — live, in the browser. Nothing to install.
+ + +
+
+
+
+ +
+
INVITE TO A DOCUMENT · #/share/… — same card, single-doc payload
+
+
quartohub.com/#/share/9fKw…
+
+
+
DOCUMENT INVITATION
+
CSCarlos Scheidegger invited you to edit
+

Quarterly report

+
+
report.qmd
+
figures/ · data.csv · 12 files
CSJL
+
+
New to Quarto Hub? It’s where teams write Quarto documents together — live, in the browser. Nothing to install.
+ + +
+
+
+
+ +
+
One component, one anatomy: kicker → inviter → title → payload preview → explainer → single CTA → honest footnote. Only the kicker, payload block, and verb change
+
Document invites stop cold-opening the editor — the same who/what/why moment as collections, then straight to the doc
+
Signed-in users see the same cards with one primary button instead of the Google one: Join and open Quarterly report / Open Quarterly report. Google round-trip returns to this screen (existing hash restore)
+ +
+
+ +
3bInitial PR — onboarding/invite-landing, scoped to the two invite flows only
+
+
    +
  • New: InviteLanding.tsx — replaces JoinCollectionLanding; renders both route types from one layout; props: kind ('collection' | 'document'), inviter, title, preview payload, signed-in state. Name/color form removed (identity from Google, fallback to existing identity service)
  • +
  • routing.tsshare route gains from= + display-only preview= (files count, top files, contributor initials); join-collection gains preview= + start=. Both parse legacy URLs (no new params) fine — landing just omits the preview block
  • +
  • App.tsx — share route stops silently connecting; renders InviteLanding, connect happens on CTA. Signed-out invite routes render the landing (not LoginScreen) with the Google CTA; pre-auth hash restore unchanged
  • +
  • ShareDialog.tsx / buildShareableUrl — embed from + preview; dialog copy: “Anyone with this link can edit this project”
  • +
  • ProjectsHome.tsx — buildInviteUrl embeds preview from cached peek summaries + optional start-here pick
  • +
  • Editor — one-time welcome banner after an invite entry (collection or document): joined context + “Change name”
  • +
  • Tests: both landings signed-in/out, legacy URLs, join→open path, dev-harness pages for both variants
  • +
+
Deferred (later PRs): zero-setup cold start (2b), seeded first-run (1c), revocable role links (1e).
+
+ +
+

Next: “handoff doc for the PR” · “design the sender side — Share dialog + invite popover emitting these links” · “edge states: expired/broken link, already-a-member”

+
+
+
2Hi-fi: invite landing (1b) + zero-setup (1d) — in the real hub-client tokens — and the branch/PR plan
+
+ +
2aInvite landing, redesigned — preview + what-is-this + Google sign-in in one flow
+
+
+
STEP 1 · SIGNED OUT — PREVIEW IS PUBLIC-SAFE, SIGN-IN IS THE ONLY GATE
+
+
quartohub.com/#/join-collection/4XyZ…
+
+
+
COLLECTION INVITATION
+
CSCarlos Scheidegger invited you to
+

Team docs

+
+
Quarterly report
report.qmd · figures/ · 12 files
CSJL
+
Methods paper
paper.qmd · refs.bib · 7 files
JL
+
+ 2 more projects
+
+
CSJLMR
Carlos, Jenny and Mine are working here
+
New to Quarto Hub? It’s where teams write Quarto documents together — everyone edits the same doc, live, in the browser. Nothing to install.
+ +

Sign-in keeps your work on your account — you’ll come straight back here. Anyone with this link can join.

+
+
+
+
+
+
STEP 2 · BACK FROM GOOGLE — SAME SCREEN, ONE CLICK LEFT
+
+
quartohub.com/#/join-collection/4XyZ…
+
+
+
COLLECTION INVITATION
+
Carlos Scheidegger invited you to
+
Team docs
+
Quarterly report · Methods paper · + 2 more
+
AMJoining as Amy Mora (from Google)
+ +
+
+
+
Already signed in? Step 1 skips the Google button and shows this join button directly — one screen, one click total
+
+
+
+
STEP 3 · LANDS IN THE EDITOR ON THE INVITER’S “START HERE” FILE
+
+
Quarterly reportreport.qmdCSAM
+
Welcome to Team docs — Carlos suggested starting here. You’re editing live as Amy Mora.×
+
+
+
+
+
Invite context now leads, not the login wall: preview + “New to Quarto Hub?” explainer render before auth; Google is the only gate, framed as “to join”
+
Identity comes free from Google (name + avatar) — no name/cursor-color form; editor banner offers a rename
+
Preview is display-only data in the invite URL (names, top files, contributor initials) — no bearer doc ids exposed, works before the collection syncs
+
Invite carries optional start= project+file → “Join and open Quarterly report” instead of dumping on the home screen
+
Round-trip already works: pre-auth hash save/restore brings the user back to this exact screen after Google
+
+
+
+ +
2bZero-setup cold start — setup screen deleted, language rewritten
+
+
+
SIGN-IN — NOW SAYS WHAT THE PRODUCT IS
+
+
quartohub.com
+
+
+
+
Quarto Hub
+

Write and publish Quarto documents together — live, in your browser.

+ +

Have an invite link? Just open it — you’ll land right where you were invited.

+
+
+
+
+
+
FIRST VISIT — STRAIGHT TO THE HOME SCREEN (SETUP SCREEN IS GONE)
+
+
Quarto HubAA
+
+
+
Create your first project
+

A project is a Quarto document or website you can edit with others in real time.

+
+
+
+
+
DEVICE LINKING — MOVES INTO THE AVATAR MENU, HUMAN WORDS
+
+
AA
Amiable Auklet
amy@example.com
+
+
Use Quarto Hub on another device…
+
Advanced: sync server…
+
Sign out
+
+
+
+
+
ProjectSetSetup never renders: the silent create/migrate effect that invites and ephemeral previews already use runs unconditionally
+
“Project set” never appears in UI copy; sync server becomes an Advanced item (default hidden)
+
Legacy migration happens silently and non-destructively; “Download backup” moves to the avatar menu
+
Device linking gets human words + stays as a link/QR under the hood (same doc-id mechanics)
+
+
+ +
2cBranch/PR plan — two stacked PRs
+
+
PR 1 · onboarding/zero-setup — small, ships first
+
    +
  • App.tsx — run the silent create/migrate effect for every route (drop the join-collection/ephemeralHub gates); stop rendering ProjectSetSetup for needs-setup/needs-migration (keep it only for the error state, reworded)
  • +
  • LoginScreen.tsx — value line + invite-link hint (strings.ts)
  • +
  • ProjectsHome / avatar menu — “Use on another device…” (wraps buildProjectSetLinkUrl), “Advanced: sync server…”, “Download backup” relocated
  • +
  • ProjectSetSetup.tsx — shrinks to the error/recovery screen; copy loses “project set”
  • +
  • Tests: setup-fresh / setup-migration dev-harness pages + e2e for silent path
  • +
+
PR 2 · onboarding/invite-landing — stacked on PR 1
+
    +
  • routing.ts — invite URL gains display-only preview= (project names, top files, contributor initials — reuse the peek-summary shape, no doc ids) and start= (project + file)
  • +
  • ProjectsHome.tsx — buildInviteUrl embeds preview from cached summaries + a “start here” picker in the invite popover
  • +
  • JoinCollectionLanding.tsx — preview list + facepile; name/color form removed (identity service default); button “Join and open <start>”
  • +
  • App.tsx — onDone: if start resolves after sync, open that project/file; else home with the collection highlighted
  • +
  • Editor — one-time welcome banner (joined collection + “Set my name”); dismiss persists
  • +
  • Tests: landing render w/ + w/o preview payload, join→open path, legacy invite URLs (no preview) keep working
  • +
+
Open questions for the PRs: cap preview payload (URL length) at ~3 projects × 2 files; auto-name wordlist location; whether “start here” defaults to the collection’s most recent project when unset.
+
+ +
+

Next: “tweak 2a/2b …” · “write the PR-ready implementation notes as a handoff doc” · “design the invite popover’s start-here picker”

+
+
+
1Getting into Quarto Hub — what happens today (from the shipped code), then prioritized fixes
+
+ +
1aCurrent-state map — every entry path as coded in hub-client today
+
+ +
PATH ACold start — types quartohub.comApp.tsx auth gate → ProjectSetSetup → ProjectsHome
+
+
quartohub.com
Quarto Hub
Sign in with Google to continue
Sign in with Google
+
Sign-in wall with zero explanation of what Quarto Hub is or why to bother
+
+
quartohub.com/#/
Quarto Hub
Multiplayer editing for your Quarto projects. Get started by creating a new project set, or link to an existing one from another browser.
Sync server URL
wss://sync.automerge.org
Create New Project Set
Link to Existing Project Set
+
Screen #2 asks infrastructure questions: “project set”? “sync server”? — jargon before any value
+
Two equally-weighted choices; a new user can’t tell which applies to them
+
+
quartohub.com/#/
Projects
ME
No projects yet
Create your first project below.
New projectImport .zip
+
Empty room. Nothing to click, read, or learn from — no samples, no tour, no “what can this do”
+
+ +
PATH BCollection invite link (the common case)#/join-collection/… → JoinCollectionLanding
+
+
…#/join-collection/4Xy…
Quarto Hub
Sign in with Google to continue
Sign in with Google
+
Invite context vanishes behind a generic login — no “Carlos invited you” until after Google
+
The invite hash survives the OAuth roundtrip only via a sessionStorage save/restore — fragile
+
+
…#/join-collection/4Xy…
Collection invitation
Carlos invited you to Team docs
A shared collection of Quarto projects — its contents sync to you when you join.
How you’ll appear to the team
?
Your name
Join Team docs
Joining subscribes you to this collection — anyone with the link can join and add or remove projects.
+
Personal root auto-created silently behind this screen — now also silently migrates legacy projects (invitee never sees setup)
+
Invite link now carries only collection id + server — slim and forward-compatible
+
Can’t see what’s inside before committing — no doc names, no faces, no preview
+
Asked to pick a name + cursor color before seeing any value
+
“Anyone with the link can join and add or remove projects” — accurate, and alarming
+
+
quartohub.com/#/
Projects
AB
Team docs
?
?
?
My projects
(empty)
+
Dumped on the home screen after joining — no “here’s the doc Carlos wanted you to see”
+
New: per-project peek summaries (top files + contributor facepile) are cached — the raw material for previews in 1b already exists
+
+ +
PATH CSingle-project share link#/share/<docId>?server=…&file=…&name=…
+
+
…#/share/9fK…?server=…
This share link is incomplete. Please ask the sender to share a new link.
+
Link breaks if any of 3 query params is missing — and the blame lands on the recipient
+
+
…#/p/local-id/file/report.qmd
Quarterly report | report.qmd
+
Straight into the editor with zero orientation — no who shared it, no what-is-this, cursor blinking
+
The link is a bearer token: permanent, irrevocable edit access; project silently added to your list forever
+
+
sender’s side: Share dialog
Share Project
⚠ Anyone with this link can access and edit this project permanently. This link cannot be revoked.
https://quartohub.com/#/share/9fK…
Copy Link
+
Senders are warned off the only sharing tool they have — one link type, all-or-nothing
+
+ +
PATH DSecond browser / device linking#/link-project-set/… or paste-a-doc-ID
+
+
quartohub.com/#/
Link to Existing Project Set
Project set ID
bs58-encoded document ID
Sync server URL
wss://sync.automerge.org
Link Project Set
+
Pure plumbing exposed: paste a bs58 document ID to see your own projects on a second machine
+
 
+
PATH E — partially built (Aug ’26)
Now exists: q2 preview --ui editor boots an ephemeral hub straight into the browser editor (no setup screens); q2 provide-hub attaches a live executor.
Still missing: a bridge to persistent Hub — Positron/RStudio, quarto.org, “edit this page” from a published site
+
Ephemeral preview reuses the invite-first pattern: root created silently, zero prompts
+
Preview sessions are throwaway — no “keep this on Quarto Hub” off-ramp into a real account
+
+ +
+ +
1bP1 · Invite landing that shows the work — join first, identity later
+
+
…/join/team-docs
CS
Carlos invited you to
Team docs
report.qmd
slides.qmd
site
CSJLCarlos + 2 others are working here
Open Team docs
You’ll join as “Curious Capybara” — rename anytime.
+
+
…/p/…/report.qmd
You’ve joined Team docs. Carlos suggested starting with report.qmd →   Set your name
+
+
+
Preview before commitment: doc thumbnails, names, live member facepile — the invite sells itself
+
Identity moves AFTER entry: auto-name, editable from a banner — one click from link to content
+
Invite can carry a “start here” file, so landing = the doc the inviter meant, not the home screen
+
+ +
1cP1 · Seeded first-run — never an empty room
+
quartohub.com/#/ (first visit)
Welcome — here’s a place to start
Samples · yours to edit or delete
Start here ▸
5-min tour you complete by editing it
Article
Slides
Website
New projectImport from computer
+
+
Seed 3 sample projects + a “Start here” tour project that teaches editing, preview, and sharing by doing
+
Samples live in a “Samples” collection — which also teaches what collections are, for free
+
Real scaffolds already exist in the codebase (blog scaffold with posts/welcome) — reuse them
+
+ +
1dP1 · Zero-setup + plain language — delete the setup screen
+
+ + + + + + + +
TODAYINSTEAD
“Create New Project Set” setup screenGone. Auto-create silently on first visit — the invite path already does exactly this
“Sync server URL” input on screen #2Hidden default; an Advanced setting for self-hosters
“Project set”“Your projects”
“Link to Existing Project Set” + bs58 ID paste“Use Quarto Hub on another device” → QR / magic link from settings, or implicit via sign-in
“Sign in with Google to continue”One line of value first: “Write and publish Quarto documents together, in the browser.”
+
Cheapest of all the fixes — copy + one deleted screen; auth account can also key the project set, making device-linking automatic
+
+ +
1eP2 · Legible, safer links — roles + revocation
+
+
Share “Quarterly report”
Anyone with the link can…
ViewCommentEdit
quartohub.com/l/quarterly-rpt
Copy link
Active links: “review link” (view) · created Tue · revoke
+
recipient lands
Carlos shared Quarterly report
preview
You can edit. Your changes sync live.
Open document
+
+
+
Replaces the “cannot be revoked” warning with named, revocable, role-scoped links — needs server-side link indirection (bigger lift, hence P2)
+
Recipient landing states who/what/which-role before the editor opens — fixes the Path C cold-open
+
+ +
1fPriorities — impact vs. effort
+
+
+
P1  1d Zero-setup + language — days of work, removes the worst wall (Path A screen #2, Path D)
+
P1  1b Invite landing shows the work — the most common entry; preview + join-first-name-later + land on the intended doc
+
P1  1c Seeded first-run — fixes the empty room; scaffolds already exist
+
P2  1e Role-scoped revocable links — needs link indirection on the server
+
P3  New entry paths“quarto publish hub” from CLI/Positron; “edit on Hub” from published sites (Path E)
+
+
Sequencing logic: fix language + walls first (cheap), then the invite path (highest traffic), then trust, then growth loops.
+
+ +
+

Try next: “go deeper on 1b — full invite flow screen by screen” · “design the 1c tour project’s content” · “riff 3 variants of the 1e share dialog”

+
+
+ + diff --git a/design_handoff_invite_landing/README.md b/design_handoff_invite_landing/README.md new file mode 100644 index 000000000..4b22d9d64 --- /dev/null +++ b/design_handoff_invite_landing/README.md @@ -0,0 +1,92 @@ +# Handoff: Unified Invite Landing (collection + document invites) + +Branch suggestion: `onboarding/invite-landing` +Repo: `quarto-dev/q2` · scope: `hub-client/` + +## Overview +Redesign of QuartoHub's two invite entry paths so they share one landing pattern: + +1. **Invite to a collection** (`#/join-collection/…`) — today: login wall → name/color form → dumped on home screen. +2. **Invite to a document** (`#/share/…`) — today: login wall → silently cold-opens the editor with zero orientation. + +New behavior: both links land on a single **InviteLanding** card that shows *who invited you*, *what you're being invited to* (a display-only preview), a short *what-is-QuartoHub* explainer, and exactly **one CTA**. Signed-out users get a Google sign-in CTA and round-trip back to the same card; signed-in users get a one-click join/open. After joining, the user lands **in the editor on the intended file** (not the home screen) with a one-time welcome banner. + +Out of scope for this PR (deferred to later PRs): zero-setup cold start (removing ProjectSetSetup), seeded first-run samples, revocable/role-scoped links. + +## About the Design Files +`QuartoHub Onboarding.dc.html` in this bundle is a **design reference created in HTML** — a canvas of annotated mockups, not production code. The task is to **recreate the designs in `hub-client`'s existing React + CSS environment**, reusing its components, tokens (`theme.css`, `ui.css`), auth flow, and routing utilities. The authoritative mocks are the two cards in section **3a** ("One invite pattern, two payloads"), as amended: no "Signed in already…" copy and no "Anyone with this link…" footnote in the cards — the CTA is the last element. Sections 1a (current-state map) and 2a/2b give context; 2b (zero-setup) is NOT in this PR. + +## Fidelity +**High-fidelity for structure, hierarchy, and copy; medium for exact pixel values.** Recreate the card anatomy, ordering, and copy exactly. For colors, radii, type, and spacing, prefer the hub-client's own tokens over the hex values below (the mocks approximate them). Where the mock and an existing hub-client pattern conflict, follow hub-client. + +## The Shared Card Anatomy (both invite types) +Centered card on the app background, max-width ~460px, in this exact order: + +1. **Kicker** — `COLLECTION INVITATION` / `DOCUMENT INVITATION`. Small caps label, brand accent color, ~11px bold, letter-spacing .06em. +2. **Inviter line** — inviter avatar (initials circle, ~28px) + "**Carlos Scheidegger** invited you to" (collection) / "invited you to edit" (document). Inviter name bold, rest regular, ~13.5px. +3. **Title** — collection or project name. ~22–24px bold headline. +4. **Payload preview** (bordered rounded box, display-only data — differs by type): + - *Collection*: list of up to ~3 projects, each row = project name (13px semibold) + top files/file-count line in mono (`report.qmd · 12 files`, ~11.5px) + contributor initials facepile (overlapping ~19px circles). Final row: `+ N more projects · Carlos, Jenny and Mine work here`. + - *Document*: document thumbnail placeholder (~74px tall, subtle line texture) with a mono filename chip (`report.qmd`) bottom-left, then one row: mono file summary (`figures/ · data.csv · 12 files`) + contributor facepile. +5. **Explainer block** — light tinted rounded box: Quarto logo (circle with 4 quadrants; use the app's real `quarto` icon asset) + "**New to Quarto Hub?** It's where teams write Quarto documents together — live, in the browser. Nothing to install." (~11.5px, bold lead-in.) +6. **CTA — single button, last element, nothing below it.** + - Signed **out**: white/outlined button with Google logo — "Continue with Google to join" (collection) / "Continue with Google to open" (document). + - Signed **in**: primary filled button — "Join and open Quarterly report" (collection, using the start-here project name; if no start target, "Join Team docs") / "Open Quarterly report" (document). + +Do NOT render: name input, cursor-color picker, "anyone with this link" warnings, or any explanation of the signed-in variant. + +## Interactions & Behavior +- **Signed-out**: invite routes render InviteLanding (not the generic LoginScreen). Google CTA triggers the existing auth flow; the existing pre-auth hash save/restore returns the user to the same invite URL, now showing the signed-in variant with the user's Google name/avatar ("Joining as **Amy Mora**" may appear near the CTA in the signed-in state — see turn-2 step-2 mock). +- **Collection join CTA**: performs today's join (subscribe to collection) with identity from the Google account (name + avatar); no identity form. Then navigate to the `start` target (project + file) if present and resolvable after sync; else home with the joined collection visible. +- **Document open CTA**: connection to the shared project happens **on CTA click**, not on route load (today `#/share/` connects silently). Then open the editor on the shared file. +- **Editor welcome banner** (one-time, dismissible, persist dismissal): tinted bar under the toolbar — "Welcome to **Team docs** — Carlos suggested starting here. You're editing live as **Amy Mora**." + a "Change name" outline button (opens existing rename affordance) + × to dismiss. Document-invite variant: "**Carlos** shared this document with you. You're editing live as …". +- **Legacy URLs** (no new params): must keep working — render the landing without the preview block and with generic CTA text ("Join collection" / "Open document"). +- Loading: if preview payload absent/invalid, skip the payload block gracefully. Broken share links keep today's error handling but styled within the landing card. + +## URL / Data Changes (`routing.ts`) +- `share` route gains: `from=` (inviter display name) and display-only `preview=` (filename shown in thumb chip, top files, file count, contributor initials). +- `join-collection` route gains: `preview=` (up to ~3 projects × {name, top files, file count, contributor initials} + total count + member first-names) and `start=` (project + file to open after join). +- Preview payloads are **display-only** — never include doc ids or anything that grants access. Cap size for URL length (~3 projects × 2 files). Encode compactly (e.g. base64url JSON). +- All params optional; parsers must accept legacy links. + +## Sender side +- `ProjectsHome.tsx` — `buildInviteUrl` embeds `preview=` from the already-cached peek summaries (top files + contributor facepiles) and an optional start-here pick. +- `ShareDialog.tsx` / `buildShareableUrl` — embed `from=` + `preview=`. +- (Sender UI redesign is not in this PR; only the URL builders change.) + +## Component Plan +- **New `InviteLanding.tsx`** — replaces `JoinCollectionLanding.tsx`; one layout, props: `kind: 'collection' | 'document'`, inviter, title, preview payload, signedIn, onCta. Kicker text, payload renderer, and CTA verb switch on `kind`. +- **`App.tsx`** — invite routes render InviteLanding when signed out (skip LoginScreen); share route stops auto-connecting; post-join navigation to `start` target. +- **`JoinCollectionLanding.tsx`** — removed/absorbed (name + cursor-color form deleted; identity comes from the Google account, falling back to the existing identity service defaults). +- **Editor** — one-time welcome banner component. + +## State Management +- `signedIn` from existing auth context. +- Parsed invite params (kind, inviter, title, preview, start) from the route. +- `joinState`: idle → joining/connecting → navigating; CTA shows a busy state while joining. +- Banner dismissal persisted (per collection/project id) in the same storage used for similar one-time UI. + +## Design Tokens (approximations used in mocks — prefer hub-client equivalents) +- Card border `#A2B8CB`, inner dividers `#D1DBE5`, radius 16px card / 12px payload box / 8px buttons. +- Text: near-black `#17212B` (titles), muted blue-gray `#305775` (secondary), accent teal `#2E6E71` (kicker, primary button bg), link/brand blue `#447099`. +- Explainer tint `rgba(68,112,153,.08)`. +- Mono for file names/summaries (the app's existing mono, JetBrains Mono in mocks). +- Card shadow `0 20px 60px rgba(0,0,0,.15)`. + +## Assets +- Quarto logo: use the app's existing quarto icon (the mocks draw a 4-quadrant circle approximation — do not ship that). +- Google logo: existing sign-in asset. +- Avatars: initials circles with the existing user-color assignment. + +## Tests +- InviteLanding renders both kinds, signed-in and signed-out. +- Legacy invite/share URLs (no `preview`/`from`/`start`) render and function. +- Collection join → lands on `start` target; no `start` → home. +- Document CTA → connect happens on click, then editor opens on the file. +- Welcome banner shows once, dismissal persists. +- Dev-harness pages for both landing variants. + +## Files in this bundle +- `QuartoHub Onboarding.dc.html` — design canvas. Section **3a** = the authoritative mocks (note: later chat edits removed the footnote lines under the CTAs; CTA is the final element). Section 2a = the three-step flow context (signed-out → back-from-Google → editor banner). Section 1a = current-state map of all entry paths with pain points. +- `screenshots/3a-unified-invite-landings.png` — the two authoritative invite cards (collection + document) with annotations. +- `screenshots/2a-invite-flow-steps.png` — full flow: signed-out landing → back from Google → editor with welcome banner. diff --git a/design_handoff_invite_landing/screenshots/2a-invite-flow-steps.png b/design_handoff_invite_landing/screenshots/2a-invite-flow-steps.png new file mode 100644 index 000000000..5a93caf5d Binary files /dev/null and b/design_handoff_invite_landing/screenshots/2a-invite-flow-steps.png differ diff --git a/design_handoff_invite_landing/screenshots/3a-unified-invite-landings.png b/design_handoff_invite_landing/screenshots/3a-unified-invite-landings.png new file mode 100644 index 000000000..d51bcc632 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/3a-unified-invite-landings.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-in-dark.png b/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-in-dark.png new file mode 100644 index 000000000..3fb82dc94 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-in-dark.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-in.png b/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-in.png new file mode 100644 index 000000000..0959df2a0 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-in.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-out-dark.png b/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-out-dark.png new file mode 100644 index 000000000..3d28a7946 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-out-dark.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-out.png b/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-out.png new file mode 100644 index 000000000..6a877ae71 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/collection-invite-signed-out.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-in-dark.png b/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-in-dark.png new file mode 100644 index 000000000..69d3a8501 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-in-dark.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-in.png b/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-in.png new file mode 100644 index 000000000..0b9a2ca92 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-in.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-out-dark.png b/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-out-dark.png new file mode 100644 index 000000000..968f18b90 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-out-dark.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-out.png b/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-out.png new file mode 100644 index 000000000..7b93fdbc1 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/project-invite-signed-out.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/project-invite-single-file-dark.png b/design_handoff_invite_landing/screenshots/implemented/project-invite-single-file-dark.png new file mode 100644 index 000000000..9bcfec3e1 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/project-invite-single-file-dark.png differ diff --git a/design_handoff_invite_landing/screenshots/implemented/project-invite-single-file.png b/design_handoff_invite_landing/screenshots/implemented/project-invite-single-file.png new file mode 100644 index 000000000..eda8721f8 Binary files /dev/null and b/design_handoff_invite_landing/screenshots/implemented/project-invite-single-file.png differ diff --git a/design_handoff_invite_landing/support.js b/design_handoff_invite_landing/support.js new file mode 100644 index 000000000..cb009b69e --- /dev/null +++ b/design_handoff_invite_landing/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: