3 Commits

Author SHA1 Message Date
MechaCat02
0aaaa75128 fix(copy): the task cards are handed out, not left on the table
Some checks failed
Audit / cargo audit (backend) (push) Failing after 8m38s
Audit / npm audit (frontend) (push) Successful in 1m16s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 35s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m47s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m1s
Checks / E2E — typecheck + lint (push) Failing after 57s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 8m31s
E2E / Cross-UA smoke matrix (push) Failing after 4m58s
"auf dem Kärtchen am Tisch" told guests to look somewhere the card is not. Each
guest gets their own, so the location is both wrong and unnecessary — the card
is already in their hand when they read this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:58:07 +02:00
MechaCat02
0a33189e7e test(loadtest): audit which real files the upload validator actually refuses
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m9s
Audit / npm audit (frontend) (push) Successful in 41s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m11s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Has been cancelled
Checks / E2E — typecheck + lint (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
E2E / Playwright E2E (chromium + webkit) (push) Has been cancelled
Checks / Frontend — vitest + svelte-check (push) Has been cancelled
"Will my photos be accepted?" was being answered by reasoning about the size
caps, which is the wrong method — size is only one of the paths that refuses a
file. The magic-byte allowlist, the decode budget (12000 px axis / 256 MiB
alloc, both code constants no setting can relax), the disk gate and the per-user
quota all reject too, and only the running server knows how they interact.

`acceptance-audit.mjs` pushes every file in the pool through the real endpoint
and groups the refusals by the server's own German message. Against the 945-file
wedding set with the raised limits it answered the question exactly: 935
accepted, and the only 10 refusals are the HEIC files.

Deliberately not a load test — no personas, no viewers, no think-time. It
measures admission, so it does not wait for the compression backlog to drain.

The sim stack gains two overridable vars, so the harness can be pointed at the
configuration actually deployed rather than the defaults: SIM_APP_IMAGE /
SIM_FE_IMAGE (audit a release image, not a local build) and KEEPSAKE_ENABLED,
which changes the disk gate and therefore changes what gets refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:42:28 +02:00
MechaCat02
5a33ab460f fix(copy): five texts that told guests the wrong thing
None of these are cosmetic — each one either misdescribes the UI or omits
something a guest needs to get back into their account.

JOIN. "Willkommen bei" + "<Event>" read as "Willkommen bei Hochzeit von …".
Adds the article. This deliberately couples the lead-in to EVENT_NAME's
grammatical gender and is wrong for "Willkommen bei der Sommerfest" — noted in
the markup, with the article-free alternative, for whoever reuses this. The
no-name fallback moves from "dem Event" to "Feier", or it would now render
"Willkommen bei der dem Event".

GUIDE / post actions. Named only the long-press, which is invisible: a guest has
to already know it exists to find it. Now names the three-dot button that is on
every card in list view — described as "die drei Punkte (⋯)" rather than a
glyph, because the icon renders HORIZONTALLY (three circles at cy=12) despite
the code comment calling it a kebab. Long-press stays in the text, because the
button exists ONLY in the list view: the grid tiles have `use:longpress` and no
button at all, so a menu-only instruction would strand anyone browsing in grid
mode. "anzeigen und speichern", not "herunterladen" — the action opens the
original inline in a new tab (Content-Disposition: inline, deliberate, it is the
only playable video source), so the guest saves it from there.

GUIDE / recovery. Said "Deinen PIN merken!", but `POST /recover` takes
{display_name, pin} — a guest who memorised four digits and forgot whether they
typed "Anna" or "Anna M." still cannot get in. Now names both. It also claimed
the PIN is "immer unter Mein Konto zu finden", which is false in the only case
that matters: /account renders it from local storage and falls back to "PIN
nicht gespeichert", so it is NOT on the new device you are trying to reach.

UPLOAD / photo-task game. Adds a persistent helper line under the caption box
rather than a second placeholder line: a `placeholder` attribute may not contain
line breaks (Safari collapses them), and a placeholder disappears exactly when
the guest starts typing and needs it. The hashtag is written plain on purpose —
`#fotoaufgabe3` is a DIFFERENT tag from `#fotoaufgabe`, so putting the number
inside it would turn twelve tasks into twelve unfilterable tags.

UPLOAD SHEET. "Öffne den Link in Safari oder Chrome" named a link that is not on
screen — the guest is already inside the app. Now names the situation (the
WhatsApp in-app browser the join link opens in) and the menu entry that escapes
it. Kept rather than removed: in that webview the camera and file picker can
silently do nothing, with no error and no operator to ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:42:13 +02:00
6 changed files with 235 additions and 8 deletions

View File

@@ -82,6 +82,8 @@ services:
COMPRESSION_WORKER_CONCURRENCY: '2' COMPRESSION_WORKER_CONCURRENCY: '2'
# Production disables comments for this event (docker-compose.yml, product decision). # Production disables comments for this event (docker-compose.yml, product decision).
COMMENTS_ENABLED: 'false' COMMENTS_ENABLED: 'false'
# Mirrors the deployed setting so the harness can audit against the real gate.
KEEPSAKE_ENABLED: ${SIM_KEEPSAKE:-true}
# The ONE deviation from production: enables /admin/__truncate so the harness can # The ONE deviation from production: enables /admin/__truncate so the harness can
# reset between runs. Never set on the real box. # reset between runs. Never set on the real box.
EVENTSNAP_TEST_MODE: '1' EVENTSNAP_TEST_MODE: '1'
@@ -102,7 +104,7 @@ services:
frontend: frontend:
# Shipped release image; `git diff v0.17.6 HEAD -- frontend/` is empty. # Shipped release image; `git diff v0.17.6 HEAD -- frontend/` is empty.
image: registry.mc02.dev/eventsnap/frontend:v0.17.6 image: ${SIM_FE_IMAGE:-registry.mc02.dev/eventsnap/frontend:v0.17.6}
cpuset: *cpuset cpuset: *cpuset
depends_on: depends_on:
- app - app

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* Acceptance audit: push EVERY file in the pool through the real upload validator
* and report exactly what the server refuses, and why.
*
* Answers one question — "with these settings, which of my photos would be turned
* away?" — and answers it empirically rather than by reasoning about limits. Size
* caps are only one of the paths that can refuse a file: the magic-byte allowlist,
* the decode budget (12000 px axis / 256 MiB alloc, both code constants), the disk
* gate and the per-user quota all reject too, and only the running server knows the
* interaction between them.
*
* Deliberately NOT a load test: no personas, no viewers, no think-time. It measures
* admission, so it does not wait for the compression backlog to drain.
*
* SIM_UPLOADERS=8 node e2e/loadtest/acceptance-audit.mjs
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
const API = `${BASE}/api/v1`;
const POOL = process.env.SIM_POOL_DIR ?? '/tmp/eventsnap-realpool';
const META = process.env.SIM_POOL_META ?? '/tmp/eventsnap-pool.json';
const ADMIN_PW = process.env.SIM_ADMIN_PW ?? 'admin-test-pw';
const CONC = parseInt(process.env.AUDIT_CONC ?? '4', 10);
// Spread across a few accounts, as a real event does — a single uploader would hit
// the per-user quota and hourly limit for reasons unrelated to the files themselves.
const UPLOADERS = parseInt(process.env.SIM_UPLOADERS ?? '8', 10);
const j = async (path, opts = {}) => {
const res = await fetch(`${API}${path}`, opts);
const text = await res.text();
let body;
try {
body = text ? JSON.parse(text) : undefined;
} catch {
body = text;
}
return { status: res.status, body };
};
async function main() {
const meta = JSON.parse(await readFile(META, 'utf8'));
console.log(`[pool] ${meta.length} files, ${(meta.reduce((a, f) => a + f.bytes, 0) / 1e9).toFixed(2)} GB`);
const admin = (
await j('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: ADMIN_PW }),
})
).body.jwt;
const cfg = (await j('/admin/config', { headers: { Authorization: `Bearer ${admin}` } })).body;
const shown = [
'max_image_size_mb',
'max_video_size_mb',
'upload_rate_per_hour',
'storage_quota_enabled',
'quota_enabled',
];
console.log(`[config] ${shown.map((k) => `${k}=${cfg[k]}`).join(' ')}`);
const stats = (await j('/admin/stats', { headers: { Authorization: `Bearer ${admin}` } })).body;
console.log(
`[disk] ${(stats.disk_free_bytes / 1e9).toFixed(1)} GB free of ${(stats.disk_total_bytes / 1e9).toFixed(1)} GB`
);
const guests = [];
for (let i = 0; i < UPLOADERS; i++) {
const r = await j('/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: `Audit ${i} ${randomUUID().slice(0, 4)}` }),
});
guests.push(r.body.jwt);
}
console.log(`[join] ${guests.length} uploaders\n`);
const results = [];
let done = 0;
const queue = [...meta];
const t0 = Date.now();
const worker = async (slot) => {
while (queue.length) {
const f = queue.shift();
if (!f) break;
const jwt = guests[slot % guests.length];
let buf;
try {
buf = await readFile(join(POOL, f.name));
} catch (e) {
results.push({ ...f, status: -1, msg: `read error: ${e}` });
continue;
}
const form = new FormData();
form.append('file', new Blob([buf], { type: f.magic }), f.name);
form.append('client_upload_id', randomUUID());
let status, body;
try {
const res = await fetch(`${API}/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
body: form,
});
status = res.status;
const t = await res.text();
try {
body = JSON.parse(t);
} catch {
body = t;
}
} catch (e) {
status = 0;
body = { message: String(e).slice(0, 80) };
}
results.push({
name: f.name,
bytes: f.bytes,
magic: f.magic,
w: f.w,
h: f.h,
status,
code: body?.code ?? body?.error,
msg: status >= 400 ? String(body?.message ?? '').slice(0, 110) : undefined,
});
if (++done % 100 === 0) {
const ok = results.filter((r) => r.status === 201).length;
console.log(
` ${done}/${meta.length} accepted ${ok} refused ${done - ok} (${((Date.now() - t0) / 1000).toFixed(0)}s)`
);
}
}
};
await Promise.all(Array.from({ length: CONC }, (_, i) => worker(i)));
// ── Report ────────────────────────────────────────────────────────────────
const ok = results.filter((r) => r.status === 201);
const bad = results.filter((r) => r.status !== 201);
const byReason = {};
for (const r of bad) {
const key = `${r.status} ${r.msg ?? r.code ?? '?'}`;
(byReason[key] ??= []).push(r);
}
console.log('\n' + '═'.repeat(74));
console.log('ACCEPTANCE AUDIT');
console.log('═'.repeat(74));
console.log(
`accepted ${ok.length}/${results.length} (${(ok.reduce((a, r) => a + r.bytes, 0) / 1e9).toFixed(2)} GB)`
);
console.log(`refused ${bad.length}\n`);
for (const [reason, rows] of Object.entries(byReason).sort((a, b) => b[1].length - a[1].length)) {
const sizes = rows.map((r) => r.bytes / 1024 / 1024);
console.log(` ${rows.length} x ${reason}`);
console.log(
` sizes ${Math.min(...sizes).toFixed(1)}${Math.max(...sizes).toFixed(1)} MB · types ${[...new Set(rows.map((r) => r.magic))].join(', ')}`
);
console.log(` e.g. ${rows.slice(0, 3).map((r) => r.name).join(', ')}`);
}
if (!bad.length) console.log(' ✓ nothing was refused');
const outDir = join(__dirname, 'results');
await mkdir(outDir, { recursive: true });
const out = join(outDir, `acceptance-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
await writeFile(out, JSON.stringify({ config: cfg, stats, results }, null, 2));
console.log(`\nfull detail → ${out}`);
console.log('═'.repeat(74));
}
main().catch((e) => {
console.error('audit failed:', e);
process.exit(1);
});

View File

@@ -41,8 +41,19 @@
{ {
kind: 'text', kind: 'text',
icon: '👆', icon: '👆',
title: 'Lange tippen für mehr', title: 'Mehr zu einem Foto',
body: 'Tippe lange auf ein Bild im Feed, um zusätzliche Aktionen zu öffnen — zum Beispiel das Original anzeigen oder eigene Beiträge löschen.' // Names the VISIBLE control first. Long-press was the only affordance mentioned here,
// and it is invisible — a guest has to already know it exists. The three-dot button
// is on every card in list view and is what most people will find.
//
// Both views are named on purpose: the button exists only in the LIST view
// (FeedListCard). The grid tiles have no button at all, only `use:longpress`, so a
// guest browsing in grid mode would be stranded by a menu-only instruction.
//
// "anzeigen und speichern", not "herunterladen": the action opens the original in a
// new tab (`Content-Disposition: inline`, deliberate — it is the only playable video
// source), so the guest saves it from there rather than getting a download.
body: 'Tippe oben rechts am Beitrag auf die drei Punkte (⋯), um weitere Aktionen zu öffnen: das Original in voller Auflösung anzeigen und speichern oder eigene Beiträge löschen. In der Kachel-Ansicht tippst du stattdessen lange auf ein Bild.'
}, },
{ {
kind: 'theme', kind: 'theme',
@@ -52,9 +63,18 @@
{ {
kind: 'text', kind: 'text',
icon: '🔑', icon: '🔑',
title: 'Deinen PIN merken!', title: 'Name und PIN merken!',
// Recovery needs BOTH the display name and the PIN (`POST /recover` takes
// {display_name, pin}), but this step only ever mentioned the PIN — so a guest who
// memorised four digits and forgot whether they typed "Anna" or "Anna M." still
// could not get back in.
//
// "solange du auf diesem Gerät angemeldet bleibst" replaces "ist immer … zu finden",
// which was false in exactly the case that matters: /account renders the PIN from
// local storage and falls back to "PIN nicht gespeichert", so it is NOT there on a
// new device — which is the only reason anyone needs it.
body: body:
'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto“ zu finden.' + 'Beim Registrieren hast du einen 4-stelligen PIN bekommen. Für die Anmeldung auf einem anderen Gerät brauchst du beides: genau den Namen, den du hier eingegeben hast, und diesen PIN. Notiere dir am besten beides — solange du auf diesem Gerät angemeldet bleibst, findest du sie unter „Mein Konto“.' +
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto“.' : '') (hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto“.' : '')
} }
]); ]);

