Skip to content

cards demo: share a card as a link whose preview is the card - #1016

Merged
patcapulong merged 100 commits into
mainfrom
pat/cards-export-share
Sep 17, 2026
Merged

patcapulong merged 100 commits into
mainfrom
pat/cards-export-share

Conversation

@patcapulong

@patcapulong patcapulong commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

What changed

The cards playground gets a Share panel. The card parks in a frame on the stage, renders to a still in the browser, and gets a link at cards.lightspark.com/c/{slug} whose preview is that card.

  • Share panel, in the stage under the card: Copy link, Share on X (opens the composer with the link), Save image, Save video (a five second spin, encoded in the browser with WebCodecs).
  • Style row: Template (the card on the Figma share template, with a dot grid in the middle column) or Hand (the card in one of five photographed hands; a Skin row picks). Backdrops: Light, Dark, Brand (the card's color, or the dominant color of its art), or a custom color.
  • Share page at /c/{slug}: a live 3D card the visitor can turn, with Design yours and View in Playground going to docs.lightspark.com/cards/demo. Crawlers get og:image pointing at the rendered still (2400 x 1256), and the title {Brand} Card — Lightspark Cards Playground.
  • Storage: Vercel Blob for the stills, video, and uploaded logo and art; Upstash Redis for the records. Both already connected to the grid-cards-demo project with per-environment prefixes (cards/preview/..., cards/production/...). Locally the store is the filesystem (.shares/, gitignored).
  • API routes under /api/shares: create, get, patch (with an edit token), file upload, view count, slug check. Rate limits per IP: 40 creates and 400 uploads per hour. Uploads are capped at 4 MB per file, enforced while the body streams; JSON bodies at 64 KB. Uploaded rasters are downscaled to 2048 px and re-encoded as WebP on the client before upload.
  • A share stores only image URLs of its own (root-relative presets, or files in this deployment's Blob store); anything else is dropped. Assets are set only by the upload route.
  • cards.lightspark.com/ redirects to docs.lightspark.com/cards/demo (the iframe's ?embed=true load is exempt).
  • Card numbers, expiry, and CVV are generated on each issue (Luhn-valid Visa PAN) instead of hardcoded.
  • Docs embed snippet: the iframe passes c and edit through from the docs page URL, and allow gains web-share.

Dot grid lattice math moved to src/lib/dotLattice.ts, shared by the stage backdrop and the share template.

Infra state

  • cards.lightspark.com CNAME added by PE (ops#4134), attached to the grid-cards-demo Vercel project, certificate issued.
  • Production env: NEXT_PUBLIC_SHARE_ORIGIN=https://cards.lightspark.com/c. Preview deployments link to their own host and to the matching Mintlify preview.

Test plan

  • node scripts/share-smoke.mjs chromium|webkit <url> against the branch's Vercel preview: both pass (template and hand alignment, five hand swaps, backdrops, custom color drag, poses, Save image download, Save video start and cancel, Copy link creating a share, a flow dismissing the panel).
  • Created a share on the preview: POST /api/shares and both file uploads returned 200, the files landed under cards/preview/, the crawler view of /c/{slug} carries the correct og:* and twitter:* tags, and the og:image on Blob is 2400 x 1256 WebP.
  • Share page loaded on desktop Chromium and mobile WebKit with no console errors; both buttons resolve to the docs URL with ?c={slug}.
  • Bugbot ran on the branch earlier; findings addressed. Greptile's three findings (partial updates, late upload cap, external image URLs) fixed in 57c579c and verified locally with curl: 413 on a 9 MB declared length and on a streamed 5 MB body; external and data: image URLs nulled on create and patch; an update issues GET, uploads, then PATCH last.
  • Docs preview (ramps-pat-cards-export-share.mintlify.site/cards/demo?c=…) loads the shared design in the iframe (brand and color restored).
  • After merge: confirm the / redirect on cards.lightspark.com, create a share from docs.lightspark.com/cards/demo, and post it to X to see the card.

patcapulong and others added 30 commits September 16, 2026 19:10
Share (under the floating card, and beside Reset on the Design divider)
opens a sheet with the picture the link will show, a format (Post, Square,
Card only), and a backdrop. Copy link makes the share: the record first,
then the stills, then a 5 s spin video at 1080p60 in the background.

- CardExporter renders the stage's own mesh and studio through an export
  camera into offscreen targets, borrowing the WebGL context between
  frames; OutputPass tone maps; HoloDove exposes its view update.
- Spin video: fixed-step frames, WebCodecs H.264, mp4-muxer.
- ShareStore interface with a local folder store (.shares/, gitignored);
  route handlers under /api/shares; team gate for named (pitch) shares,
  edit tokens, view counts.
- Share page at /c/[slug] with server-rendered og/twitter meta, og:video
  for iMessage, Open in the playground; ?preview=unfurl mocks X, Slack,
  and iMessage.
- ?c= deep link loads a shared design; the docs embed forwards c and edit
  and allows web-share; painters load images with CORS.

Phase 1 of the plan: local only, no Vercel store, no website rewrite yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ialog

The still and the video now sit on the og-image template (light, dark,
and a Brand surface from the card's color or its art). The sheet is
Origin's Dialog with the square preview, the sidebar's rows for Backdrop
and Link, and Origin buttons: Copy link, Post to X, Download image,
Download video. Header, close control, format tabs, the customer option,
and the idle status line are gone.

Co-authored-by: Cursor <cursoragent@cursor.com>
… tile actions

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Inside a dialog scaling in, the screen rects are all smaller alike; the
ring was placed from them and landed short. Divide by the row's scale
and read the row once more after the entrance.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…er is Reset alone again

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The dialog goes. Share brings a panel up in the middle of the stage: the
share template in DOM with an empty slot, which the one card flies into
and is posed in; the Backdrop and Pose rows and the tiles stagger in
under it as the panel grows. The card is turned by hand right there
(CardMotion.free: a release stops at any angle) or from the Pose row
(setPose). Stills render from the live pose. The Share button reads Close
while the panel is up; closing flies the card back.

Co-authored-by: Cursor <cursoragent@cursor.com>
The controls were mounting one after another under Motion layout
animations, each reflowing the panel and re-centering it: a jump per
arrival. Now the panel is anchored where its grown height will sit, one
height animation opens the room downward, and the always-mounted rows
and tiles rise in inside the clip on their own delays. The stage button
morphs its label (torph) and turns its glyph from the share arrow to the
cross.

Co-authored-by: Cursor <cursoragent@cursor.com>
… is done

Co-authored-by: Cursor <cursoragent@cursor.com>
…wn work

The output target was an sRGB framebuffer, so the pass's sRGB values
were encoded again on write and every export came out lifted (a black
card gray). Plain bytes now; the export matches the stage pixel for
pixel. The progress bar and status lines go: a working tile turns its
glyph into a spinner and morphs its label (Making link…, Rendering…);
only errors are written out.

Co-authored-by: Cursor <cursoragent@cursor.com>
…g with it

Co-authored-by: Cursor <cursoragent@cursor.com>
…; Share button after the intro

Save video renders on its own (the link, if any, gets the same file
attached later, or on Copy link without a second render). Save image
and Save video morph to Saved with a check for a moment. A tile's glyph
changes through a blur and a scale rather than a cut. Cancel aborts a
render and forgets the ask. The rows and tiles come down into place.
The stage's Share button arrives out of a blur once the intro has played.

Co-authored-by: Cursor <cursoragent@cursor.com>
…no hover tilt in the frame

Co-authored-by: Cursor <cursoragent@cursor.com>
…the Share button and sinks back

Motion read 'auto' once at the start of the growth and the content
settled a pixel or two after (fonts, the swatch ring), so the end of the
animation was a jump. The target is the content's layout height now,
kept current by a ResizeObserver. The panel enters scaled about its
bottom edge and risen 36px, and leaves the same way, toward the button.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Sep 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
grid-cards-demo Ready Ready Preview Sep 17, 2026 3:41am UTC
2 Skipped Deployments
Project Deployment Actions Updated
grid-flow-builder Ignored Ignored Preview Sep 17, 2026 3:41am UTC
grid-wallet-demo Ignored Ignored Preview Sep 17, 2026 3:41am UTC

Request Review

@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no outstanding correctness or repository-rule violations remain.

Summary

This PR adds end-to-end card sharing to the cards playground.

  • Adds browser-rendered previews, image and video exports, share styles, hand templates, and backdrop controls.
  • Adds persistent share records, managed asset uploads, edit-token authorization, rate limits, and public share pages with social metadata.
  • Adds docs iframe propagation and share-domain routing.
  • The latest change fixes the outstanding concurrent-update race by atomically reading each Redis record with its corresponding version before the conditional write.
  • All previous Greptile threads are resolved, and the current code addresses the previously reported stale-record race.
Diagram
sequenceDiagram
    participant User as Playground user
    participant Client as Share client
    participant API as Share API
    participant Redis as Upstash Redis
    participant Blob as Vercel Blob
    participant Visitor as Share visitor

    User->>Client: Copy link / share
    Client->>API: Create share record
    API->>Redis: Store record and edit-token hash
    Redis-->>API: Share ID and slug
    API-->>Client: Record and edit token
    Client->>Client: Render card previews
    Client->>API: Upload managed assets
    API->>Blob: Store immutable preview files
    Blob-->>API: Asset URLs
    Client->>API: Publish final record patch
    API->>Redis: MGET record + version
    API->>Redis: Conditional versioned write
    Visitor->>API: "Open /c/{slug}"
    API->>Redis: Read share record
    API-->>Visitor: Interactive card and social metadata
Loading

Reviews (5) · Last reviewed commit: "cards demo share: read the record and it..."

Comment thread components/grid-cards-demo/src/lib/share/client.ts
Comment thread components/grid-cards-demo/src/app/api/shares/[id]/files/route.ts Outdated
Comment thread components/grid-cards-demo/src/lib/share/http.ts Outdated
Mintlify's snippet processing fails to parse the export when the block
carries // comments (Could not parse expression with acorn), and the
component then renders nothing. The passthrough is written plainly, with
no comments in the file.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ped as they stream, updates publish last

- cleanDesign nulls a logo or art source that is not a root-relative path
  or a file in this deployment's Blob store (BLOB_STORE_ID), so a share
  cannot point visitors' browsers at another server.
- PATCH /api/shares/{id} no longer accepts assets; only the upload route
  sets them. clearAsset, which used that, is gone.
- The upload route refuses a declared length over the role's cap before
  reading, and cuts a stream off as it crosses it. JSON bodies are capped
  at 64 KB.
- The video role is removed: nothing uploads a video (attachVideo was
  unused), and 40 MB could not pass a route handler anyway.
- Updating a share uploads the files and stills first and patches the
  record last, so a failure part way leaves the public link as it was.

Co-authored-by: Cursor <cursoragent@cursor.com>
@patcapulong

Copy link
Copy Markdown
Contributor Author

@greptileai review the latest commits: the three findings are addressed in 57c579c (owned-only image URLs, streamed upload cap, record patched last on update) and the docs embed snippet changed in fabeedc.

…ther

The upload route only stores a file and returns its URL. The client
uploads the brand images and both stills, then publishes the design and
every URL in a single PATCH (URLs checked to be ours). A failure part way
leaves the public record as it was. The Vercel store deletes replaced
files when the record moves off them, not at upload time.

Co-authored-by: Cursor <cursoragent@cursor.com>
@patcapulong

Copy link
Copy Markdown
Contributor Author

@greptileai the update is now atomic (7a7f365): uploads only store files, and a single PATCH publishes the design and all asset URLs together. Replaced Blob files are deleted when the record moves off them.

Comment thread components/grid-cards-demo/src/app/api/shares/[id]/route.ts Outdated
Comment thread components/grid-cards-demo/src/lib/share/vercelStore.ts Outdated
…s are conditional writes

- ownedFileUrl binds a URL to the share being patched: the local store's
  /api/shares/{id}/files/ or the Blob store's cards/{env}/{id}/{role}-…;
  a share being created (no files yet) may only reference presets. So a
  share cannot adopt another share's file and have the store delete it.
- The client re-uploads a stored image it does not own (a design opened
  from someone's link and shared afresh) instead of referencing it.
- VercelStore.update writes through a Lua script conditional on the
  record's version (cards:{env}:ver:{id}) and retries a lost race with
  backoff, so two tabs publishing at once cannot leave the record pointing
  at a file the other transition deleted. Deletion is limited to files
  under the share's own Blob prefix.

Co-authored-by: Cursor <cursoragent@cursor.com>
@patcapulong

Copy link
Copy Markdown
Contributor Author

@greptileai 4132f8b: asset and design URLs are now bound to the authenticated share's own path (another share's file is refused with 400), deletion only touches files under the share's own prefix, and the record write is a version-conditional Lua script with retry, so concurrent publishes serialize. Verified locally: 20 concurrent PATCHes leave a consistent record.

Comment thread components/grid-cards-demo/src/lib/share/vercelStore.ts Outdated
…the conditional write

Co-authored-by: Cursor <cursoragent@cursor.com>
@patcapulong

Copy link
Copy Markdown
Contributor Author

@greptileai a823473: the record and its version are now read in a single MGET, so the version checked by the Lua write is the one the record was read at.

@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Confirmed. update() now reads rec and ver with one Redis MGET, so the version passed to WRITE_IF_VERSION corresponds to the record snapshot used to apply the patch. The remaining read-to-write race is correctly handled by the Lua compare-and-set and retry loop: a competing publish causes the write to fail, then the latest record is re-read and the patch is reapplied. This resolves the stale-record/version mismatch finding; no further concern on this issue.

@patcapulong

Copy link
Copy Markdown
Contributor Author

@greptileai review

1 similar comment
@patcapulong

Copy link
Copy Markdown
Contributor Author

@greptileai review

@patcapulong
patcapulong enabled auto-merge (squash) September 17, 2026 04:01
@patcapulong
patcapulong merged commit 8029cf9 into main Sep 17, 2026
12 checks passed
@patcapulong
patcapulong deleted the pat/cards-export-share branch September 17, 2026 04:04
patcapulong added a commit that referenced this pull request Sep 17, 2026
…on dark systems (#1017)

## What changed

Three fixes for the cards share page, found after #1016 went live.

- The card rendered nearly black on iOS. The studio environment maps
were `Float32` `DataTexture`s, and iOS Safari has no
`OES_texture_float_linear`, so the PMREM could not filter them and
sampled black; the card was lit by the one directional lamp alone. The
maps are now uploaded as half floats (`DataUtils.toHalfFloat`), which
every WebGL2 implementation filters. The desktop picture is unchanged.
- Grey bands above and below the page. Mobile Safari colors its status
bar and tab bar from the document background, which was the app body's
surface (`#1a1a1a` in dark) rather than the page's (`#111111`). The
share page's `html` and `body` now take its surface, and the page
exports its own `themeColor` (the root layout's `theme-color` metas
moved to a `viewport` export so the page-level one replaces them).

- White flash on a dark system (desktop). The page's surface and ink
were inline styles from a React state that started light and flipped in
an effect after hydration. They are now CSS custom properties keyed on
`html[data-theme]`, which the root layout's boot script sets before the
first paint, so the server's HTML paints in the right colors. The GL
reads the theme through `useSyncExternalStore` (client snapshot during
hydration, re-rendered before paint), and follows a theme switch.

## Test plan

- iOS 27 Simulator (iPhone 18 Pro), Safari, against the local dev
server: the green Buchi card is lit and legible in dark mode; status and
tab bar areas sample as the page color in both light (`#f8f8f7`) and
dark (`#111111`).
- Desktop Chromium: the playground stage in light and dark looks as
before the half-float change.
- CDP screencast of the page load in headless Chromium with
`colorScheme: dark`: production shows a light frame before hydration;
the branch shows only dark frames. SSR HTML carries no inline surface
style.
- `curl` of `/` and `/c/{slug}` shows the app's and the page's
`theme-color` tags respectively, no duplicates.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants