Skip to content

Commit 9a0c47d

Browse files
Save the card. Post to X.
The PNG is a real download, not a share sheet. X, Threads, and Facebook take the walk.
1 parent 430a455 commit 9a0c47d

20 files changed

Lines changed: 326 additions & 129 deletions

HOW-TO-PLAY.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Live booth: [first-bucket-studios.vercel.app](https://first-bucket-studios.verce
1010
1. **Rip the pack.** A franchise, an era, and a luck lane land. New every time.
1111
2. **Tear the foil.** Ten cards flip. Tap five.
1212
3. **Lock five.** Eighty-two nights walk from those names. The number on the card is that walk.
13-
4. **Send the card.** Save the PNG. Copy the walk. The URL is the certificate.
13+
4. **Send the card.** Save the PNG. Post to X. Copy the walk. The URL is the certificate.
1414

1515
![Home](docs/stills/01-home.jpg)
1616

@@ -29,9 +29,11 @@ One pull on tape: [docs/tape/demo.mp4](docs/tape/demo.mp4)
2929
| **Rip the pack** | Deals a new room. New ten. |
3030
| **Rip pack** / tap the foil | Tears it. The ten turn. |
3131
| **Lock five** | Walks 82 nights. |
32-
| **Send the card** | Opens the tray. |
3332
| **Save the card** | Downloads `first-bucket-{team}-{wins}.png`. |
34-
| **Copy the walk** | Copies `/walk/v1.…` so you can paste it. |
33+
| **Post to X** | Opens X with the walk. Attach the PNG if you want the plate in the post. |
34+
| **Threads / Facebook** | In **More ways to send**. Same walk URL. |
35+
| **More ways to send** | Opens the tray: story frame, native share, copy. |
36+
| **Copy the walk** | Copies the public walk URL so you can paste it. |
3537
| **Open the walk** | Same five, same nights, forever. |
3638

3739
There is no “copy image.” The file is the card. The URL is the walk.

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Ninety seconds. [HOW-TO-PLAY.md](HOW-TO-PLAY.md) is the sheet.
3030

3131
![The card](docs/stills/03-result.jpg)
3232

33-
**4. Send the card.** Save the PNG. Copy the walk. The URL is the certificate.
33+
**4. Send the card.** Save the PNG. Post to X. Copy the walk. The URL is the certificate.
3434

3535
![Send](docs/stills/04-tray.jpg)
3636

@@ -40,9 +40,10 @@ Tape: [docs/tape/demo.mp4](docs/tape/demo.mp4)
4040
| --- | --- |
4141
| **Rip the pack** | New room. New ten. |
4242
| **Lock five** | 82 nights from those names. |
43-
| **Send the card** | Opens the tray. |
4443
| **Save the card** | `first-bucket-{team}-{wins}.png` |
45-
| **Copy the walk** | `/walk/v1.…` — paste it anywhere. |
44+
| **Post to X** | Opens X with the walk. Attach the PNG for the plate. |
45+
| **More ways to send** | Tray: story frame, Threads, Facebook, native share. |
46+
| **Copy the walk** | Public `/walk/v1.…` — paste it anywhere. |
4647
| **Open the walk** | Same five, same nights, forever. |
4748

4849
There is no “copy image.” The file is the card. The URL is the walk.

SUPPORT.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ Do not paste secrets. Walks are public certificates; they are safe to share.
1818

1919
## Save did not download
2020

21-
Save is a native file link. A nested preview can swallow it. Open the walk in a top-level tab, then Save. Copy the walk still works in the frame.
21+
Save is a native file link (`<a download>` with a PNG data URL). A nested preview can swallow it. Open the live booth in a top-level tab, then Save. Copy the walk still works in the frame.
22+
23+
## Share on social did nothing
24+
25+
**Post to X** is a real X intent. It posts the walk URL. The PNG is a separate file — Save it, then attach it on X if you want the plate in the post. Instagram has no web share target; pick **9:16 studio** in More ways to send, Save, then upload.
2226

2327
## Every pack is Thunder 51
2428

docs/ANALYTICS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Product analytics live in [`src/lib/demo-funnel.ts`](../src/lib/demo-funnel.ts).
1616
| `foil` | Tear the foil | Second rip confused them. |
1717
| `lock` | Five names locked | Draft tax. |
1818
| `card` | Number on the lithograph | Walk never finished. |
19-
| `tray` | Send the card | They liked the poster and stalled. |
20-
| `save` | Save the card | File is the souvenir. |
19+
| `tray` | More ways to send | They opened the tray. |
20+
| `save` | Save the card / Post to X | File or social. |
2121
| `copy` | Copy the walk | URL is the certificate. |
2222
| `open` | Open the walk | Revisit. Optional. |
2323

docs/TESTING.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ GitHub Action **Pin** runs layers 1 and 3 on every push to `main`. Layer 4 needs
2424
| Live 82-0 pack | Ready | Random franchise. Tear. Ten plates. Not Thunder every time. |
2525
| Player cards | Ready | One 5×7 face. Night plate. Pick five. Court back only when face-down. |
2626
| House pin | Ready | Thunder 51–31, same walk every time. Optional. |
27-
| Send PNG | Ready in a top-level tab | Tray: Save the card / Copy the walk / Open the walk. |
27+
| Send PNG | Ready in a top-level tab | **Save the card** is a real `<a download>`. **Post to X** is `x.com/intent/tweet`. |
28+
| Social | Ready | X on the card. Threads + Facebook + native share in the tray. |
2829
| Pin tests | Must stay green | `npm run test:pin` then `npm run test:vl` |
2930
| Public URL | Ready | [first-bucket-studios.vercel.app](https://first-bucket-studios.vercel.app). GitHub stays private. Domain later. |
3031

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"typecheck": "tsc --noEmit",
2020
"check:auth": "node scripts/check-auth-invariant.mjs",
2121
"test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/auth/gate-identity.test.ts",
22-
"test:pin": "node --experimental-strip-types --test src/lib/house-pack.test.ts src/lib/nba.test.ts src/lib/walk.test.ts src/lib/demo-funnel.test.ts src/lib/stream-json.test.ts src/lib/foil-gl.test.ts",
22+
"test:pin": "node --experimental-strip-types --test src/lib/house-pack.test.ts src/lib/nba.test.ts src/lib/walk.test.ts src/lib/demo-funnel.test.ts src/lib/stream-json.test.ts src/lib/foil-gl.test.ts src/lib/deliver.test.ts",
2323
"test:demo": "node scripts/demo-eval.mjs",
2424
"test:vl": "node scripts/vl-bench.mjs",
2525
"capture": "node scripts/capture-demo.mjs",

scripts/capture-demo.mjs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ for (let i = 0; i < Math.min(5, n); i++) {
6363
}
6464

6565
await page.getByRole("button", { name: "Lock five" }).click();
66-
await page.waitForSelector("text=Send the card");
66+
await page.waitForSelector("text=Save the card");
67+
await page.waitForSelector("text=Post to X");
6768
await page.waitForTimeout(400);
6869
await shot("03-result.jpg");
6970

70-
await page.getByRole("button", { name: "Send the card" }).click();
71-
await page.waitForSelector("text=Save the card");
71+
await page.getByRole("button", { name: "More ways to send" }).click();
72+
await page.waitForSelector("text=Save the PNG");
7273
await page.waitForTimeout(400);
7374
await shot("04-tray.jpg");
7475

scripts/demo-eval.mjs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,23 @@ try {
9191
if ((await card.getAttribute("aria-pressed")) !== "true") await card.click();
9292
}
9393
await page.getByRole("button", { name: "Lock five" }).click();
94-
await page.getByRole("button", { name: "Send the card" }).waitFor();
94+
await page.getByRole("link", { name: "Save the card" }).waitFor();
95+
await page.getByRole("link", { name: "Post to X" }).waitFor();
9596
await flushBeats();
9697
const walk = (await page.locator("text=/\\/walk\\/v1\\./").first().textContent()) ?? "";
9798
const id = walk.replace(/^[\s\S]*\/walk\//, "").trim();
9899
if (!id.startsWith("v1.")) throw new Error(`no walk on the card: ${walk}`);
99100
if (id === HOUSE) throw new Error("live pack collapsed to the house pin");
100101
emit({ type: "walk", id, live: true });
101-
await page.getByRole("button", { name: "Send the card" }).click();
102-
await page.getByRole("link", { name: "Save the card" }).waitFor();
102+
const save = page.getByRole("link", { name: "Save the card" }).first();
103+
const href = await save.getAttribute("href");
104+
if (!href?.startsWith("data:image/png")) throw new Error(`save is not a png data url: ${href?.slice(0, 40)}`);
105+
if (!(await save.getAttribute("download"))?.endsWith(".png")) throw new Error("save has no png download name");
106+
const post = page.getByRole("link", { name: "Post to X" });
107+
const tweet = await post.getAttribute("href");
108+
if (!tweet?.includes("x.com/intent/tweet")) throw new Error(`post to x is not an intent: ${tweet}`);
109+
await page.getByRole("button", { name: "More ways to send" }).click();
110+
await page.getByRole("link", { name: "Save the card" }).nth(1).waitFor();
103111
await page.getByRole("button", { name: "Copy the walk" }).click();
104112
await page.waitForTimeout(250);
105113
const demo = await flushBeats();

src/components/eighty-two.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import { seasonTelemetry } from "@/lib/telemetry";
4343
import { seasonWalk, wnbaWalk, type Night } from "@/lib/sim";
4444
import { bestFrom, justFiled, loadSave, needsExportNag, recordRun, todayKey, yesterdayKey } from "@/lib/studio-save";
4545
import { ripTick } from "@/lib/tick";
46-
import { dailyRoom, encodeChallengeIds, encodeWalk, encodeWnbaWalk, houseWalk, playersOf, walkUrl } from "@/lib/walk";
46+
import { dailyRoom, encodeChallengeIds, encodeWalk, encodeWnbaWalk, houseWalk, playersOf, publicWalkUrl } from "@/lib/walk";
4747
import { cn } from "@/lib/utils";
4848

4949
type Mode = "82-0" | "daily" | "corners" | "wnba";
@@ -317,7 +317,7 @@ export function EightyTwo({ mode, challenge }: { mode: Mode; challenge?: Challen
317317
}
318318

319319
async function copyWalk() {
320-
const url = walkUrl(walkId);
320+
const url = publicWalkUrl(walkId);
321321
const ok = await copyText(url);
322322
setWalkCopied(ok);
323323
if (ok) markDemo("copy", walkId);

src/components/file-tray.tsx

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@ import { useNavigate } from "@tanstack/react-router";
33
import { X } from "lucide-react";
44
import {
55
copyText,
6+
facebookIntent,
67
nativeShare,
78
objectUrl,
89
registerProofOpener,
910
shareSheetOk,
11+
threadsIntent,
12+
tweetIntent,
1013
type Proof,
1114
} from "@/lib/deliver";
1215
import { ASPECTS, lastShareOpts, renderShareCard, type CardAspect } from "@/lib/share-card";
1316
import { rememberWalk } from "@/lib/studio-save";
1417
import { markDemo } from "@/lib/demo-funnel";
15-
import { walkHref, walkIdFromHref } from "@/lib/walk";
18+
import { publicWalkUrl, walkHref, walkIdFromHref } from "@/lib/walk";
1619
import { useSpecular } from "@/lib/hooks";
1720
import { toast } from "@/components/toast-host";
1821
import { cn } from "@/lib/utils";
@@ -32,7 +35,7 @@ function FileTray({ proof, onClose }: { proof: Proof; onClose: () => void }) {
3235
const sheetRef = useRef<HTMLDivElement>(null);
3336
const navigate = useNavigate();
3437
useSpecular(sheetRef);
35-
const [url, setUrl] = useState("");
38+
const [url, setUrl] = useState(() => objectUrl(proof.blob));
3639
const [saveHref, setSaveHref] = useState("");
3740
const [preview, setPreview] = useState("");
3841
const [aspect, setAspect] = useState<CardAspect>("plate");
@@ -42,10 +45,18 @@ function FileTray({ proof, onClose }: { proof: Proof; onClose: () => void }) {
4245
const canShare = shareSheetOk();
4346
const walkId = proof.href ? walkIdFromHref(proof.href) : "";
4447
const walkPath = walkId ? walkHref(walkId) : proof.href || "";
48+
const shareHref = walkId ? publicWalkUrl(walkId) : "";
49+
const postText = (proof.caption || "First Bucket Studio")
50+
.replace(/\shttps?:\/\/\S+$/, "")
51+
.replace(/\s\/walk\/\S+$/, "")
52+
.slice(0, 180);
4553

4654
useEffect(() => {
4755
const next = objectUrl(proof.blob);
48-
setUrl(next);
56+
setUrl((prev) => {
57+
if (prev && prev !== next) URL.revokeObjectURL(prev);
58+
return next;
59+
});
4960
setSheet(proof.blob);
5061
if (proof.kind === "json" || proof.kind === "file") {
5162
void proof.blob.text().then((text) => setPreview(text.slice(0, 2400)));
@@ -74,8 +85,9 @@ function FileTray({ proof, onClose }: { proof: Proof; onClose: () => void }) {
7485

7586
async function onCopyWalk() {
7687
if (!walkPath) return;
77-
markDemo("copy", walkPath);
78-
const ok = await copyText(walkPath);
88+
const copied = shareHref || walkPath;
89+
markDemo("copy", copied);
90+
const ok = await copyText(copied);
7991
setNote(ok ? "Walk copied." : "Select the walk and copy it.");
8092
if (ok) toast("Walk copied.");
8193
field.current?.select();
@@ -191,7 +203,7 @@ function FileTray({ proof, onClose }: { proof: Proof; onClose: () => void }) {
191203
<input
192204
ref={field}
193205
readOnly
194-
value={walkPath}
206+
value={shareHref || walkPath}
195207
onFocus={(event) => event.currentTarget.select()}
196208
className="mt-1 w-full min-h-11 rounded-md glass-control px-3 font-mono text-xs text-fg"
197209
/>
@@ -202,6 +214,10 @@ function FileTray({ proof, onClose }: { proof: Proof; onClose: () => void }) {
202214

203215
{note ? <p className="mt-3 text-sm text-fg">{note}</p> : null}
204216

217+
<p className="v-secondary mt-4 text-sm">
218+
Save the PNG. Post the walk on X. Story frame is for Instagram — save, then upload.
219+
</p>
220+
205221
<div className="tray-actions mt-5">
206222
{fileHref ? (
207223
<a
@@ -215,14 +231,47 @@ function FileTray({ proof, onClose }: { proof: Proof; onClose: () => void }) {
215231
) : (
216232
<span className="tray-btn tray-btn-primary opacity-40">Save the card</span>
217233
)}
234+
{shareHref ? (
235+
<a
236+
className="tray-btn tray-btn-primary"
237+
href={tweetIntent(postText, shareHref)}
238+
target="_blank"
239+
rel="noopener noreferrer"
240+
onClick={() => markDemo("save", "x")}
241+
>
242+
Post to X
243+
</a>
244+
) : null}
218245
{walkPath ? (
219-
<button type="button" className="tray-btn tray-btn-primary" onClick={() => void onCopyWalk()}>
246+
<button type="button" className="tray-btn" onClick={() => void onCopyWalk()}>
220247
Copy the walk
221248
</button>
222249
) : null}
250+
{shareHref ? (
251+
<a
252+
className="tray-btn"
253+
href={threadsIntent(postText, shareHref)}
254+
target="_blank"
255+
rel="noopener noreferrer"
256+
onClick={() => markDemo("save", "threads")}
257+
>
258+
Threads
259+
</a>
260+
) : null}
261+
{shareHref ? (
262+
<a
263+
className="tray-btn"
264+
href={facebookIntent(shareHref)}
265+
target="_blank"
266+
rel="noopener noreferrer"
267+
onClick={() => markDemo("save", "facebook")}
268+
>
269+
Facebook
270+
</a>
271+
) : null}
223272
{canShare ? (
224273
<button type="button" className="tray-btn" onClick={() => void onShare()}>
225-
Send
274+
Send to apps
226275
</button>
227276
) : null}
228277
{walkId ? (

0 commit comments

Comments
 (0)