View File

@@ -305,8 +305,14 @@
line of text and is never wrong. It lives here rather than in the root layout line of text and is never wrong. It lives here rather than in the root layout
because both layout banners are gated on `$showBottomNav`, which `/upload` turns because both layout banners are gated on `$showBottomNav`, which `/upload` turns
off — a banner there would never render on the composer. --> off — a banner there would never render on the composer. -->
<!-- "diese Seite", not "den Link": the guest is already inside the app when they read
this, so there is no link on screen for "den Link" to refer to. Naming the app
they most likely arrived from, and the menu entry that gets them out, turns a
hint they cannot act on into an instruction they can. -->
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400"> <p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
Nichts passiert beim Tippen? Öffne den Link in Safari oder Chrome. Nichts passiert beim Tippen? Dann bist du wahrscheinlich im Browser von WhatsApp o. Ä.
Öffne diese Seite in Safari oder Chrome — dort funktionieren Kamera und Galerie. (Im Menü
des In-App-Browsers: „In Safari öffnen“ bzw. „Im Browser öffnen“.)
</p> </p>
{/if} {/if}

View File

@@ -309,8 +309,15 @@
{/if} {/if}
{:else} {:else}
<!-- Normal join form --> <!-- Normal join form -->
<!-- The article is HARDCODED to match this event's name ("Hochzeit von …"), so the
lead-in and the <h1> below read as one sentence: "Willkommen bei der Hochzeit
von …". That couples this string to EVENT_NAME's grammatical gender — it is
wrong for "Willkommen bei der Sommerfest". Deliberate for a single event; if
this app is reused, either make the article configurable alongside EVENT_NAME
or drop back to an article-free lead-in ("Herzlich willkommen!"), which works
with any name. The fallback below must keep agreeing with whatever is chosen. -->
<p class="mb-1 text-center text-sm font-medium text-gray-500 dark:text-gray-400"> <p class="mb-1 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
Willkommen bei Willkommen bei der
</p> </p>
{#if eventName} {#if eventName}
<h1 <h1
@@ -320,8 +327,10 @@
{eventName} {eventName}
</h1> </h1>
{:else} {:else}
<!-- "Feier", not "dem Event": the lead-in above now carries the article, so the
old fallback would render "Willkommen bei der dem Event". -->
<h1 class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100"> <h1 class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100">
dem Event Feier
</h1> </h1>
{/if} {/if}
<p class="mb-6 text-center text-gray-600 dark:text-gray-400"> <p class="mb-6 text-center text-gray-600 dark:text-gray-400">

View File

@@ -308,6 +308,18 @@
rows="4" rows="4"
class="input resize-none text-sm" class="input resize-none text-sm"
></textarea> ></textarea>
<!-- Persistent helper, NOT a second placeholder line. Two reasons: a `placeholder`
attribute may not contain line breaks (Safari collapses them outright), and a
placeholder disappears the moment the guest starts typing — which is exactly when
they need to read what to write. This stays visible while they type.
The hashtag is written plain on purpose: `#fotoaufgabe3` would be a DIFFERENT tag
from `#fotoaufgabe`, so twelve tasks would produce twelve unfilterable tags. The
number belongs in the prose, the hashtag stays one word. -->
<p class="mt-1.5 text-xs leading-snug text-gray-500 dark:text-gray-400">
<span class="font-semibold text-gray-600 dark:text-gray-300">Fotoaufgabe?</span> Schreib
die Aufgabe dazu — die Nummer steht unten rechts auf dem Kärtchen — und setze den Hashtag
#fotoaufgabe.
</p>
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400"> <div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
{caption.length} / {MAX_CAPTION_LENGTH} {caption.length} / {MAX_CAPTION_LENGTH}
</div> </div>