diff --git a/dembrane/frontend/e2e/setup-suggestions-harness.spec.ts b/dembrane/frontend/e2e/setup-suggestions-harness.spec.ts
new file mode 100644
index 000000000..59763ad33
--- /dev/null
+++ b/dembrane/frontend/e2e/setup-suggestions-harness.spec.ts
@@ -0,0 +1,146 @@
+import { expect, type Page, test } from "@playwright/test";
+import { SUGGESTIONS } from "../src/components/project/setup-suggestions/setupSuggestions.fixture";
+
+// ECHO-1043: "Suggest key terms" under the project context in the create flow's name step
+// (the "conversation" template, which the fixture's proposals change), in each state (idle, loading, results, empty, error), at desktop and phone width, in English
+// and Dutch. Reuses the templates harness page. E2E_SHOTS=
also saves screenshots.
+const shots = process.env.E2E_SHOTS;
+const sizes = [
+ { height: 900, name: "desktop", width: 1280 },
+ { height: 844, name: "phone", width: 390 },
+];
+const AGENDA =
+ "Bewonersavond Strijp-S met wethouder Marieke de Wolf, Woonbedrijf en Ennatuurlijk. 12 tafels.";
+
+type Answer = "results" | "empty" | "error" | "pending" | "url_note";
+
+async function answerApi(page: Page, answer: () => Answer) {
+ await page.route("**/v2/workspaces/w1/free-allowance**", (route) =>
+ route.fulfill({ json: { free: false } }),
+ );
+ await page.route("**/v2/workspaces/w1/project-setup/suggestions", (route) => {
+ const a = answer();
+ if (a === "pending") return; // never answered: the loading state stays
+ if (a === "error")
+ return route.fulfill({
+ json: { code: "project.setup_suggestions_unavailable" },
+ status: 503,
+ });
+ if (a === "empty")
+ return route.fulfill({
+ json: { key_terms: [], max_key_terms: 30, notes: [], settings: [] },
+ });
+ return route.fulfill({
+ json: {
+ ...SUGGESTIONS,
+ notes: a === "url_note" ? ["url_not_read"] : [],
+ },
+ });
+ });
+}
+
+for (const size of sizes)
+ for (const lang of ["en-US", "nl-NL"])
+ test(`setup suggestions, ${size.name}, ${lang}`, async ({ page }) => {
+ 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 = "pending";
+ await answerApi(page, () => answer);
+ const block = page.getByTestId("setup-suggestions");
+ const shot = async (name: string) => {
+ if (!shots) return;
+ await block.scrollIntoViewIfNeeded();
+ await page.screenshot({
+ fullPage: true,
+ path: `${shots}/${size.name}-${lang}-${name}.png`,
+ });
+ await block.screenshot({
+ path: `${shots}/${size.name}-${lang}-${name}-block.png`,
+ });
+ };
+ const noSideScroll = async () =>
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth,
+ ),
+ ).toBe(true);
+ const suggest = () =>
+ page.getByTestId("setup-suggestions-submit").click();
+
+ await page.goto(
+ `/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new?template=conversation`,
+ );
+ await expect(block).toBeVisible();
+ const context = page.getByRole("textbox").nth(1);
+ await shot("0-empty");
+ await context.fill(AGENDA);
+ await shot("1-idle");
+ await noSideScroll();
+
+ answer = "pending";
+ await suggest();
+ await expect(
+ page.getByTestId("setup-suggestions-status"),
+ ).not.toBeEmpty();
+ // Create is never held up by a running request.
+ await page.getByRole("textbox").first().fill("Bewonersavond");
+ await expect(page.getByTestId("create-project-submit")).toBeEnabled();
+ await shot("2-loading");
+
+ // A fresh page per state: the pending request above never settles.
+ const fresh = async (a: Answer) => {
+ answer = a;
+ await page.goto(
+ `/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new?template=conversation`,
+ );
+ await page.getByRole("textbox").nth(1).fill(AGENDA);
+ if (a === "url_note") {
+ await page.getByTestId("setup-suggestions-link-toggle").click();
+ await page
+ .getByTestId("setup-suggestions-url")
+ .fill("https://example.org/avond");
+ }
+ await suggest();
+ };
+
+ await fresh("results");
+ await expect(page.getByTestId("setup-suggestions-results")).toBeVisible();
+ await page.getByTestId("setup-suggestions-term-toggle-1").click();
+ await page
+ .getByTestId("setup-suggestions-setting-is_verify_enabled")
+ .check();
+ // The reason shows on keyboard focus, as on hover.
+ await page.mouse.move(0, 0);
+ await page.getByTestId("setup-suggestions-term-toggle-0").focus();
+ await page.keyboard.press("Shift+Tab");
+ await page.keyboard.press("Tab");
+ await expect(
+ page.getByRole("tooltip", { name: SUGGESTIONS.key_terms[0]?.reason }),
+ ).toBeVisible();
+ await page.waitForTimeout(300);
+ await shot("3-results");
+ await noSideScroll();
+
+ await fresh("url_note");
+ await expect(
+ page.getByTestId("setup-suggestions-url-note"),
+ ).toBeVisible();
+ await page.getByTestId("setup-suggestions-accept-all").click();
+ await page.mouse.move(0, 0);
+ await page.waitForTimeout(300);
+ await shot("4-accepted-all-url-note");
+
+ await fresh("empty");
+ await expect(page.getByTestId("setup-suggestions-empty")).toBeVisible();
+ await shot("5-empty");
+
+ await fresh("error");
+ await expect(page.getByTestId("setup-suggestions-error")).toBeVisible();
+ await shot("6-error");
+ await noSideScroll();
+ expect(errors).toEqual([]);
+ });
diff --git a/dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx b/dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx
index 5dbb518b8..e970a9425 100644
--- a/dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx
+++ b/dembrane/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx
@@ -14,6 +14,14 @@ import {
import { CaretDownIcon, CaretUpIcon, CheckIcon } from "@phosphor-icons/react";
import { useId, useMemo, useState } from "react";
import { SuggestionCardFrame } from "@/components/common/SuggestionCardFrame";
+import {
+ ADDED_MARK,
+ asDisplayString,
+ fieldLabel,
+ isEmptyValue,
+ REMOVED_MARK,
+ ValueText,
+} from "@/components/common/settingChange";
import { toast } from "@/components/common/Toaster";
import {
buildWordDiff,
@@ -46,38 +54,6 @@ export type ProjectUpdateSuggestion = {
changes: ProjectUpdateSuggestionChange[];
};
-// Hosts see the portal editor's language, not backend field names.
-const FIELD_LABELS: Record string> = {
- anonymize_transcripts: () => t`Anonymise transcripts`,
- context: () => t`Project context`,
- conversation_title_prompt: () => t`Title guidance`,
- default_conversation_ask_for_participant_email: () =>
- t`Ask participants for their email`,
- default_conversation_ask_for_participant_name: () =>
- t`Ask participants for their name`,
- default_conversation_description: () => t`Portal description`,
- default_conversation_finish_text: () => t`Portal finish message`,
- default_conversation_title: () => t`Portal title`,
- default_conversation_transcript_prompt: () => t`Key terms`,
- default_conversation_tutorial_slug: () => t`Portal tutorial`,
- enable_ai_title_and_tags: () => t`Automatic titles and draft tags`,
- get_reply_mode: () => t`Reply mode`,
- get_reply_prompt: () => t`Reply guidance`,
- host_guide: () => t`Host guide`,
- image_generation_model: () => t`Image style`,
- is_conversation_allowed: () => t`Portal open for new conversations`,
- is_get_reply_enabled: () => t`Replies to participants`,
- is_project_notification_subscription_allowed: () =>
- t`Participant updates subscription`,
- is_verify_enabled: () => t`Participant verification`,
- is_verify_on_finish_enabled: () => t`Verification on the finish screen`,
- language: () => t`Portal language`,
- legal_basis: () => t`Legal basis`,
- name: () => t`Project name`,
- selected_verification_key_list: () => t`Verification topics`,
- tutorial_slug: () => t`Tutorial`,
-};
-
/**
* What a field actually touches, in one sentence. Only fields whose reach is
* wider than the setting screen they live on: the host cannot be expected to
@@ -96,10 +72,6 @@ const FIELD_IMPACT: Record string> = {
t`This sets the language participants see in the portal, including the questions they are asked.`,
};
-const fieldLabel = (field: string) =>
- FIELD_LABELS[field]?.() ??
- field.replace(/^default_conversation_/, "").replace(/_/g, " ");
-
const fieldImpact = (field: string) => FIELD_IMPACT[field]?.() ?? null;
/** Names the fields in the headline. Past three, the tail becomes a count so
@@ -112,85 +84,15 @@ const summarizeFields = (fields: string[]) => {
return t`${names} and ${extra} more`;
};
-const isEmptyValue = (value: unknown) =>
- value === null || value === undefined || value === "";
-
/** Coarse pointers need a 44px target; Mantine's xs controls are 30px. */
const COARSE_TAP_TARGET = "[@media(pointer:coarse)]:min-h-11";
const VALUE_BOX =
"max-h-64 overflow-y-auto border-x-0 border-y border-solid border-[color:var(--app-rule-color)] px-2 py-1.5";
-/**
- * The three ways a fragment can read inside a value box, and the only place
- * they are defined. The word diff and the short-value line both pull from
- * here, so a marking can never drift between the long and short paths.
- *
- * Each mark carries a shape as well as a colour: the removal is struck
- * through, the addition is underlined. A background alone does not survive
- * greyscale (the green sits at about 97% luminance) or a colour-blind reader,
- * and the addition used to have nothing but that background.
- *
- * Unchanged text recedes on purpose. Marked and unmarked prose used to sit at
- * the same contrast, so a two word edit inside a paragraph read as one flat
- * block; dropping the surround to a mid grey makes the edit the first thing
- * the eye lands on, without inventing a colour the card does not already use.
- */
-const REMOVED_MARK =
- "bg-[var(--app-danger-tint)] text-[var(--app-danger-on-tint)] line-through decoration-[var(--app-danger)] decoration-2";
-const ADDED_MARK =
- "bg-[var(--app-success-tint)] text-[var(--app-success)] underline decoration-[var(--app-success)] decoration-2";
+// Unchanged text recedes so the marked words (common/settingChange) come forward.
const UNCHANGED_TEXT = "text-[var(--app-muted)]";
-const asDisplayString = (value: unknown) => {
- if (typeof value === "string") return value;
- if (value === null || value === undefined) return "";
- if (typeof value === "object") return JSON.stringify(value);
- return String(value);
-};
-
-const ValueText = ({
- value,
- kind,
-}: {
- value: unknown;
- kind: "old" | "new";
-}) => {
- if (isEmptyValue(value)) {
- // A description of the value, not the value itself. Italics used to carry
- // that difference and carried it to nobody; the words say it instead, and
- // the dimmed colour keeps them from reading as content.
- return (
-
- {kind === "old" ? (
- nothing here yet
- ) : (
- emptied out
- )}
-
- );
- }
- const display =
- typeof value === "boolean" ? (
- value ? (
- on
- ) : (
- off
- )
- ) : (
- asDisplayString(value)
- );
- return (
-
- {display}
-
- );
-};
-
/**
* One side of a word diff. The same chunk list renders both sides: the
* "current" side hides additions and strikes removals, the "proposed" side
diff --git a/dembrane/frontend/src/components/common/settingChange.tsx b/dembrane/frontend/src/components/common/settingChange.tsx
new file mode 100644
index 000000000..75f116172
--- /dev/null
+++ b/dembrane/frontend/src/components/common/settingChange.tsx
@@ -0,0 +1,117 @@
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import { Text } from "@mantine/core";
+
+/**
+ * How a proposed settings change reads, shared by the assistant's proposal card
+ * (chat/ProjectUpdateSuggestionCard) and the setup suggestions in the create flow
+ * (project/setup-suggestions): the field's name in the portal editor's words, and a
+ * value marked as the one being replaced or the one proposed.
+ */
+
+// Hosts see the portal editor's language, not backend field names.
+export const FIELD_LABELS: Record string> = {
+ anonymize_transcripts: () => t`Anonymise transcripts`,
+ context: () => t`Project context`,
+ conversation_title_prompt: () => t`Title guidance`,
+ default_conversation_ask_for_participant_email: () =>
+ t`Ask participants for their email`,
+ default_conversation_ask_for_participant_name: () =>
+ t`Ask participants for their name`,
+ default_conversation_description: () => t`Portal description`,
+ default_conversation_finish_text: () => t`Portal finish message`,
+ default_conversation_title: () => t`Portal title`,
+ default_conversation_transcript_prompt: () => t`Key terms`,
+ default_conversation_tutorial_slug: () => t`Portal tutorial`,
+ enable_ai_title_and_tags: () => t`Automatic titles and draft tags`,
+ get_reply_mode: () => t`Reply mode`,
+ get_reply_prompt: () => t`Reply guidance`,
+ host_guide: () => t`Host guide`,
+ image_generation_model: () => t`Image style`,
+ is_conversation_allowed: () => t`Portal open for new conversations`,
+ is_get_reply_enabled: () => t`Replies to participants`,
+ is_project_notification_subscription_allowed: () =>
+ t`Participant updates subscription`,
+ is_verify_enabled: () => t`Participant verification`,
+ is_verify_on_finish_enabled: () => t`Verification on the finish screen`,
+ language: () => t`Portal language`,
+ legal_basis: () => t`Legal basis`,
+ name: () => t`Project name`,
+ selected_verification_key_list: () => t`Verification topics`,
+ tutorial_slug: () => t`Tutorial`,
+};
+
+export const fieldLabel = (field: string) =>
+ FIELD_LABELS[field]?.() ??
+ field.replace(/^default_conversation_/, "").replace(/_/g, " ");
+
+export const isEmptyValue = (value: unknown) =>
+ value === null || value === undefined || value === "";
+
+/**
+ * The three ways a fragment can read inside a value box, and the only place
+ * they are defined. The word diff and the short-value line both pull from
+ * here, so a marking can never drift between the long and short paths.
+ *
+ * Each mark carries a shape as well as a colour: the removal is struck
+ * through, the addition is underlined. A background alone does not survive
+ * greyscale (the green sits at about 97% luminance) or a colour-blind reader,
+ * and the addition used to have nothing but that background.
+ *
+ * Unchanged text recedes on purpose. Marked and unmarked prose used to sit at
+ * the same contrast, so a two word edit inside a paragraph read as one flat
+ * block; dropping the surround to a mid grey makes the edit the first thing
+ * the eye lands on, without inventing a colour the card does not already use.
+ */
+export const REMOVED_MARK =
+ "bg-[var(--app-danger-tint)] text-[var(--app-danger-on-tint)] line-through decoration-[var(--app-danger)] decoration-2";
+export const ADDED_MARK =
+ "bg-[var(--app-success-tint)] text-[var(--app-success)] underline decoration-[var(--app-success)] decoration-2";
+export const asDisplayString = (value: unknown) => {
+ if (typeof value === "string") return value;
+ if (value === null || value === undefined) return "";
+ if (typeof value === "object") return JSON.stringify(value);
+ return String(value);
+};
+
+export const ValueText = ({
+ value,
+ kind,
+}: {
+ value: unknown;
+ kind: "old" | "new";
+}) => {
+ if (isEmptyValue(value)) {
+ // A description of the value, not the value itself. Italics used to carry
+ // that difference and carried it to nobody; the words say it instead, and
+ // the dimmed colour keeps them from reading as content.
+ return (
+
+ {kind === "old" ? (
+ nothing here yet
+ ) : (
+ emptied out
+ )}
+
+ );
+ }
+ const display =
+ typeof value === "boolean" ? (
+ value ? (
+ on
+ ) : (
+ off
+ )
+ ) : (
+ asDisplayString(value)
+ );
+ return (
+
+ {display}
+
+ );
+};
diff --git a/dembrane/frontend/src/components/project/ProjectContextInput.tsx b/dembrane/frontend/src/components/project/ProjectContextInput.tsx
index dcaa71bd9..ab6c9a930 100644
--- a/dembrane/frontend/src/components/project/ProjectContextInput.tsx
+++ b/dembrane/frontend/src/components/project/ProjectContextInput.tsx
@@ -21,8 +21,8 @@ export const ProjectContextInput = forwardRef<
return (