@voila.dev/cliche 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +218 -0
  3. package/apps/album/src/app.ts +107 -0
  4. package/apps/album/src/index.html +38 -0
  5. package/apps/album/src/index.ts +12 -0
  6. package/apps/album/src/shots.ts +85 -0
  7. package/apps/album/src/styles.css +66 -0
  8. package/dist/capture.d.ts +28 -0
  9. package/dist/capture.d.ts.map +1 -0
  10. package/dist/capture.js +115 -0
  11. package/dist/capture.js.map +1 -0
  12. package/dist/cli.d.ts +3 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +107 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/index.d.ts +8 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +5 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/keys.d.ts +10 -0
  21. package/dist/keys.d.ts.map +1 -0
  22. package/dist/keys.js +21 -0
  23. package/dist/keys.js.map +1 -0
  24. package/dist/mcp.d.ts +19 -0
  25. package/dist/mcp.d.ts.map +1 -0
  26. package/dist/mcp.js +193 -0
  27. package/dist/mcp.js.map +1 -0
  28. package/dist/options.d.ts +38 -0
  29. package/dist/options.d.ts.map +1 -0
  30. package/dist/options.js +91 -0
  31. package/dist/options.js.map +1 -0
  32. package/dist/setup.d.ts +11 -0
  33. package/dist/setup.d.ts.map +1 -0
  34. package/dist/setup.js +85 -0
  35. package/dist/setup.js.map +1 -0
  36. package/dist/skill.d.ts +8 -0
  37. package/dist/skill.d.ts.map +1 -0
  38. package/dist/skill.js +13 -0
  39. package/dist/skill.js.map +1 -0
  40. package/dist/upload.d.ts +27 -0
  41. package/dist/upload.d.ts.map +1 -0
  42. package/dist/upload.js +51 -0
  43. package/dist/upload.js.map +1 -0
  44. package/package.json +57 -0
  45. package/skill/SKILL.md +65 -0
  46. package/src/capture.ts +85 -0
  47. package/src/cli.ts +101 -0
  48. package/src/index.ts +7 -0
  49. package/src/keys.ts +24 -0
  50. package/src/mcp.ts +220 -0
  51. package/src/options.ts +141 -0
  52. package/src/setup.ts +104 -0
  53. package/src/skill.ts +14 -0
  54. package/src/upload.ts +72 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emilien Bidet and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ <img src="assets/og.png" alt="cliche — un cliché of every page. Front-end PRs you can SEE, and a photo album of your app by accident." width="100%">
