@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.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/apps/album/src/app.ts +107 -0
- package/apps/album/src/index.html +38 -0
- package/apps/album/src/index.ts +12 -0
- package/apps/album/src/shots.ts +85 -0
- package/apps/album/src/styles.css +66 -0
- package/dist/capture.d.ts +28 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +115 -0
- package/dist/capture.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +107 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +21 -0
- package/dist/keys.js.map +1 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +193 -0
- package/dist/mcp.js.map +1 -0
- package/dist/options.d.ts +38 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +91 -0
- package/dist/options.js.map +1 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +85 -0
- package/dist/setup.js.map +1 -0
- package/dist/skill.d.ts +8 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +13 -0
- package/dist/skill.js.map +1 -0
- package/dist/upload.d.ts +27 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +51 -0
- package/dist/upload.js.map +1 -0
- package/package.json +57 -0
- package/skill/SKILL.md +65 -0
- package/src/capture.ts +85 -0
- package/src/cli.ts +101 -0
- package/src/index.ts +7 -0
- package/src/keys.ts +24 -0
- package/src/mcp.ts +220 -0
- package/src/options.ts +141 -0
- package/src/setup.ts +104 -0
- package/src/skill.ts +14 -0
- 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
|
+
`` 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 `` 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` → ``.
|
|
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"}
|