Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
403a4e8
ECHO-1041 feat(platform): host events on the browser's person, and fi…
JorimTheuns Nov 10, 2026
9e78dbf
ECHO-1041 feat(frontend): one identity per host, and a typed onboardi…
JorimTheuns Nov 10, 2026
27a3492
ECHO-1047 feat(accounts): tasks summary names the next task's document
JorimTheuns Nov 10, 2026
34a4f18
ECHO-1047 feat: signing survives a reload and explains access errors
JorimTheuns Nov 10, 2026
2af950f
ECHO-1047 i18n: the access error strings in six more languages
JorimTheuns Nov 10, 2026
ed04852
ECHO-1047 fix: the tasks banner is a labelled region, not an alert
JorimTheuns Nov 10, 2026
505d1ef
ECHO-1047 feat: a reload reads as resumed, not only abandoned
JorimTheuns Nov 10, 2026
3a33f06
test: merge ECHO-1041 measurement into the combined onboarding test b…
JorimTheuns Nov 10, 2026
bd1ea8a
test: merge ECHO-1047 signing resume into the combined onboarding tes…
JorimTheuns Nov 10, 2026
22cc3c0
ECHO-1042 feat(platform): project templates, applied when the project…
JorimTheuns Nov 10, 2026
e104abf
ECHO-1042 feat(frontend): start from a template, straight after signup
JorimTheuns Nov 10, 2026
170cfb4
ECHO-1042 i18n: the template strings in eight languages, and one Dutc…
JorimTheuns Nov 10, 2026
7b93383
ECHO-1042 docs(go): the parity line without a dash
JorimTheuns Nov 10, 2026
a363309
test: merge ECHO-1042 project templates into the combined onboarding …
JorimTheuns Nov 10, 2026
65cf187
ECHO-1046 feat(platform): a rate-limit refusal says how long to wait
JorimTheuns Nov 10, 2026
d82aa66
ECHO-1046 feat(frontend): login says what went wrong and when to try …
JorimTheuns Nov 10, 2026
7b1dd47
ECHO-1046 chore(i18n): login and registration messages in every locale
JorimTheuns Nov 10, 2026
66cd01e
ECHO-1046 fix(frontend): Change email sits left under the resend link
JorimTheuns Nov 10, 2026
1a561e5
test: merge ECHO-1046 login messages into the combined onboarding tes…
JorimTheuns Nov 10, 2026
73ad806
ECHO-1043 feat(platform): setup suggestions from what the host says, …
JorimTheuns Nov 10, 2026
abfe6d4
ECHO-1043 feat(frontend): "Tell dembrane about your session" in the c…
JorimTheuns Nov 10, 2026
15c9916
ECHO-1043 i18n: setup suggestions in eight languages
JorimTheuns Nov 10, 2026
d4879a9
ECHO-1043 perf(frontend): load the setup suggestion blocks only where…
JorimTheuns Nov 10, 2026
03d68e2
test: merge ECHO-1043 proposed key terms into the combined onboarding…
JorimTheuns Nov 10, 2026
6eb8b25
ECHO-1043 i18n: catalog references after the lazy-load move
JorimTheuns Nov 10, 2026
0369d06
test: merge ECHO-1043 catalog references into the combined onboarding…
JorimTheuns Nov 10, 2026
6546014
ECHO-1043 feat(platform): suggestions read the project context up to …
JorimTheuns Nov 10, 2026
17c06d2
ECHO-1043 feat(frontend): one field, the project context, feeds "Sugg…
JorimTheuns Nov 10, 2026
60cbb42
ECHO-1043 i18n: the context-first suggestion strings in eight languages
JorimTheuns Nov 10, 2026
442fe4b
test: merge ECHO-1043 context-first suggestions into the combined onb…
JorimTheuns Nov 10, 2026
e622046
ECHO-1052 feat(platform): Verify and Explore prompts that work out of…
JorimTheuns Nov 10, 2026
29fde1a
ECHO-1052 feat(frontend): one clear button to Verify or Explore, and …
JorimTheuns Nov 10, 2026
e4f7251
ECHO-1052 test(platform): prompt comparison pack, old against new
JorimTheuns Nov 10, 2026
288c794
ECHO-1052 perf(frontend): load the Verify and Explore choice screens …
JorimTheuns Nov 10, 2026
d1b8836
Merge origin/echo-1043-proposed-key-terms into echo-1052-verify-explore
JorimTheuns Nov 10, 2026
04799ce
test: merge ECHO-1052 Verify and Explore into the combined onboarding…
JorimTheuns Nov 10, 2026
998d00f
ECHO-1053 feat(platform): templates by purpose, with Verify, Explore …
JorimTheuns Nov 10, 2026
7a508e5
ECHO-1053 feat(frontend): pick what your session is for
JorimTheuns Nov 10, 2026
a359a23
ECHO-1053 i18n: the template picker's own catalog, in eight languages
JorimTheuns Nov 10, 2026
351e330
ECHO-1053 style(frontend): the picker links on the spacing scale
JorimTheuns Nov 10, 2026
a9a11bb
Merge origin/echo-1053-purpose-templates into test/onboarding-all
JorimTheuns Nov 10, 2026
1df8be9
ECHO-1043 fix(platform): judge private addresses without regular expr…
JorimTheuns Nov 10, 2026
7386a9c
Merge origin/echo-1043-proposed-key-terms into echo-1052-verify-explore
JorimTheuns Nov 10, 2026
68bd296
ECHO-1052 fix(frontend): with Verify and Explore both on, the button …
JorimTheuns Nov 10, 2026
e15a789
ECHO-1052 fix(platform): Dutch prompts say je, not u
JorimTheuns Nov 10, 2026
968cc39
ECHO-1052 test(platform): duplicates keep asking for email, new proje…
JorimTheuns Nov 10, 2026
b5c2d47
ECHO-1046 fix(frontend): the blocked notice offers the sign-in code once
JorimTheuns Nov 10, 2026
75ba2db
ECHO-1046 i18n: catalog references after the Login move
JorimTheuns Nov 10, 2026
234bba9
Merge origin/echo-1052-verify-explore into echo-1053-purpose-templates
JorimTheuns Nov 10, 2026
2b64e64
ECHO-1053 fix(frontend): the name box example follows its label
JorimTheuns Nov 10, 2026
ceac946
ECHO-1053 test(platform): host scripts name the buttons the portal shows
JorimTheuns Nov 10, 2026
1242cbc
ECHO-1053 test(frontend): the harness shows the portal name box with …
JorimTheuns Nov 10, 2026
48c163a
chore(i18n): the German dashboard and portal say du
JorimTheuns Nov 10, 2026
fdbbdae
chore(frontend): the German onboarding cards and host guide say du
JorimTheuns Nov 10, 2026
2fc7989
chore(platform): German server messages and prompts say du
JorimTheuns Nov 10, 2026
7e7108a
chore(platform): the remaining German prompt copies say du
JorimTheuns Nov 10, 2026
0f6913f
Merge origin/echo-1046-login-messages into test/onboarding-all
JorimTheuns Nov 10, 2026
47db35a
Merge origin/echo-1053-purpose-templates into test/onboarding-all
JorimTheuns Nov 10, 2026
08d9019
Merge origin/echo-de-informal into test/onboarding-all
JorimTheuns Nov 10, 2026
bfa182e
ECHO-1046 i18n: the German login messages say du
JorimTheuns Nov 10, 2026
77c77b1
Merge origin/echo-1046-login-messages into test/onboarding-all
JorimTheuns Nov 10, 2026
87e0d4e
ECHO-1055 fix(frontend): the drop zone filters by its accept setting
JorimTheuns Nov 10, 2026
3522a5e
ECHO-1055 feat(platform): every prompt reads the project context with…
JorimTheuns Nov 10, 2026
663e620
ECHO-1055 feat(platform): a digest endpoint turns a host's material i…
JorimTheuns Nov 10, 2026
8b32353
ECHO-1055 feat(frontend): drop your material and dembrane sets up the…
JorimTheuns Nov 10, 2026
2d6ff60
ECHO-1055 fix(frontend): the digest review follows the grammar's icon…
JorimTheuns Nov 10, 2026
c95bebc
Merge origin/echo-1055-context-digest into test/onboarding-all
JorimTheuns Nov 10, 2026
eece75c
ECHO-1055 fix: escape every regex character in the attribute lookup
JorimTheuns Nov 10, 2026
847c299
Merge branch 'echo-1055-context-digest' into test/onboarding-all
JorimTheuns Nov 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions dembrane-go/FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Legend: ✅ built · � gated (needs echo backend, Sameer's OK) · 🖥️ desk

## Projects / workspaces
- ✅ List (cross-workspace flat picker, cached) · create · switch · default "Go Recordings" (auto-create, remembered)
- ⏳ Start from a template (web: ECHO-1042): the server applies the preset, and the app only has to send `template_key` (`conversation`, `series`, `event`, `upload` or `blank`) on `POST /api/v2/workspaces/:id/projects`, with the list from `GET /api/v2/project-templates`. Without a key a project starts bare, as the app's create does today.
- ✅ First-run "choose a workspace" onboarding
- ✅ Settings → open project editor (dashboard portal-editor link)
- 🖥️ Project settings/portal-editor (tags, flow, feature toggles, anonymize), access/visibility, clone, usage, webhooks, members/invites — desktop
Expand Down
280 changes: 280 additions & 0 deletions dembrane/frontend/e2e/digest-harness.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
import { expect, type Page, test } from "@playwright/test";
import {
DIGEST_RESULT_EN,
DIGEST_RESULT_NL,
} from "../src/components/project/digest/digest.fixture";
import {
makeDocx,
makePdf,
makePptx,
} from "../src/components/project/digest/extract/fixtures";

// ECHO-1055: "Start from your documents" on the template picker. Real generated PDF, DOCX
// and PPTX files go into the drop zone; the browser reads them, the digest endpoint is
// answered here. The entry row, the reading states, the review with every section, a
// section's provenance, an edited section, the failure fallback and the scanned-PDF
// refusal, at desktop and phone width, in English and Dutch. A network capture checks
// that only text leaves the browser. E2E_SHOTS=<dir> also saves screenshots.
const shots = process.env.E2E_SHOTS;
const sizes = [
{ height: 900, name: "desktop", width: 1280 },
{ height: 844, name: "phone", width: 390 },
];

const INVITATION = makePdf([
[
"Uitnodiging bewonersavond Kanaalzone-Oost",
"Gemeente Veldhoven-Noord en woningcorporatie Thuisbasis nodigen je uit.",
"Donderdag 4 december, 19.30 tot 21.30 uur",
"Locatie: Wijkcentrum De Schakel, Sluisweg 12",
],
["Aanmelden bij Ilse Verbeek, i.verbeek@thuisbasis.example of 06-41234567."],
]);
const AGENDA = makeDocx([
"Agenda bewonersavond Kanaalzone-Oost",
"19.30 Welkom door wethouder Ramon Dijkhuis",
"19.45 Ronde 1: Wat gaat goed in de Kanaalzone-Oost?",
"20.35 Ronde 2: Wat moet anders aan de groenstrook langs de Sluisweg?",
]);
const SLIDES = makePptx([
{
lines: [
"Bewonersavond Kanaalzone-Oost",
"Bewoners praten aan tafels in twee rondes",
],
},
{
lines: ["Ronde 1", "Wat gaat goed?"],
notes: ["Laat iedereen kort aan het woord."],
},
]);
const SCAN = makePdf([null, null]);

const files = {
agenda: {
buffer: Buffer.from(AGENDA),
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
name: "agenda.docx",
},
invitation: {
buffer: Buffer.from(INVITATION),
mimeType: "application/pdf",
name: "uitnodiging-bewonersavond.pdf",
},
scan: {
buffer: Buffer.from(SCAN),
mimeType: "application/pdf",
name: "scan.pdf",
},
slides: {
buffer: Buffer.from(SLIDES),
mimeType:
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
name: "programma.pptx",
},
};

type Answer = "result" | "fail" | "hold";

async function answerApi(page: Page, nl: boolean, answer: () => Answer) {
const sent: { url: string; body: string; type: string }[] = [];
page.on("request", (r) => {
if (r.method() !== "GET")
sent.push({
body: r.postData() ?? "",
type: r.headers()["content-type"] ?? "",
url: r.url(),
});
});
let release: (() => void) | null = null;
await page.route("**/v2/workspaces/w1/free-allowance**", (route) =>
route.fulfill({ json: { free: false } }),
);
await page.route(
"**/v2/workspaces/w1/project-setup/digest",
async (route) => {
const a = answer();
if (a === "hold")
await new Promise<void>((r) => {
release = r;
});
if (a === "fail")
return route.fulfill({
json: { code: "project.setup_suggestions_unavailable" },
status: 503,
});
return route.fulfill({ json: nl ? DIGEST_RESULT_NL : DIGEST_RESULT_EN });
},
);
await page.route("**/v2/workspaces/w1/projects", (route) =>
route.fulfill({ json: { id: "p9", name: "x", workspace_id: "w1" } }),
);
return { release: () => release?.(), sent };
}

for (const size of sizes)
for (const lang of ["en-US", "nl-NL"])
test(`digest harness, ${size.name}, ${lang}`, async ({ page }) => {
test.setTimeout(120_000);
const nl = lang === "nl-NL";
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.setViewportSize({ height: size.height, width: size.width });
await page.addInitScript((l) => {
localStorage.setItem("dembrane-language", l);
}, lang);
let answer: Answer = "hold";
const api = await answerApi(page, nl, () => answer);
const shot = async (name: string, full = true) => {
if (shots)
await page.screenshot({
fullPage: full,
path: `${shots}/${size.name}-${lang}-${name}.png`,
});
};
const noSideScroll = async () =>
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);

await page.goto(
`/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new`,
);
const entry = page.getByTestId("project-digest-entry");
await expect(entry).toBeVisible();
await expect(page.getByTestId("template-picker")).toBeVisible();
await expect(page.getByTestId("project-digest-privacy")).toBeVisible();
await shot("01-entry", false);
await noSideScroll();

// The scanned PDF is refused on this device; nothing is sent.
const input = page.locator(
"[data-testid=project-digest-dropzone] input[type=file]",
);
await input.setInputFiles([files.scan]);
await expect(
page.getByTestId("project-digest-file-0-reason"),
).toContainText(nl ? "scan" : "scan");
await expect(page.getByTestId("template-picker")).toBeVisible();
await shot("02-scanned-refused", false);
expect(api.sent.filter((s) => s.url.includes("digest"))).toHaveLength(0);

// Three real files: read here, then "Reading your material" while held.
await input.setInputFiles([files.invitation, files.agenda, files.slides]);
await expect(
page.getByTestId("project-digest-progress-status"),
).toContainText(
nl ? "Je materiaal wordt gelezen" : "Reading your material",
);
await expect(page.getByTestId("template-picker")).toBeHidden();
await shot("03-reading", false);
await noSideScroll();
answer = "result";
api.release();

const review = page.getByTestId("project-digest-review");
await expect(review).toBeVisible();
for (const id of [
"name",
"goal",
"context",
"key-terms",
"template",
"tags",
"verify",
"name-label",
"explore",
"host-steps",
])
await expect(
page.getByTestId(`project-digest-section-${id}`),
).toBeVisible();
await page.waitForTimeout(300);
await shot("04-review");
await noSideScroll();

// Provenance: the source on the button, the words on focus.
const from = page.getByTestId("project-digest-provenance-goal");
await from.scrollIntoViewIfNeeded();
await from.focus();
await expect(page.getByRole("tooltip")).toContainText("Samen met jou");
await page.waitForTimeout(200);
await shot("05-provenance", false);

// Edited: the goal rewritten, the Explore question switched off, another template.
await page
.getByTestId("project-digest-goal")
.fill(
nl
? "Weten wat bewoners willen aan de Sluisweg."
: "Learn what residents want at the Sluisweg.",
);
await page
.getByTestId("project-digest-section-explore-switch")
.evaluate((el) => (el as HTMLInputElement).click());
await page.getByTestId("project-digest-runner-up-world_cafe").click();
await page
.getByTestId("project-digest-section-goal")
.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
await shot("06-edited");

// The network: only text and names went out, never a file's bytes.
const digestCalls = api.sent.filter((s) =>
s.url.includes("project-setup/digest"),
);
expect(digestCalls).toHaveLength(1);
for (const s of api.sent) {
expect(s.type).not.toContain("multipart");
expect(s.body).not.toContain("%PDF");
expect(s.body).not.toContain("PK\u0003\u0004");
}
const body = JSON.parse(digestCalls[0]?.body ?? "{}");
expect(body.sources.map((x: { name: string }) => x.name)).toEqual([
"uitnodiging-bewonersavond.pdf",
"agenda.docx",
"programma.pptx",
]);
expect(Object.keys(body.sources[0]).sort()).toEqual([
"kind",
"name",
"text",
]);
expect(body.sources[0].text).toContain("Wijkcentrum De Schakel");
expect(body.sources[2].text).toContain("Speaker notes: Laat iedereen");

// Create sends the template and what is on, in one request.
await page.getByTestId("project-digest-create").click();
await expect(page.getByTestId("harness-landed")).toBeAttached();
const create = api.sent.find((s) =>
s.url.endsWith("/v2/workspaces/w1/projects"),
);
const created = JSON.parse(create?.body ?? "{}");
expect(created.template_key).toBe("world_cafe");
expect(created.digest.goal).toContain("Sluisweg");
expect(created.digest.explore_prompt).toBeUndefined();
expect(created.digest.key_terms).toHaveLength(5);

// The failure fallback: the picker with the text in the context.
answer = "fail";
await page.goto(
`/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new`,
);
await page
.locator("[data-testid=project-digest-dropzone] input[type=file]")
.setInputFiles([files.agenda]);
await expect(page.getByTestId("project-digest-fallback")).toBeVisible();
await expect(page.getByTestId("template-picker")).toBeVisible();
await shot("07-fallback", false);
await page.getByTestId("template-card-resident_evening").click();
await page.getByTestId("create-project-template-continue").click();
await expect(
page.getByLabel(nl ? "Projectcontext" : "Project context"),
).toHaveValue(/Ronde 1: Wat gaat goed/);
await shot("08-fallback-context");

expect(errors).toEqual([]);
});
26 changes: 25 additions & 1 deletion dembrane/frontend/e2e/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,31 @@ export const flows: Record<string, Flow> = {
params: project,
variants: [...EMPTY],
},
"/w/:workspaceId/projects/new": { params: { workspaceId: "$workspace" } },
"/w/:workspaceId/projects/new": {
params: { workspaceId: "$workspace" },
// The template picker by purpose: a verb filter, all cards, then the name step with
// "What this sets up" and "More options" open (ECHO-1042, ECHO-1053).
states: [
{ clicks: ["template-filter-verb-decide"], name: "filtered" },
{ clicks: ["template-filter-show-all"], name: "all templates" },
{
clicks: [
"template-card-resident_evening",
"create-project-template-continue",
"template-setup-summary-toggle",
],
name: "name",
},
{
clicks: [
"template-card-resident_evening",
"create-project-template-continue",
"create-project-more-options-toggle",
],
name: "more options",
},
],
},
"/w/:workspaceId/settings/*": {
params: {
"*": ["general", "members", "training", "billing", "danger"],
Expand Down
66 changes: 66 additions & 0 deletions dembrane/frontend/e2e/register-verify-login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createHmac } from "node:crypto";
import { expect, test } from "@playwright/test";

// Register, verify, log in: the whole way in for a new person, through the real UI and API.
//
// The verification email is not readable from here (locally it stays in the API's memory),
// so the spec mints the same link the email carries: Better Auth's verification token is an
// HS256 JWT of { email } signed with the API's AUTH_SECRET. Pass that secret as
// E2E_AUTH_SECRET; without it the spec skips. Needs the dashboard (E2E_BASE_URL, default
// :5173) talking to a running API with a migrated database. Not a PR gate.
const AUTH_SECRET = process.env.E2E_AUTH_SECRET ?? "";

const base64url = (value: string | Buffer) =>
Buffer.from(value).toString("base64url");

function verificationToken(email: string, secret: string): string {
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: "HS256" }));
const payload = base64url(
JSON.stringify({ email: email.toLowerCase(), exp: now + 3600, iat: now }),
);
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
return `${header}.${payload}.${signature}`;
}