2
+
3
+ # cliche 📸
4
+
5
+ **Take a _cliché_ of any page and get a shareable URL.** No Playwright, no
6
+ browser download, no dependencies — just Bun. Ouistiti ! 🐒
7
+
8
+ > _Un cliché_ is French for a snapshot. This one screenshots your app with
9
+ > [`Bun.WebView`](https://bun.com/docs/runtime/webview), uploads it to any
10
+ > S3-compatible bucket with `Bun.S3Client`, and hands you the URL (or the
11
+ > markdown line) to paste into a pull request. **Everything runs on your
12
+ > machine** — the only thing that leaves it is the upload to *your* bucket.
13
+ >
14
+ > This very image was captured by cliche, from an HTML file, in one command.
15
+ > [cliche.voila.dev](https://cliche.voila.dev)
16
+
17
+ ```sh
18
+ bunx @voila.dev/cliche https://localhost:4001/missions mission-list.png --upload --prefix pr-123
19
+ ```
20
+
21
+ ```
22
+ Captured https://localhost:4001/missions -> mission-list.png
23
+ Uploaded mission-list.png -> pr-123/2026-08-29-mission-list-d1cf773c.png
24
+ https://assets.example.com/pr-123/2026-08-29-mission-list-d1cf773c.png
25
+ ```
26
+
27
+ The URL is yours to paste anywhere; add `--markdown` to get a ready-made
28
+ `![mission list](…)` line for `gh pr edit --body` instead.
29
+
30
+ Or skip the CLI entirely and give the tools to your agent — `cliche` is also
31
+ a local MCP server:
32
+
33
+ ```sh
34
+ claude mcp add cliche -- bunx @voila.dev/cliche mcp
35
+ ```
36
+
37
+ ## Why you want this
38
+
39
+ **1. Front-end PRs you can SEE.** A diff tells the reviewer what changed in
40
+ the code; a before/after tells them what changed *for the user*. When every
41
+ UI pull request ships with its pixels, review gets faster, regressions get
42
+ caught at a glance, and "looks good to me" actually means someone looked.
43
+
44
+ **2. You're building a photo album by accident.** Content-hashed keys mean
45
+ every shot stays in your bucket forever, dated and browsable. Six months in,
46
+ you own something no git history gives you: what your app *looked like*,
47
+ release by release. Retrospectives, launch recaps, "remember when the
48
+ dashboard looked like this?" — it's all just sitting in S3.
49
+
50
+ The usual capture path drags in a Playwright install, a 100MB browser
51
+ download, and a place to host the image. `cliche` is a single
52
+ zero-dependency CLI (and MCP server):
53
+
54
+ - **Capture** — `Bun.WebView`: the system WKWebView on macOS (nothing to
55
+ install), your installed Chrome via CDP on Linux/Windows. Retina-crisp PNGs.
56
+ - **Upload** — `Bun.S3Client`: works with Cloudflare R2, AWS S3, MinIO,
57
+ anything that speaks S3. Keys are content-hashed, so re-uploads never break
58
+ old links.
59
+ - **URL out** — one public URL per file on stdout (progress on stderr), or
60
+ `--markdown` for `![caption](url)` lines, so you can pipe it wherever the
61
+ review happens.
62
+ - **MCP in** — `cliche mcp` serves the same two tools (`screenshot`, `upload`)
63
+ over stdio to any MCP client, hand-rolled JSON-RPC, still zero dependencies.
64
+ Everything runs locally.
65
+
66
+ Requires Bun ≥ 1.4.0.
67
+
68
+ ## Capture
69
+
70
+ ```sh
71
+ cliche <url> <out.png> [options]
72
+ ```
73
+
74
+ | Option | What it does |
75
+ | --- | --- |
76
+ | `--viewport <WxH>` | Viewport, default `1440x900` (`390x844` for mobile shots). |
77
+ | `--wait-for <css>` | Hold the shot until a selector exists — SPAs render late. |
78
+ | `--scroll-to <css>` | Scroll a component into view before shooting. |
79
+ | `--settle <ms>` | Let fonts/images/animations finish, default `1500`. |
80
+ | `--full-page` | Grow the viewport to the full page height — the whole page in one shot. |
81
+ | `--local-storage k=v` | Seed the target origin's localStorage (repeatable). |
82
+
83
+ The `--local-storage` flag is the trick for authenticated screens: seed your
84
+ app's session token and the page boots logged in — no login form scripting.
85
+
86
+ ```sh
87
+ cliche http://localhost:4001/admin dashboard.png \
88
+ --local-storage "myapp.session-token=$TOKEN" \
89
+ --wait-for '[data-testid=dashboard]'
90
+ ```
91
+
92
+ ## Upload
93
+
94
+ ```sh
95
+ cliche upload [--prefix pr-123] [--markdown] *.png # or add --upload to a capture
96
+ ```
97
+
98
+ Configuration is the standard environment variables `Bun.S3Client` already
99
+ reads — if your shell can talk to your bucket, so can `cliche`:
100
+
101
+ | Variable | Example |
102
+ | --- | --- |
103
+ | `S3_BUCKET` | `assets-dev` |
104
+ | `S3_ENDPOINT` | `https://<account>.r2.cloudflarestorage.com` (R2) — omit for AWS |
105
+ | `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | your keys (`AWS_*` works too) |
106
+ | `CLICHE_PUBLIC_URL` | `https://assets.example.com` — the bucket's public/custom domain |
107
+
108
+ Objects are keyed `<prefix>/<yyyy-mm-dd>-<name>-<content-hash>.<ext>`: the
109
+ hash makes re-uploads cache-safe, the date keeps the bucket browsable. The
110
+ caption is derived from the file name — name files like you want them read:
111
+ `mission-detail-after.png` → `![mission detail after](…)`.
112
+
113
+ > [!WARNING]
114
+ > The bucket you point `cliche` at should be one you're happy to have public
115
+ > (PR descriptions live forever). Never capture real user data.
116
+
117
+ ## MCP server
118
+
119
+ ```sh
120
+ cliche mcp # stdio; nothing leaves your machine except the S3 upload
121
+ ```
122
+
123
+ Registers two tools with any MCP client:
124
+
125
+ - **`screenshot`** — `url` (required), `out`, `viewport` (`"390x844"`),
126
+ `wait_for`, `scroll_to`, `settle_ms`, `local_storage` (object), `upload`,
127
+ `prefix`, `markdown`. Without `out` the shot lands in a temp file; with
128
+ `upload: true` the result is the public URL.
129
+ - **`upload`** — `files` (required), `prefix`, `markdown`.
130
+
131
+ One-liners:
132
+
133
+ ```sh
134
+ claude mcp add cliche -- bunx @voila.dev/cliche mcp # Claude Code
135
+ ```
136
+
137
+ or in a project's `.mcp.json`:
138
+
139
+ ```json
140
+ {
141
+ "mcpServers": {
142
+ "cliche": { "command": "bunx", "args": ["@voila.dev/cliche", "mcp"] }
143
+ }
144
+ }
145
+ ```
146
+
147
+ ## Programmatic API
148
+
149
+ ```ts
150
+ import { capture, upload } from "@voila.dev/cliche";
151
+
152
+ await capture({
153
+ url: "http://localhost:3000",
154
+ out: "home.png",
155
+ viewport: { width: 390, height: 844 },
156
+ waitFor: "main",
157
+ });
158
+
159
+ const [shot] = await upload({ files: ["home.png"], prefix: "pr-7" });
160
+ console.log(shot.markdown);
161
+ ```
162
+
163
+ ## One-command S3 setup (Cloudflare R2)
164
+
165
+ ```sh
166
+ bunx @voila.dev/cliche setup # or: setup --bucket my-shots
167
+ ```
168
+
169
+ Riding your existing `wrangler login`, this creates the R2 bucket, enables
170
+ its managed public `*.r2.dev` URL, and writes `S3_BUCKET`, `S3_ENDPOINT` and
171
+ `CLICHE_PUBLIC_URL` to `.env`. One thing wrangler can't mint: the two S3 API
172
+ keys — the command prints the exact dashboard link, you paste two values,
173
+ done. Any other S3-compatible service works too with the plain env vars.
174
+
175
+ ## The album 📔
176
+
177
+ One command turns your bucket into a browsable photo album — every cliché,
178
+ grouped by month, polaroid-style, with filtering and a lightbox. This is the
179
+ retrospective machine:
180
+
181
+ ```sh
182
+ bunx @voila.dev/cliche album # reads the same S3_* env; demo album if none
183
+ ```
184
+
185
+ It's a tiny Bun fullstack app (HTML imports, zero deps, of course) that
186
+ ships inside the package — the source lives in `apps/album`.
187
+
188
+ <img src="assets/album-preview.png" alt="album — real tries.care pages captured by cliche, grouped by month as polaroids" width="100%">
189
+
190
+ *(Real example: [tries.care](https://tries.care)'s pages, captured by cliche,
191
+ served from its PR-screenshots bucket.)*
192
+
193
+ ## Claude Code skill
194
+
195
+ A ready-made [Claude Code](https://claude.com/claude-code) PR-screenshots
196
+ skill ships with the package — one line installs it into your repo:
197
+
198
+ ```sh
199
+ bunx @voila.dev/cliche skill # writes .claude/skills/pr-screenshots/SKILL.md
200
+ ```
201
+
202
+ (Also served at [cliche.voila.dev/skill.md](https://cliche.voila.dev/skill.md)
203
+ if you'd rather `curl` it.) With the skill in place, Claude captures, uploads,
204
+ and embeds before/after screenshots whenever a PR touches something visible.
205
+
206
+ ## Platform notes
207
+
208
+ - **macOS** — WKWebView, zero setup. Shots come out at the display's scale
209
+ factor (2x on retina).
210
+ - **Linux / Windows** — drives an installed Chrome, Chromium, Edge or Brave
211
+ over the Chrome DevTools Protocol (GitHub Actions runners ship Chrome, so
212
+ CI capture works out of the box).
213
+ - `Bun.WebView` is marked experimental by Bun; `cliche` pins none of its
214
+ sharp edges and will track the API as it settles.
215
+
216
+ ## License
217
+
218
+ MIT
@@ -0,0 +1,107 @@
1
+ interface Shot {
2
+ key: string;
3
+ url: string;
4
+ size: number;
5
+ date: string;
6
+ }
7
+
8
+ const MONTH_LABELS = [
9
+ "January", "February", "March", "April", "May", "June",
10
+ "July", "August", "September", "October", "November", "December",
11
+ ];
12
+
13
+ function monthOf(shot: Shot): string {
14
+ return shot.date.slice(0, 7);
15
+ }
16
+
17
+ function monthLabel(month: string): string {
18
+ const [year, index] = month.split("-");
19
+ return `${MONTH_LABELS[Number(index) - 1] ?? "Sometime"} ${year}`;
20
+ }
21
+
22
+ function captionOf(key: string): string {
23
+ const base = key.split("/").at(-1) ?? key;
24
+ return base
25
+ .replace(/\.\w+$/, "")
26
+ .replace(/^\d{4}-\d{2}-\d{2}-/, "")
27
+ .replace(/-[0-9a-f]{8}$/, "")
28
+ .replace(/[-_]+/g, " ");
29
+ }
30
+
31
+ function kilobytes(size: number): string {
32
+ return size > 0 ? `${Math.max(1, Math.round(size / 1024))} KB` : "";
33
+ }
34
+
35
+ const album = document.getElementById("album") as HTMLElement;
36
+ const filterInput = document.getElementById("filter") as HTMLInputElement;
37
+ const count = document.getElementById("count") as HTMLElement;
38
+ const lightbox = document.getElementById("lightbox") as HTMLDialogElement;
39
+ const lightboxImage = document.getElementById("lightbox-image") as HTMLImageElement;
40
+ const lightboxKey = document.getElementById("lightbox-key") as HTMLElement;
41
+
42
+ let allShots: Array<Shot> = [];
43
+
44
+ function render(): void {
45
+ const query = filterInput.value.trim().toLowerCase();
46
+ const shots = allShots.filter((shot) => shot.key.toLowerCase().includes(query));
47
+ count.textContent = `${shots.length} cliché${shots.length === 1 ? "" : "s"}`;
48
+ album.replaceChildren();
49
+ if (shots.length === 0) {
50
+ const empty = document.createElement("p");
51
+ empty.className = "empty";
52
+ empty.textContent = "No clichés here (yet). Ouistiti ! 🐒";
53
+ album.append(empty);
54
+ return;
55
+ }
56
+ const byMonth = new Map<string, Array<Shot>>();
57
+ for (const shot of shots) {
58
+ const month = monthOf(shot);
59
+ byMonth.set(month, [...(byMonth.get(month) ?? []), shot]);
60
+ }
61
+ for (const [month, monthShots] of [...byMonth.entries()].sort((a, b) => b[0].localeCompare(a[0]))) {
62
+ const heading = document.createElement("h2");
63
+ heading.className = "month";
64
+ heading.innerHTML = `${monthLabel(month)} <span class="n">${monthShots.length}</span>`;
65
+ const grid = document.createElement("div");
66
+ grid.className = "grid";
67
+ for (const shot of monthShots.sort((a, b) => b.key.localeCompare(a.key))) {
68
+ const card = document.createElement("figure");
69
+ card.className = "polaroid";
70
+ const image = document.createElement("img");
71
+ image.src = shot.url;
72
+ image.alt = captionOf(shot.key);
73
+ image.loading = "lazy";
74
+ const caption = document.createElement("figcaption");
75
+ caption.className = "caption";
76
+ caption.textContent = captionOf(shot.key);
77
+ const meta = document.createElement("div");
78
+ meta.className = "meta";
79
+ meta.textContent = [shot.date, kilobytes(shot.size)].filter(Boolean).join(" · ");
80
+ card.append(image, caption, meta);
81
+ card.addEventListener("click", () => {
82
+ lightboxImage.src = shot.url;
83
+ lightboxImage.alt = captionOf(shot.key);
84
+ lightboxKey.textContent = shot.key;
85
+ lightbox.dataset.url = shot.url;
86
+ lightbox.showModal();
87
+ });
88
+ grid.append(card);
89
+ }
90
+ album.append(heading, grid);
91
+ }
92
+ }
93
+
94
+ document.getElementById("lightbox-close")?.addEventListener("click", () => lightbox.close());
95
+ document.getElementById("lightbox-copy")?.addEventListener("click", () => {
96
+ void navigator.clipboard.writeText(lightbox.dataset.url ?? "");
97
+ });
98
+ lightbox.addEventListener("click", (event) => {
99
+ if (event.target === lightbox) lightbox.close();
100
+ });
101
+ filterInput.addEventListener("input", render);
102
+
103
+ const response = await fetch("/api/shots");
104
+ const payload = (await response.json()) as { demo: boolean; shots: Array<Shot> };
105
+ allShots = payload.shots;
106
+ (document.getElementById("demo-banner") as HTMLElement).hidden = !payload.demo;
107
+ render();
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>album · cliche 📸</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
8
+ <link rel="stylesheet" href="./styles.css">
9
+ </head>
10
+ <body>
11
+ <header>
12
+ <div class="brand">
13
+ <span class="camera">📸</span>
14
+ <div>
15
+ <h1>album<span class="accent">.</span></h1>
16
+ <p class="tagline">Your app, release by release. Every cliché in the bucket.</p>
17
+ </div>
18
+ </div>
19
+ <div class="controls">
20
+ <input id="filter" type="search" placeholder="Filter by key… (pr-123, checkout)">
21
+ <span id="count" class="count"></span>
22
+ </div>
23
+ </header>
24
+ <div id="demo-banner" class="demo-banner" hidden>
25
+ Demo album. No bucket configured yet: Run <code>bunx @voila.dev/cliche setup</code> to plug yours.
26
+ </div>
27
+ <main id="album"></main>
28
+ <dialog id="lightbox">
29
+ <img id="lightbox-image" alt="">
30
+ <div class="lightbox-bar">
31
+ <span id="lightbox-key"></span>
32
+ <button id="lightbox-copy">Copy URL</button>
33
+ <button id="lightbox-close">Close</button>
34
+ </div>
35
+ </dialog>
36
+ <script type="module" src="./app.ts"></script>
37
+ </body>
38
+ </html>
@@ -0,0 +1,12 @@
1
+ import index from "./index.html";
2
+ import { listShots } from "./shots.ts";
3
+
4
+ const server = Bun.serve({
5
+ port: Number(process.env.PORT ?? 4949),
6
+ routes: {
7
+ "/": index,
8
+ "/api/shots": async () => Response.json(await listShots()),
9
+ },
10
+ });
11
+
12
+ console.error(`📸 l'album — ${server.url}`);
@@ -0,0 +1,85 @@
1
+ import { publicBaseUrl } from "../../../src/upload.ts";
2
+
3
+ export interface Shot {
4
+ readonly key: string;
5
+ readonly url: string;
6
+ readonly size: number;
7
+ /** ISO date, from the object key when it carries one, else lastModified. */
8
+ readonly date: string;
9
+ }
10
+
11
+ const KEY_DATE = /(\d{4}-\d{2}-\d{2})/;
12
+
13
+ function dateOf(key: string, lastModified: string | undefined): string {
14
+ return KEY_DATE.exec(key)?.[1] ?? (lastModified ?? "").slice(0, 10);
15
+ }
16
+
17
+ async function listBucket(baseUrl: string): Promise<Array<Shot>> {
18
+ const client = new Bun.S3Client();
19
+ const shots: Array<Shot> = [];
20
+ let continuationToken: string | undefined;
21
+ do {
22
+ const page = await client.list({
23
+ maxKeys: 1000,
24
+ ...(continuationToken === undefined ? {} : { continuationToken }),
25
+ });
26
+ for (const object of page.contents ?? []) {
27
+ if (!/\.(png|jpe?g|webp|gif)$/i.test(object.key)) continue;
28
+ shots.push({
29
+ key: object.key,
30
+ url: `${baseUrl}/${object.key}`,
31
+ size: object.size ?? 0,
32
+ date: dateOf(object.key, object.lastModified),
33
+ });
34
+ }
35
+ continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;
36
+ } while (continuationToken !== undefined);
37
+ return shots;
38
+ }
39
+
40
+ function demoShot(prefix: string, name: string, date: string, hue: number): Shot {
41
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='640' height='400'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='hsl(${hue} 90% 72%)'/><stop offset='1' stop-color='hsl(${hue + 30} 85% 55%)'/></linearGradient></defs><rect width='640' height='400' fill='url(%23g)'/><rect x='24' y='24' width='250' height='22' rx='11' fill='rgba(255,255,255,0.75)'/><rect x='24' y='58' width='160' height='22' rx='11' fill='rgba(255,255,255,0.55)'/><rect x='24' y='330' width='140' height='40' rx='10' fill='rgba(29,26,20,0.8)'/></svg>`;
42
+ const key = `${prefix}/${date}-${name}-0000cafe.png`;
43
+ return { key, url: `data:image/svg+xml,${svg}`, size: 42_000, date };
44
+ }
45
+
46
+ function demoShots(): Array<Shot> {
47
+ const screens = ["home", "dashboard", "checkout", "settings", "profile"];
48
+ const shots: Array<Shot> = [];
49
+ let hue = 10;
50
+ for (const [index, month] of ["2026-03", "2026-05", "2026-08"].entries()) {
51
+ for (const [day, screen] of screens.entries()) {
52
+ shots.push(demoShot(`pr-${100 + index * 40 + day}`, `${screen}-after`, `${month}-${String(day * 5 + 2).padStart(2, "0")}`, hue));
53
+ hue += 37;
54
+ }
55
+ }
56
+ return shots;
57
+ }
58
+
59
+ /**
60
+ * The bucket's shots — or a JSON manifest (`CLICHE_ALBUM_MANIFEST`, an array
61
+ * of `{key, url, size?, date?}`) when listing credentials are not available,
62
+ * or a generated demo album when nothing is configured at all.
63
+ */
64
+ export async function listShots(): Promise<{ demo: boolean; shots: Array<Shot> }> {
65
+ const manifestPath = process.env.CLICHE_ALBUM_MANIFEST;
66
+ if (manifestPath !== undefined) {
67
+ const entries = (await Bun.file(manifestPath).json()) as Array<Partial<Shot>>;
68
+ const shots = entries.map((entry) => ({
69
+ key: entry.key ?? "",
70
+ url: entry.url ?? "",
71
+ size: entry.size ?? 0,
72
+ date: entry.date ?? dateOf(entry.key ?? "", undefined),
73
+ }));
74
+ return { demo: false, shots };
75
+ }
76
+ try {
77
+ const baseUrl = publicBaseUrl();
78
+ return { demo: false, shots: await listBucket(baseUrl) };
79
+ } catch (error) {
80
+ console.error(
81
+ `No bucket configured (${error instanceof Error ? error.message.split(":")[0] : error}) — serving the demo album. Run \`bunx @voila.dev/cliche setup\` to plug a real one.`,
82
+ );
83
+ return { demo: true, shots: demoShots() };
84
+ }
85
+ }
@@ -0,0 +1,66 @@
1
+ * { margin: 0; box-sizing: border-box; }
2
+ body {
3
+ font-family: -apple-system, "SF Pro Display", "Segoe UI", Helvetica, Arial, sans-serif;
4
+ background: #faf5ec; color: #1d1a14; padding-bottom: 80px;
5
+ }
6
+ header {
7
+ max-width: 1100px; margin: 0 auto; padding: 40px 24px 8px;
8
+ display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; flex-wrap: wrap;
9
+ }
10
+ .brand { display: flex; align-items: center; gap: 16px; }
11
+ .camera { font-size: 48px; transform: rotate(-8deg); filter: drop-shadow(4px 6px 0 rgba(29,26,20,0.12)); }
12
+ h1 { font-size: 52px; letter-spacing: -2px; font-weight: 800; }
13
+ h1 .accent { color: #ff6600; }
14
+ .tagline { color: #4a4438; font-size: 17px; }
15
+ .controls { display: flex; align-items: center; gap: 14px; }
16
+ #filter {
17
+ font: inherit; font-size: 16px; padding: 10px 16px; width: 300px;
18
+ border: 3px solid #1d1a14; border-radius: 12px; background: #fff;
19
+ box-shadow: 4px 4px 0 rgba(29,26,20,0.9); outline: none;
20
+ }
21
+ .count { font-weight: 700; color: #4a4438; white-space: nowrap; }
22
+ .demo-banner {
23
+ max-width: 1100px; margin: 20px auto 0; padding: 12px 20px;
24
+ background: #ff6600; color: #fff; font-weight: 700; border-radius: 12px;
25
+ box-shadow: 4px 4px 0 rgba(29,26,20,0.9);
26
+ }
27
+ .demo-banner code { background: rgba(29,26,20,0.35); padding: 2px 8px; border-radius: 6px; font-weight: 400; }
28
+ main { max-width: 1100px; margin: 0 auto; padding: 12px 24px; }
29
+ h2.month {
30
+ font-size: 28px; letter-spacing: -1px; margin: 44px 0 4px;
31
+ display: flex; align-items: baseline; gap: 12px;
32
+ }
33
+ h2.month .n { font-size: 15px; color: #8d8471; font-weight: 700; }
34
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 26px; margin-top: 18px; }
35
+ .polaroid {
36
+ background: #fff; border: 2px solid #1d1a14; border-radius: 8px;
37
+ padding: 8px 8px 6px; cursor: zoom-in;
38
+ box-shadow: 5px 5px 0 rgba(29,26,20,0.25);
39
+ transition: transform 0.15s ease;
40
+ }
41
+ .polaroid:nth-child(3n+1) { transform: rotate(-1.6deg); }
42
+ .polaroid:nth-child(3n+2) { transform: rotate(1.2deg); }
43
+ .polaroid:nth-child(4n+3) { transform: rotate(-0.7deg); }
44
+ .polaroid:hover { transform: rotate(0deg) scale(1.04); box-shadow: 7px 7px 0 rgba(29,26,20,0.9); }
45
+ .polaroid img { width: 100%; aspect-ratio: 8/5; object-fit: cover; object-position: top; border-radius: 4px; background: #eee7d8; display: block; }
46
+ .polaroid .caption { font-size: 14px; font-weight: 700; padding: 8px 4px 2px; }
47
+ .polaroid .meta { font-size: 12px; color: #8d8471; padding: 0 4px 2px; }
48
+ .empty { text-align: center; color: #8d8471; font-size: 18px; padding: 80px 0; }
49
+ dialog#lightbox {
50
+ /* The `* { margin: 0 }` reset kills the UA's `margin: auto` that centers
51
+ a <dialog>; restore it. */
52
+ margin: auto;
53
+ border: 3px solid #1d1a14; border-radius: 16px; padding: 12px;
54
+ max-width: min(1200px, 92vw); background: #fff;
55
+ box-shadow: 10px 10px 0 rgba(29,26,20,0.9);
56
+ }
57
+ dialog#lightbox::backdrop { background: rgba(29,26,20,0.55); }
58
+ #lightbox-image { max-width: 100%; max-height: 76vh; border-radius: 8px; display: block; }
59
+ .lightbox-bar { display: flex; align-items: center; gap: 12px; padding-top: 12px; }
60
+ #lightbox-key { font-family: ui-monospace, Menlo, monospace; font-size: 13px; color: #4a4438; flex: 1; overflow-wrap: anywhere; }
61
+ .lightbox-bar button {
62
+ font: inherit; font-weight: 700; font-size: 14px; cursor: pointer;
63
+ border: 2px solid #1d1a14; border-radius: 10px; padding: 8px 16px;
64
+ background: #ff6600; color: #fff; box-shadow: 3px 3px 0 rgba(29,26,20,0.9);
65
+ }
66
+ #lightbox-close { background: #fff; color: #1d1a14; }
@@ -0,0 +1,28 @@
1
+ export interface Viewport {
2
+ readonly width: number;
3
+ readonly height: number;
4
+ }
5
+ export interface CaptureOptions {
6
+ /** The page to screenshot. */
7
+ readonly url: string;
8
+ /** Where to write the PNG. */
9
+ readonly out: string;
10
+ /** Defaults to 1440×900. */
11
+ readonly viewport?: Viewport;
12
+ /**
13
+ * Entries seeded into the target origin's localStorage before the page
14
+ * loads — the way in for apps that keep their session token there.
15
+ */
16
+ readonly localStorage?: Readonly<Record<string, string>>;
17
+ /** CSS selector to wait for before shooting (15s timeout). */
18
+ readonly waitFor?: string;
19
+ /** CSS selector scrolled into view before shooting. */
20
+ readonly scrollTo?: string;
21
+ /** Milliseconds to let the page settle after load. Defaults to 1500. */
22
+ readonly settleMilliseconds?: number;
23
+ /** Grow the viewport to the full page height before shooting. */
24
+ readonly fullPage?: boolean;
25
+ }
26
+ /** Screenshot a page with Bun.WebView and write it to `options.out` as PNG. */
27
+ export declare function capture(options: CaptureOptions): Promise<void>;
28
+ //# sourceMappingURL=capture.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../src/capture.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,8BAA8B;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8BAA8B;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,4BAA4B;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACzD,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,wEAAwE;IACxE,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC,iEAAiE;IACjE,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B;AAoBD,+EAA+E;AAC/E,wBAAsB,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAmCpE"}