Initial commit: postcard v1

Phone photo -> composited postcard (canvas templates, stamp corner,
EXIF-aware date/location, self-hosted handwriting font) with a small
server for share links, day-context caption drafting off goonk's
day-summary API, and Nominatim reverse geocoding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-08-06 19:49:03 +02:00
co-authored by Claude Sonnet 5
commit cf0f5fca2c
24 changed files with 2839 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# Copy to .env and adjust. Read two ways: `npm start`/`npm run dev` in
# server/ load it directly via Node's --env-file-if-exists; `docker
# compose up` reads it as compose's env file instead (IMAGE/HOST_PORT are
# only meaningful there, the rest get passed through to the container).
# Image to deploy — set by CI/CD normally; only needed for a manual
# docker compose up.
IMAGE=repo.explewd.com/explewd/postcard:latest
# Host port to expose (container always listens on 3098).
HOST_PORT=3098
# Retention — a postcard link is meant to be opened once by the
# recipient, not browsed later, so this is shorter than latent's.
TTL_HOURS=72
MAX_UPLOAD_BYTES=20971520
RATE_LIMIT_PER_HOUR=30
# Optional day-context source for caption drafting. GET {url}?date=YYYY-MM-DD
# must return { git?, spotify?, note? }. Left unset, captions fall back to
# generic postcard-voice lines instead of invented personal data.
DAY_SUMMARY_URL=https://example.com/api/day-summary
+5
View File
@@ -0,0 +1,5 @@
node_modules
.env
data
server/data
.claude/
+26
View File
@@ -0,0 +1,26 @@
FROM node:24-slim
# better-sqlite3 has a native addon with no prebuilt binary for this
# platform/Node combo yet — build it from source, then drop the toolchain.
RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY server/package*.json server/
RUN npm install --prefix server --omit=dev \
&& apt-get purge -y python3 make g++ && apt-get autoremove -y
COPY server/src/ server/src/
COPY index.html style.css app.js exif.js favicon.svg ./
COPY fonts/ fonts/
# Do NOT copy data/ — mutable runtime volume holding the SQLite db and
# image blobs, would get clobbered on every redeploy if baked in.
ENV NODE_ENV=production
ENV PORT=3098
ENV DATA_DIR=/app/data
EXPOSE 3098
CMD ["node", "server/src/index.js"]
+143
View File
@@ -0,0 +1,143 @@
# Proposal: `postcard` — turn a phone photo into a sendable postcard
**Status:** proposed, not built. Self-contained — written so a fresh
agent with no prior conversation context can pick this up and implement
it without anything explained first. No repo exists yet; this file is
the seed of one.
## Motivation
Upload a photo from your phone (or any device), it gets composited onto
a vintage postcard template — border, stamp corner, a handwritten-style
caption — and you get a shareable link or a downloadable image. The
caption is auto-drafted from whatever context is available for that
day (git activity, what was playing, a note title) so the postcard reads
like it was actually written *from* that day, not just a filter applied
to a photo.
Structurally this is `latent`'s upload → transform → deliver shape, with
the transform swapped from film-emulation filters to postcard
compositing, and one addition: a short generated caption line instead of
(or alongside) canvas filters. Reuse `latent`'s validated pieces
wholesale rather than re-deriving them — same phone-upload flow, same
blob+SQLite+TTL server, same no-accounts/link-is-the-credential model.
## Architecture
Static/client-heavy frontend (canvas compositing, same "no build step"
family as `latent`/`typo`/`flit`/`wisp`) plus a small single-process
server, copied from `latent/server` almost unchanged: `db.js` (SQLite
metadata), `ids.js` (opaque random IDs), `cleanup.js` (interval TTL
sweep), `ratelimit.js` (basic IP throttle on upload), `routes.js`,
`index.js`. No accounts, no auth — the link is the credential, same as
`latent`/`wisp`.
### Editing (client-side, `<canvas>`)
1. **Upload** — `<input type="file" capture>` for direct camera access,
normal file picker as fallback (same open question `latent` already
flagged, same answer: ship both).
2. **Template** — a handful of postcard border/stamp-corner presets
(plain cream border, torn-edge, faded color-photo-era border) — pick
a vibe, not a full editor. Reuse `latent`'s "presets, not sliders"
call for the same reason: faster to ship, more toy than tool.
3. **Caption** — an editable text field, pre-filled by a generated draft
(see below), rendered onto the card in a handwriting-style webfont.
Editable because auto-drafted text should be a starting point, not
the final word — the person sending it should be able to make it
actually theirs.
4. **Postmark/stamp corner** — today's date and a small generated
"location" stamp if EXIF GPS is present (round-tripped through the
same reverse-geocoding approach `rewind`/`galr` already use, if one
exists — otherwise just the date, no invented location).
5. Compositing is canvas-based, non-destructive within the session
(recompute from the original photo + a param object, matching
`latent`'s pattern) so switching templates doesn't require
re-uploading.
### Caption generation
The interesting new piece `latent` didn't need. Draft a one-line caption
from whatever of these is available and non-empty for the photo's
date (EXIF date if present, else upload date):
- Git activity that day (via the same Gitea-API aggregate-count approach
`rewind` already built — "spent the day untangling a deploy script" /
"quiet one, no commits")
- What was playing (Spotify plays log, same source `/wrapped`/`/now`
already read — "[artist] on repeat")
- A note/blog title from that date, if one exists
This is explicitly a **remix of existing fragments**, not a generative
free-text model call — same spirit as the haiku/found-poetry idea
below: assemble from real fragments of that day, phrase them into one
short sentence with a small set of template shapes ("Spent the day
{git_fragment}. {spotify_fragment} the whole time." /
"{spotify_fragment}. {git_fragment}, mostly."), and always leave it
editable. If none of the three sources have data for that date, fall
back to a small set of generic postcard-voice lines ("Wish you were
here. Mostly wasn't.") rather than leaving the field empty.
### Finishing paths
Same two-path split as `latent`:
1. **Instant** — composite, then download directly, entirely
client-side, no server touched. Optional "generate share link"
(explicit action, not automatic).
2. **Send** — uploads the composited image to server storage, returns a
link. No delayed-reveal mechanic here (that's `latent`'s gimmick, not
this one) — the postcard metaphor's own delay is real mail taking a
few days to arrive, which the link doesn't need to fake.
### Server
Copied from `latent/server`'s shape almost directly:
**Table `postcards`:**
| column | type | notes |
|---|---|---|
| `id` | text PK | random opaque ID, used in the URL |
| `blob_path` | text | composited image on disk |
| `mime` | text | |
| `created_at` | integer | |
| `expires_at` | integer | TTL sweep target, same as `latent` |
**Endpoints:**
- `POST /api/upload` — body: composited image bytes. Returns `{ id, url }`.
- `GET /p/:id` — serves the postcard image directly (no develop-later
gate — this app's whole point is immediacy, unlike `latent`).
- TTL sweep, same interval-based pattern as `latent`/`wisp`.
No auth. Rate-limit `/api/upload` the same way `latent` does.
## Non-goals
- No accounts, no "your postcards" history — every card stands alone via
its own link, same throwaway philosophy as `latent`/`wisp`.
- No delayed-reveal mechanic — that's `latent`'s gimmick; this one's
point is speed, not anticipation.
- No free-text LLM-generated captions — template-and-fragment assembly
from real personal data only, kept editable, never presented as if the
app "wrote" it.
- No collaborative/multi-recipient cards — one photo, one card, one link.
## Open questions
- **Where the day-context lookups live** — `rewind` and `/wrapped`/`/now`
already have working Gitea-API and Spotify-log integrations; decide
whether `postcard`'s server calls those APIs directly (network
dependency on goonk's API being reachable) or whether this only makes
sense as a goonk-hosted feature rather than a fully separate sibling
project. Worth resolving before writing `routes.js`.
- **Location stamp source** — confirm whether `galr`/`rewind` already do
reverse geocoding anywhere reusable, or whether this ships without a
location stamp in a first pass.
- **Handwriting webfont choice and licensing** — pick one, check its
license allows self-hosting.
- **Image size limits** — same question `latent` flagged, same likely
answer (downscale on upload, cap stored size).
- **Retention window** — shorter than `latent`'s, probably — a postcard
link is meant to be opened once by the recipient, not browsed later.
+84
View File
@@ -0,0 +1,84 @@
# postcard
Turn a phone photo into a sendable postcard. Upload a photo, it gets
composited onto a vintage postcard template (border, stamp corner,
handwriting-style caption) entirely in the browser, then either download
it right away or upload it for a shareable link.
The caption is auto-drafted by assembling real fragments of that day
(git activity, what was playing, a note title) via `server/src/caption.js`
— never a free-text model call — and stays editable. With no day-context
source configured it falls back to a small set of generic postcard-voice
lines.
Structurally this is a copy of [`latent`](../latent)'s upload → transform
→ deliver shape and server (`db.js`/`ids.js`/`cleanup.js`/`ratelimit.js`/
`routes.js`), with the transform swapped for postcard compositing. See
[PROPOSAL.md](PROPOSAL.md) for the original design writeup.
## Two finishing paths off one editor
1. **Instant** — composite, then download directly, entirely client-side.
Optionally generate a share link (explicit action, not automatic).
2. **Send** — uploads the composited image to server storage, returns a
link immediately (no delayed-reveal gate — that's `latent`'s gimmick,
not this one's).
## Running locally
```
npm install --prefix server
npm start --prefix server
```
Then open http://localhost:3098. `server`'s `start`/`dev` scripts load
`../.env` automatically via Node's `--env-file-if-exists` — copy
`.env.example` to `.env` at the repo root and adjust. `DATA_DIR` defaults
to `./server/data` (gitignored) for the SQLite db and image blobs.
## Deploying
`docker compose up` using the provided `docker-compose.yml` (copy
`.env.example` to `.env` and adjust — `IMAGE`/`HOST_PORT` are only read by
compose, the rest are passed through to the container).
## Config
All via env vars:
- `TTL_HOURS` — link retention (default 72h; shorter than `latent`'s,
since a postcard link is meant to be opened once, not browsed later).
- `MAX_UPLOAD_BYTES`, `RATE_LIMIT_PER_HOUR` — basic abuse limits; no
accounts or auth on any endpoint, the link itself is the credential.
- `DAY_SUMMARY_URL` — optional, no code-level default (set it in `.env`).
If set, `GET {url}?date=YYYY-MM-DD` is expected to return
`{ git?, spotify?, note? }` fragments for that date. `.env` here points
it at `https://goonk.se/api/day-summary`, goonk's live endpoint
(confirmed returning `{ git, spotify? }` — no `note` field observed yet).
A response body containing an `error` key (goonk's shape for a
missing/malformed date) is treated the same as no summary. Left unset,
captions fall back to generic lines — no invented personal data.
- `NOMINATIM_URL` — reverse-geocoding endpoint for the stamp corner's
location line (only queried when a photo has EXIF GPS). Defaults to the
public `nominatim.openstreetmap.org`; `server/src/geocode.js` throttles
to 1 req/sec and caches by rounded coordinate per that API's usage
policy. No API key needed.
## Notable implementation details
- EXIF is parsed client-side with no dependency (`exif.js`) — just enough
to read `DateTimeOriginal` and GPS lat/lon out of a JPEG. Parse
failures/missing tags fall back to upload date / no location stamp,
never invented data.
- The caption's handwriting font is self-hosted (`fonts/caveat-latin.woff2`,
Caveat, SIL OFL — see `fonts/OFL.txt`), not loaded from
fonts.googleapis.com at runtime.
- The upload card offers separate "take a photo" (camera capture) and
"choose from library" buttons rather than relying on the OS's file
picker to offer both — some mobile browsers skip the chooser and go
straight to the camera when `capture` is set on a single input.
## Non-goals
No accounts, no "your postcards" history, no delayed-reveal mechanic, no
free-text LLM-generated captions, no collaborative/multi-recipient cards.
+376
View File
@@ -0,0 +1,376 @@
(() => {
const fileInput = document.getElementById('file-input');
const cameraInput = document.getElementById('camera-input');
const cameraBtn = document.getElementById('camera-btn');
const libraryBtn = document.getElementById('library-btn');
const dropZone = document.getElementById('drop-zone');
const dropCard = document.getElementById('drop-card');
const editorCard = document.getElementById('editor-card');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const templateRow = document.getElementById('template-row');
const captionInput = document.getElementById('caption-input');
const captionStatus = document.getElementById('caption-status');
const redraftBtn = document.getElementById('redraft-btn');
const newPhotoBtn = document.getElementById('new-photo-btn');
const downloadBtn = document.getElementById('download-btn');
const shareBtn = document.getElementById('share-btn');
const resultPanel = document.getElementById('result-panel');
const source = document.createElement('canvas'); // original photo, cover-cropped into the photo area
const sourceCtx = source.getContext('2d');
let originalImg = null;
let photoDate = new Date();
let locationLabel = null; // "City, Country" from EXIF GPS + server-side reverse geocode, or null
const params = {
template: 'cream',
caption: '',
};
// Registered via CSS @font-face but canvas fillText won't wait for a
// lazily-loaded webfont on its own — load it explicitly once up front.
let fontReady = false;
document.fonts.load('700 40px Caveat').catch(() => {}).finally(() => {
fontReady = true;
if (originalImg) render();
});
// Small seeded PRNG so a template's jagged/faded look stays fixed per
// photo instead of jittering every redraw.
function mulberry32(seed) {
let a = Math.floor(seed * 0xffffffff);
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
let torSeed = Math.random();
async function loadFile(file) {
if (!file || !file.type.startsWith('image/')) return;
// Read EXIF before the image element even loads — DateTimeOriginal
// backdates the caption/postmark to when the photo was actually taken,
// not when it happened to be uploaded; GPS (if present) drives the
// location line. Both are optional: parse failures/missing tags just
// mean "fall back to upload date, no location," per PROPOSAL.md.
const exif = window.PostcardExif ? await window.PostcardExif.read(file) : null;
photoDate = exif?.date || new Date();
locationLabel = null;
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
originalImg = img;
URL.revokeObjectURL(url);
dropCard.hidden = true;
editorCard.hidden = false;
resultPanel.hidden = true;
torSeed = Math.random();
rebuildSource();
render();
draftCaption();
if (exif && exif.lat != null && exif.lon != null) lookupLocation(exif.lat, exif.lon);
};
img.src = url;
}
async function lookupLocation(lat, lon) {
try {
const res = await fetch(`/api/geocode?lat=${lat}&lon=${lon}`);
const data = await res.json();
if (data.label) {
locationLabel = data.label;
render();
}
} catch {
// No location line — never invents one, per PROPOSAL.md.
}
}
// Builds `source`: the photo cover-cropped to a fixed postcard photo-area
// aspect (3:2, classic postcard proportions), capped so a raw phone photo
// doesn't choke canvas ops.
function rebuildSource() {
if (!originalImg) return;
const iw = originalImg.naturalWidth;
const ih = originalImg.naturalHeight;
const targetAspect = 3 / 2;
let cropW = iw, cropH = ih, cropX = 0, cropY = 0;
const currentAspect = iw / ih;
if (currentAspect > targetAspect) {
cropW = Math.round(ih * targetAspect);
cropX = Math.round((iw - cropW) / 2);
} else {
cropH = Math.round(iw / targetAspect);
cropY = Math.round((ih - cropH) / 2);
}
const maxDim = 1800;
let outW = cropW, outH = cropH;
if (Math.max(outW, outH) > maxDim) {
const scale = maxDim / Math.max(outW, outH);
outW = Math.round(outW * scale);
outH = Math.round(outH * scale);
}
source.width = outW;
source.height = outH;
sourceCtx.clearRect(0, 0, outW, outH);
sourceCtx.drawImage(originalImg, cropX, cropY, cropW, cropH, 0, 0, outW, outH);
// Full card = photo area + a border margin all around, matching a
// physical postcard's white/cream frame.
const margin = Math.round(outW * 0.06);
canvas.width = outW + margin * 2;
canvas.height = outH + margin * 2;
}
function render() {
if (!originalImg) return;
const w = canvas.width;
const h = canvas.height;
const margin = Math.round(source.width * 0.06);
ctx.clearRect(0, 0, w, h);
// Border background per template.
if (params.template === 'faded') {
ctx.fillStyle = '#e9dfc8';
} else {
ctx.fillStyle = '#f7f2e6';
}
ctx.fillRect(0, 0, w, h);
// Photo, with a per-template filter applied at draw time.
ctx.save();
ctx.filter = params.template === 'faded'
? 'sepia(0.25) saturate(0.55) contrast(0.85) brightness(1.1)'
: 'none';
if (params.template === 'torn') {
clipTornRect(margin, margin, source.width, source.height, torSeed);
}
ctx.drawImage(source, margin, margin);
ctx.restore();
if (params.template !== 'torn') {
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.lineWidth = 1;
ctx.strokeRect(margin + 0.5, margin + 0.5, source.width - 1, source.height - 1);
}
drawStampCorner(w, h, margin);
drawCaption(w, h, margin);
}
// Clips the current path to a rectangle with a hand-torn edge — jagged
// teeth along each side, seeded so it's stable per photo.
function clipTornRect(x, y, w, h, seed) {
const rng = mulberry32(seed);
const tooth = Math.max(2, Math.round(Math.min(w, h) * 0.006));
const step = tooth * 3;
ctx.beginPath();
let px = x, py = y;
ctx.moveTo(px, py);
for (; px < x + w; px += step) ctx.lineTo(px, y + (rng() - 0.5) * tooth);
ctx.lineTo(x + w, y + (rng() - 0.5) * tooth);
for (; py < y + h; py += step) ctx.lineTo(x + w + (rng() - 0.5) * tooth, py);
ctx.lineTo(x + w + (rng() - 0.5) * tooth, y + h);
for (px = x + w; px > x; px -= step) ctx.lineTo(px, y + h + (rng() - 0.5) * tooth);
ctx.lineTo(x, y + h + (rng() - 0.5) * tooth);
for (py = y + h; py > y; py -= step) ctx.lineTo(x + (rng() - 0.5) * tooth, py);
ctx.closePath();
ctx.clip();
}
// Postmark stamp — date always, plus a location line only when the photo
// had EXIF GPS and reverse geocoding succeeded. Never an invented
// location, per PROPOSAL.md's fallback for "no EXIF GPS."
function drawStampCorner(w, h, margin) {
const size = margin * 1.6;
// Right edge of the (rotated) box must stay inside the card, not just
// inside the photo area — the border margin is the only safe zone.
const x = w - margin - size;
const y = margin * 0.15;
ctx.save();
ctx.translate(x, y);
ctx.rotate((6 * Math.PI) / 180);
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
ctx.lineWidth = Math.max(1, size * 0.02);
ctx.setLineDash([size * 0.04, size * 0.03]);
ctx.strokeRect(0, 0, size, size * 0.8);
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.textAlign = 'center';
const dateStr = photoDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
if (locationLabel) {
ctx.font = `${Math.round(size * 0.1)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText(truncate(locationLabel, 18), size / 2, size * 0.3);
ctx.font = `${Math.round(size * 0.11)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText(dateStr, size / 2, size * 0.52);
ctx.font = `${Math.round(size * 0.08)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText('postcard', size / 2, size * 0.7);
} else {
ctx.font = `${Math.round(size * 0.13)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText(dateStr, size / 2, size * 0.45);
ctx.font = `${Math.round(size * 0.09)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`;
ctx.fillText('postcard', size / 2, size * 0.65);
}
ctx.restore();
}
function truncate(str, maxLen) {
return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str;
}
function drawCaption(w, h, margin) {
if (!params.caption) return;
ctx.save();
ctx.fillStyle = 'rgba(30,25,15,0.85)';
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
// Caveat reads smaller than a sans face at the same px size (thin
// connected strokes), so it gets a bigger multiplier and bolder weight
// than a plain cursive fallback would need.
const fontSize = Math.max(16, Math.round(margin * 0.55));
ctx.font = fontReady
? `700 ${fontSize}px "Caveat", cursive`
: `${Math.round(fontSize * 0.75)}px "Segoe Script", "Bradley Hand", cursive`;
wrapText(params.caption, margin, h - margin * 0.35, w - margin * 2, fontSize * 1.15);
ctx.restore();
}
function wrapText(text, x, bottomY, maxWidth, lineHeight) {
const words = text.split(/\s+/);
const lines = [];
let line = '';
for (const word of words) {
const test = line ? `${line} ${word}` : word;
if (ctx.measureText(test).width > maxWidth && line) {
lines.push(line);
line = word;
} else {
line = test;
}
}
if (line) lines.push(line);
const clipped = lines.slice(-3); // at most 3 lines, bottom-anchored
const startY = bottomY - (clipped.length - 1) * lineHeight;
clipped.forEach((l, i) => ctx.fillText(l, x, startY + i * lineHeight));
}
async function draftCaption() {
captionStatus.textContent = 'drafting…';
try {
const dateStr = photoDate.toISOString().slice(0, 10);
const res = await fetch(`/api/caption?date=${dateStr}`);
const data = await res.json();
captionInput.value = data.caption || '';
params.caption = captionInput.value;
captionStatus.textContent = '';
render();
} catch {
captionStatus.textContent = '';
}
}
function showResult(html, isError) {
resultPanel.innerHTML = html;
resultPanel.classList.toggle('error', !!isError);
resultPanel.hidden = false;
}
function canvasToBlob() {
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.92));
}
async function upload() {
shareBtn.disabled = true;
showResult('uploading&hellip;');
try {
const blob = await canvasToBlob();
const res = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'image/jpeg' },
body: blob,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `upload failed (${res.status})`);
}
const data = await res.json();
const link = `${location.origin}${data.url}`;
showResult(`share link: <a href="${link}">${link}</a>`);
} catch (err) {
showResult(err.message || 'something went wrong', true);
} finally {
shareBtn.disabled = false;
}
}
fileInput.addEventListener('change', () => loadFile(fileInput.files[0]));
cameraInput.addEventListener('change', () => loadFile(cameraInput.files[0]));
cameraBtn.addEventListener('click', () => cameraInput.click());
libraryBtn.addEventListener('click', () => fileInput.click());
['dragover', 'dragenter'].forEach(evt =>
dropZone.addEventListener(evt, e => {
e.preventDefault();
dropZone.classList.add('drag-over');
})
);
['dragleave', 'dragend', 'drop'].forEach(evt =>
dropZone.addEventListener(evt, () => dropZone.classList.remove('drag-over'))
);
dropZone.addEventListener('drop', e => {
e.preventDefault();
loadFile(e.dataTransfer.files[0]);
});
[...templateRow.children].forEach(btn => {
btn.addEventListener('click', () => {
params.template = btn.dataset.template;
[...templateRow.children].forEach(b => b.classList.toggle('active', b === btn));
render();
});
});
captionInput.addEventListener('input', () => {
params.caption = captionInput.value;
render();
});
redraftBtn.addEventListener('click', draftCaption);
newPhotoBtn.addEventListener('click', () => {
originalImg = null;
locationLabel = null;
fileInput.value = '';
cameraInput.value = '';
editorCard.hidden = true;
dropCard.hidden = false;
resultPanel.hidden = true;
});
downloadBtn.addEventListener('click', async () => {
const blob = await canvasToBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `postcard-${Date.now()}.jpg`;
a.click();
URL.revokeObjectURL(url);
});
shareBtn.addEventListener('click', upload);
})();
+25
View File
@@ -0,0 +1,25 @@
services:
app:
image: ${IMAGE}
container_name: postcard
restart: unless-stopped
pull_policy: always
ports:
- "${HOST_PORT:-3098}:3098"
environment:
- TTL_HOURS=${TTL_HOURS:-72}
- MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-20971520}
- RATE_LIMIT_PER_HOUR=${RATE_LIMIT_PER_HOUR:-30}
# Day-context source for caption drafting — see README. Left unset,
# captions fall back to generic postcard-voice lines.
- DAY_SUMMARY_URL=${DAY_SUMMARY_URL:-}
# Reverse-geocoding endpoint for the stamp corner's location line.
# Defaults to the public Nominatim instance if unset.
- NOMINATIM_URL=${NOMINATIM_URL:-}
volumes:
# Named volume, not a bind mount into the build context — the SQLite
# db and image blobs live here and must survive image redeploys.
- postcard-data:/app/data
volumes:
postcard-data:
+117
View File
@@ -0,0 +1,117 @@
// Minimal EXIF reader — just enough to pull DateTimeOriginal and GPS lat/lon
// out of a JPEG, with no dependency (this project's "no build step" family
// doesn't pull in npm packages for the client). Not a general-purpose EXIF
// library: unsupported/malformed data is treated as "nothing found," never
// thrown, since the caller always has upload-date/no-location fallbacks.
window.PostcardExif = (() => {
const TYPE_SIZES = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
function readValue(view, type, count, offset, littleEndian) {
// Only the types actually used by the tags we read (ASCII, SHORT,
// LONG, RATIONAL) — anything else returns null rather than guessing.
if (type === 2) { // ASCII
let s = '';
for (let i = 0; i < count - 1; i++) s += String.fromCharCode(view.getUint8(offset + i));
return s;
}
if (type === 3) return view.getUint16(offset, littleEndian); // SHORT
if (type === 4) return view.getUint32(offset, littleEndian); // LONG
if (type === 5) { // RATIONAL
const num = view.getUint32(offset, littleEndian);
const den = view.getUint32(offset + 4, littleEndian);
return den === 0 ? 0 : num / den;
}
return null;
}
function readIfd(view, tiffStart, ifdOffset, littleEndian) {
const entries = {};
const count = view.getUint16(tiffStart + ifdOffset, littleEndian);
for (let i = 0; i < count; i++) {
const entryOffset = tiffStart + ifdOffset + 2 + i * 12;
const tag = view.getUint16(entryOffset, littleEndian);
const type = view.getUint16(entryOffset + 2, littleEndian);
const valueCount = view.getUint32(entryOffset + 4, littleEndian);
const size = (TYPE_SIZES[type] || 1) * valueCount;
const valueOffset = size > 4
? tiffStart + view.getUint32(entryOffset + 8, littleEndian)
: entryOffset + 8;
if (type === 5 && valueCount === 3) {
// GPSLatitude/Longitude: [degrees, minutes, seconds] rationals.
entries[tag] = [0, 1, 2].map(i => readValue(view, type, 2, valueOffset + i * 8, littleEndian));
} else {
entries[tag] = readValue(view, type, valueCount, valueOffset, littleEndian);
}
}
return entries;
}
function dmsToDecimal(dms, ref) {
if (!Array.isArray(dms) || dms.length !== 3) return null;
const [d, m, s] = dms;
let dec = d + m / 60 + s / 3600;
if (ref === 'S' || ref === 'W') dec = -dec;
return dec;
}
function parseDateTimeOriginal(str) {
// "YYYY:MM:DD HH:MM:SS" — EXIF's own format, always local time with no
// timezone info, so this is treated as a plain calendar date.
const m = /^(\d{4}):(\d{2}):(\d{2})/.exec(str || '');
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
function parse(buffer) {
const view = new DataView(buffer);
if (view.getUint16(0) !== 0xffd8) return null; // not a JPEG
let offset = 2;
while (offset < view.byteLength - 4) {
const marker = view.getUint16(offset);
if ((marker & 0xff00) !== 0xff00) break; // not a valid marker, bail out
if (marker === 0xffd8) { offset += 2; continue; }
if (marker === 0xffda || marker === 0xffd9) break; // start of scan / EOI — no more APPn markers past this
const segLength = view.getUint16(offset + 2);
if (marker === 0xffe1) {
const exifStart = offset + 4;
if (view.getUint32(exifStart) === 0x45786966) { // "Exif"
const tiffStart = exifStart + 6;
const littleEndian = view.getUint16(tiffStart) === 0x4949;
const ifd0Offset = view.getUint32(tiffStart + 4, littleEndian);
const ifd0 = readIfd(view, tiffStart, ifd0Offset, littleEndian);
let date = null;
let lat = null, lon = null;
if (ifd0[0x8769] != null) {
const exifIfd = readIfd(view, tiffStart, ifd0[0x8769], littleEndian);
date = parseDateTimeOriginal(exifIfd[0x9003]);
}
if (ifd0[0x8825] != null) {
const gpsIfd = readIfd(view, tiffStart, ifd0[0x8825], littleEndian);
lat = dmsToDecimal(gpsIfd[0x0002], gpsIfd[0x0001]);
lon = dmsToDecimal(gpsIfd[0x0004], gpsIfd[0x0003]);
}
return { date, lat, lon };
}
}
offset += 2 + segLength;
}
return null;
}
return {
// Returns { date: Date|null, lat: number|null, lon: number|null }, or
// null on any parse failure — never throws.
async read(file) {
try {
const buffer = await file.arrayBuffer();
return parse(buffer);
} catch {
return null;
}
},
};
})();
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="3" y="7" width="26" height="18" rx="1.5" fill="#0c0a08" stroke="#d9873f" stroke-width="1.5"/>
<path d="M3 8 L16 18 L29 8" fill="none" stroke="#d9873f" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 300 B

+6
View File
@@ -0,0 +1,6 @@
Caveat — SIL Open Font License, Version 1.1
Fetched from Google Fonts (fonts.gstatic.com), latin subset, variable
weight (400-700). See https://fonts.google.com/specimen/Caveat for the
canonical source and full license text:
https://openfontlicense.org
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>postcard</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<div class="header-row">
<h1>postcard</h1>
</div>
<p class="hint">turn a photo into something that looks like it was mailed</p>
<div class="card" id="drop-card">
<label class="drop-zone" id="drop-zone" for="file-input">
<span class="drop-zone-label">choose a photo, or drag one here</span>
<span class="drop-zone-sub">nothing leaves your browser until you say so</span>
</label>
<input type="file" id="file-input" accept="image/*" hidden>
<div class="inline-row source-row">
<button class="btn btn-small" id="camera-btn">take a photo</button>
<button class="btn btn-small" id="library-btn">choose from library</button>
</div>
<input type="file" id="camera-input" accept="image/*" capture="environment" hidden>
</div>
<div class="card" id="editor-card" hidden>
<div class="canvas-wrap">
<canvas id="canvas"></canvas>
</div>
<div class="controls">
<div class="control-group">
<div class="control-label">template</div>
<div class="preset-row" id="template-row">
<button class="preset-btn active" data-template="cream">plain cream</button>
<button class="preset-btn" data-template="torn">torn edge</button>
<button class="preset-btn" data-template="faded">faded color-era</button>
</div>
</div>
<div class="control-group">
<div class="control-label">
<span>caption</span>
<span class="control-value" id="caption-status"></span>
</div>
<textarea id="caption-input" class="caption-input" rows="2" maxlength="140" placeholder="wish you were here..."></textarea>
<div class="inline-row">
<button class="btn btn-small" id="redraft-btn">redraft</button>
</div>
</div>
</div>
<div class="actions actions-row">
<button class="btn" id="new-photo-btn">new photo</button>
<button class="btn btn-primary" id="download-btn">download</button>
</div>
<div class="actions actions-row">
<button class="btn" id="share-btn">generate share link</button>
</div>
<div id="result-panel" class="result-panel" hidden></div>
</div>
</div>
<script src="exif.js"></script>
<script src="app.js"></script>
</body>
</html>
+1252
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "postcard-server",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Small server for postcard: composited-image storage behind opaque links, plus day-context caption drafting",
"scripts": {
"start": "node --env-file-if-exists=../.env src/index.js",
"dev": "node --env-file-if-exists=../.env --watch src/index.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2"
}
}
+55
View File
@@ -0,0 +1,55 @@
import { config } from './config.js'
// Remix of real fragments from the day, not a free-text model call — per
// PROPOSAL.md's non-goal on LLM-generated captions. Always returns
// something (never an empty field): falls back to generic postcard-voice
// lines when no day-context source is configured or it has nothing for
// that date.
const FALLBACKS = [
'Wish you were here. Mostly wasn\'t.',
'Having a time. More on that later, maybe.',
'Sending this before I talk myself out of it.',
'Quiet one. Thinking of you anyway.',
]
function pick(seedStr, arr) {
let h = 0
for (let i = 0; i < seedStr.length; i++) h = (h * 31 + seedStr.charCodeAt(i)) >>> 0
return arr[h % arr.length]
}
async function fetchDaySummary(dateStr) {
if (!config.daySummaryUrl) return null
try {
const url = `${config.daySummaryUrl}?date=${encodeURIComponent(dateStr)}`
const res = await fetch(url, { signal: AbortSignal.timeout(3000) })
if (!res.ok) return null
const data = await res.json() // { git?: string, spotify?: string, note?: string }
// goonk's /api/day-summary returns HTTP 200 with an `error` body for a
// missing/malformed date instead of a non-2xx status — treat that the
// same as "no summary" rather than reading undefined fragments.
if (data && typeof data.error === 'string') return null
return data
} catch {
return null
}
}
const SHAPES = [
{ needs: ['git', 'spotify'], build: ({ git, spotify }) => `Spent the day ${git}. ${spotify} the whole time.` },
{ needs: ['git', 'spotify'], build: ({ git, spotify }) => `${spotify}. ${git}, mostly.` },
{ needs: ['git'], build: ({ git }) => `Spent the day ${git}.` },
{ needs: ['spotify'], build: ({ spotify }) => `${spotify} all day.` },
{ needs: ['note'], build: ({ note }) => `Been thinking about "${note}."` },
]
export async function draftCaption(dateStr) {
const summary = await fetchDaySummary(dateStr)
const fragments = { git: summary?.git || null, spotify: summary?.spotify || null, note: summary?.note || null }
const usable = SHAPES.filter(shape => shape.needs.every(key => fragments[key]))
if (usable.length === 0) return pick(dateStr, FALLBACKS)
const shape = pick(dateStr + 'shape', usable)
return shape.build(fragments)
}
+25
View File
@@ -0,0 +1,25 @@
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
import { sweepRateLimitWindows } from './ratelimit.js'
// Interval-based sweep, not push-based — deletes blobs + rows past
// expires_at regardless of whether they were ever viewed, matching
// latent/wisp's "deliberately dumb" precedent.
export function sweepExpiredPostcards() {
const now = Math.floor(Date.now() / 1000)
const expired = db.prepare(`SELECT id FROM postcards WHERE expires_at < ?`).all(now)
for (const { id } of expired) {
fs.rm(blobPath(id), { force: true }, () => {})
}
if (expired.length > 0) {
db.prepare(`DELETE FROM postcards WHERE expires_at < ?`).run(now)
}
sweepRateLimitWindows()
}
export function startCleanupJob() {
return setInterval(sweepExpiredPostcards, config.cleanupIntervalSeconds * 1000)
}
+22
View File
@@ -0,0 +1,22 @@
import path from 'node:path'
export const config = {
port: Number(process.env.PORT ?? 3098),
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
// Shorter than latent's — a postcard link is meant to be opened once by
// the recipient, not browsed later, per PROPOSAL.md's retention question.
ttlHours: Number(process.env.TTL_HOURS ?? 72),
maxUploadBytes: Number(process.env.MAX_UPLOAD_BYTES ?? 20 * 1024 * 1024), // 20MB
cleanupIntervalSeconds: Number(process.env.CLEANUP_INTERVAL_SECONDS ?? 60 * 15),
// Basic IP-based throttle so /api/upload can't become an open file host.
rateLimitPerHour: Number(process.env.RATE_LIMIT_PER_HOUR ?? 30),
// Optional day-context source for caption drafting — goonk's live
// GET {base}/api/day-summary?date=YYYY-MM-DD -> { git, spotify, note? }.
// No code-level default; set via .env. Left unset, caption generation
// falls back to generic postcard-voice lines.
daySummaryUrl: process.env.DAY_SUMMARY_URL ?? '',
}
+24
View File
@@ -0,0 +1,24 @@
import Database from 'better-sqlite3'
import fs from 'node:fs'
import path from 'node:path'
import { config } from './config.js'
fs.mkdirSync(config.dataDir, { recursive: true })
fs.mkdirSync(path.join(config.dataDir, 'blobs'), { recursive: true })
export const db = new Database(path.join(config.dataDir, 'postcard.db'))
db.pragma('journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS postcards (
id TEXT PRIMARY KEY,
blob_path TEXT NOT NULL,
mime TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
`)
export function blobPath(id) {
return path.join(config.dataDir, 'blobs', id)
}
+55
View File
@@ -0,0 +1,55 @@
// Reverse geocoding for the stamp corner's location line — confirmed via
// PROPOSAL.md's open question that no reverse-geocoding code already
// exists in goonk/rewind/galr to reuse, so this talks to the public
// Nominatim (OpenStreetMap) API directly. No API key required, but its
// usage policy (https://operations.osmfoundation.org/policies/nominatim/)
// caps unauthenticated use at 1 request/sec and requires an identifying
// User-Agent — both enforced below.
// `||` not `??` — docker-compose's environment: block sets this to an
// empty string (not unset) when NOMINATIM_URL isn't provided in .env, and
// an empty string should still fall through to the real default.
const NOMINATIM_URL = process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org/reverse'
const USER_AGENT = 'postcard/0.1 (https://github.com/explewd/postcard)'
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // a coordinate's city/country doesn't change
const cache = new Map() // "lat,lon" (rounded) -> { label, ts }
let lastRequestAt = 0
function cacheKey(lat, lon) {
// Rounded to ~1km — plenty for a "City, Country" label, and it turns
// nearby-but-not-identical GPS fixes into cache hits.
return `${lat.toFixed(2)},${lon.toFixed(2)}`
}
function labelFromAddress(address) {
if (!address) return null
const place = address.city || address.town || address.village || address.municipality || address.county
const country = address.country
return [place, country].filter(Boolean).join(', ') || null
}
export async function reverseGeocode(lat, lon) {
const key = cacheKey(lat, lon)
const cached = cache.get(key)
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.label
// Serialize outgoing requests to respect Nominatim's 1 req/sec cap.
const wait = Math.max(0, 1100 - (Date.now() - lastRequestAt))
if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait))
lastRequestAt = Date.now()
try {
const url = `${NOMINATIM_URL}?format=jsonv2&lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&zoom=10&addressdetails=1&accept-language=en`
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) return null
const data = await res.json()
const label = labelFromAddress(data.address)
cache.set(key, { label, ts: Date.now() })
return label
} catch {
return null
}
}
+23
View File
@@ -0,0 +1,23 @@
import { randomBytes } from 'node:crypto'
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
// 128-bit random value, rendered as base62 — unguessable, since the link
// itself is the only credential (no accounts, per PROPOSAL.md).
function randomBase62(bits) {
const bytes = randomBytes(Math.ceil(bits / 8) + 4) // headroom for the mod-bias trim below
let value = 0n
for (const b of bytes) value = (value << 8n) | BigInt(b)
let out = ''
const base = BigInt(BASE62.length)
while (value > 0n) {
out = BASE62[Number(value % base)] + out
value /= base
}
return out.padStart(Math.ceil(bits / Math.log2(62)), '0')
}
export function newPostcardId() {
return randomBase62(128)
}
+28
View File
@@ -0,0 +1,28 @@
import express from 'express'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from './config.js'
import { apiRouter, imageRouter } from './routes.js'
import { sweepExpiredPostcards, startCleanupJob } from './cleanup.js'
const app = express()
// Deployed behind a reverse proxy in production — needed for accurate
// req.ip in the rate limiter.
app.set('trust proxy', true)
// Mounted before the static frontend and before any body parser: the
// upload route reads the raw request stream itself and must not have it
// consumed by express.json()/express.raw() first.
app.use('/api', apiRouter)
app.use('/', imageRouter)
const staticRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
app.use(express.static(staticRoot))
sweepExpiredPostcards()
startCleanupJob()
app.listen(config.port, () => {
console.log(`postcard server listening on :${config.port}`)
})
+28
View File
@@ -0,0 +1,28 @@
import { config } from './config.js'
// Deliberately dumb in-memory fixed-window counter, not a distributed
// rate limiter — this is a single-process toy server, same as latent/wisp.
const windows = new Map() // ip -> { count, windowStart }
export function checkRateLimit(ip) {
const now = Date.now()
const hourMs = 60 * 60 * 1000
const entry = windows.get(ip)
if (!entry || now - entry.windowStart > hourMs) {
windows.set(ip, { count: 1, windowStart: now })
return true
}
entry.count += 1
return entry.count <= config.rateLimitPerHour
}
// Prevents the Map from growing forever across long-running processes.
export function sweepRateLimitWindows() {
const now = Date.now()
const hourMs = 60 * 60 * 1000
for (const [ip, entry] of windows) {
if (now - entry.windowStart > hourMs) windows.delete(ip)
}
}
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express'
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
import { newPostcardId } from './ids.js'
import { checkRateLimit } from './ratelimit.js'
import { draftCaption } from './caption.js'
import { reverseGeocode } from './geocode.js'
export const apiRouter = Router()
export const imageRouter = Router()
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp'])
// GET /api/caption?date=YYYY-MM-DD — draft a one-line caption from that
// day's context. Editable client-side before it's ever composited in.
apiRouter.get('/caption', async (req, res) => {
const date = /^\d{4}-\d{2}-\d{2}$/.test(req.query.date) ? req.query.date : new Date().toISOString().slice(0, 10)
const caption = await draftCaption(date)
res.json({ caption })
})
// GET /api/geocode?lat=&lon= — "City, Country" for the stamp corner's
// location line, only ever called when the uploaded photo had EXIF GPS.
// Proxied server-side so the client never talks to Nominatim directly
// (keeps the 1req/sec throttling and User-Agent policy in one place).
apiRouter.get('/geocode', async (req, res) => {
const lat = Number(req.query.lat)
const lon = Number(req.query.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
res.status(400).json({ error: 'lat and lon required' })
return
}
const label = await reverseGeocode(lat, lon)
res.json({ label })
})
apiRouter.post('/upload', (req, res) => {
const ip = req.ip
if (!checkRateLimit(ip)) {
res.status(429).json({ error: 'too many uploads, try again later' })
return
}
const mime = (req.get('Content-Type') || '').split(';')[0].trim()
if (!ALLOWED_MIME.has(mime)) {
res.status(415).json({ error: 'unsupported image type' })
return
}
const contentLength = Number(req.get('Content-Length') ?? 0)
if (contentLength > config.maxUploadBytes) {
res.status(413).json({ error: 'file too large' })
return
}
const id = newPostcardId()
const dest = blobPath(id)
const writeStream = fs.createWriteStream(dest, { flags: 'wx' })
let bytesReceived = 0
let aborted = false
req.on('data', chunk => {
bytesReceived += chunk.length
if (bytesReceived > config.maxUploadBytes) {
aborted = true
writeStream.destroy()
req.destroy()
}
})
req.pipe(writeStream)
writeStream.on('error', () => {
fs.rm(dest, { force: true }, () => {})
if (!res.headersSent) res.status(500).json({ error: 'write failed' })
})
writeStream.on('finish', () => {
if (aborted) {
fs.rm(dest, { force: true }, () => {})
if (!res.headersSent) res.status(413).json({ error: 'file too large' })
return
}
const now = Math.floor(Date.now() / 1000)
const expiresAt = now + config.ttlHours * 3600
db.prepare(
`INSERT INTO postcards (id, blob_path, mime, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
).run(id, dest, mime, now, expiresAt)
res.json({ id, url: `/p/${id}` })
})
})
// No develop-later gate — this app's whole point is immediacy, unlike latent.
imageRouter.get('/p/:id', (req, res) => {
const postcard = db.prepare(`SELECT * FROM postcards WHERE id = ?`).get(req.params.id)
const now = Math.floor(Date.now() / 1000)
if (!postcard || postcard.expires_at < now || !fs.existsSync(postcard.blob_path)) {
res.status(404).send(renderPage('not found', '<p>this one\'s gone — wrong link, or it already expired.</p>'))
return
}
res.setHeader('Content-Type', postcard.mime)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
fs.createReadStream(postcard.blob_path).pipe(res)
})
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function renderPage(title, bodyHtml) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>postcard &mdash; ${escapeHtml(title)}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<div class="header-row"><h1>postcard</h1></div>
<div class="card">${bodyHtml}</div>
</div>
</body>
</html>`
}
+296
View File
@@ -0,0 +1,296 @@
/* Self-hosted (fonts/caveat-latin.woff2, SIL OFL — see fonts/OFL.txt) so
the caption webfont doesn't depend on fonts.googleapis.com at runtime. */
@font-face {
font-family: 'Caveat';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('fonts/caveat-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122;
}
:root {
--bg: #0c0a08;
--bg-alt: #14100c;
--text: #cbc3b8;
--muted: #6b6258;
--heading: #f2e9dc;
--accent: #d9873f;
--accent-dim: rgba(217, 135, 63, 0.12);
--accent-border:rgba(217, 135, 63, 0.28);
--border: rgba(255, 245, 230, 0.08);
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
--error: #ff6b6b;
--error-dim: rgba(255, 107, 107, 0.14);
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
font: 16px/1.6 var(--sans);
color: var(--text);
background: var(--bg);
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
#app {
width: 640px;
max-width: 100%;
margin: 0 auto;
padding: 32px 16px 64px;
}
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
p { margin: 0; }
/* ── Header ─────────────────────────────────────────────────────────── */
.header-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 8px;
}
.header-row h1 {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
text-align: center;
margin: 0;
}
.header-row h1::before {
content: '> ';
color: var(--muted);
font-weight: 400;
}
.hint {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
text-align: center;
margin-bottom: 32px;
}
/* ── Card ───────────────────────────────────────────────────────────── */
.card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
background: var(--bg-alt);
box-shadow: var(--shadow);
margin-bottom: 16px;
}
/* ── Drop zone ──────────────────────────────────────────────────────── */
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 48px 16px;
border: 1px dashed var(--border);
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--accent-border);
background: var(--accent-dim);
}
.drop-zone-label {
font-family: var(--mono);
font-size: 14px;
color: var(--text);
}
.source-row {
justify-content: center;
margin-top: 16px;
}
.drop-zone-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
/* ── Canvas ─────────────────────────────────────────────────────────── */
.canvas-wrap {
display: flex;
justify-content: center;
margin-bottom: 20px;
border-radius: 8px;
overflow: hidden;
background: #000;
}
#canvas {
max-width: 100%;
max-height: 60vh;
display: block;
}
/* ── Controls ───────────────────────────────────────────────────────── */
.controls {
display: flex;
flex-direction: column;
gap: 16px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.control-label {
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.control-value {
color: var(--accent);
text-transform: none;
letter-spacing: normal;
}
.preset-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preset-btn {
font-family: var(--mono);
font-size: 12px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
}
.preset-btn:hover { border-color: var(--accent-border); }
.preset-btn.active {
background: var(--accent-dim);
border-color: var(--accent-border);
color: var(--accent);
}
/* ── Caption input ──────────────────────────────────────────────────── */
.caption-input {
width: 100%;
resize: vertical;
font-family: var(--sans);
font-size: 14px;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.caption-input:focus {
outline: none;
border-color: var(--accent-border);
}
/* ── Inline rows ────────────────────────────────────────────────────── */
.inline-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
/* ── Actions / buttons ──────────────────────────────────────────────── */
.actions-row {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.btn {
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.01em;
}
.btn:hover { border-color: var(--accent-border); color: var(--heading); }
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: default; transform: none; }
.btn-small {
font-size: 11px;
padding: 6px 10px;
align-self: flex-start;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #000;
font-weight: 700;
}
.btn-primary:hover { background: #e59a53; border-color: #e59a53; color: #000; }
/* ── Result panel ───────────────────────────────────────────────────── */
.result-panel {
margin-top: 18px;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
font-family: var(--mono);
font-size: 12px;
color: var(--text);
text-align: center;
}
.result-panel a {
color: var(--accent);
word-break: break-all;
}
.result-panel.error { color: var(--error); border-color: var(--error-dim); }