test.skip(!AUTH_SECRET, "set E2E_AUTH_SECRET to the API's AUTH_SECRET");

test("a new person registers, verifies their email and logs in", async ({
page,
}) => {
test.setTimeout(60_000);
const email = `e2e-${Date.now()}@example.com`;
const password = "Abcdefgh1!xyz";

await page.goto("/register");
await page.getByTestId("auth-register-first-name-input").fill("E2E");
await page.getByTestId("auth-register-email-input").fill(email);
await page.getByTestId("auth-register-terms-checkbox").check();
await page.getByTestId("auth-register-continue-button").click();
await page.getByTestId("auth-register-password-input").fill(password);
await page.getByTestId("auth-register-confirm-password-input").fill(password);
await page.getByTestId("auth-register-submit-button").click();
await expect(page.getByTestId("auth-register-verify-step")).toContainText(
email,
);

// Logging in before verifying is refused with the resend offer.
await page.goto("/login");
await page.getByTestId("auth-login-email-input").fill(email);
await page.getByTestId("auth-login-password-input").fill(password);
await page.getByTestId("auth-login-submit-button").click();
await expect(page.getByTestId("auth-resend-verification")).toBeVisible();

await page.goto(
`/verify-email?token=${verificationToken(email, AUTH_SECRET)}`,
);
await page.waitForURL(/\/login\?verified=1/, { timeout: 15_000 });

await page.getByTestId("auth-login-email-input").fill(email);
await page.getByTestId("auth-login-password-input").fill(password);
await page.getByTestId("auth-login-submit-button").click();
// A new account lands on onboarding (or the home list once that is done).
await page.waitForURL(/\/(onboarding|o)\b/, { timeout: 20_000 });
});
Loading
Loading