From 7d104c93e76daac5b30102c8d63b38d659176f7a Mon Sep 17 00:00:00 2001 From: Mathieu Audebert Date: Tue, 13 Oct 2026 17:20:49 +0200 Subject: [PATCH] feat(console): the last step of the setup: two cards, and an example page in the preview (FRU-242) The commits of the branch as one, on main. Step 3 is in routes/setup-install.tsx, after the split of the setup in #267. Co-Authored-By: Claude Opus 5.5 --- apps/console/app/install-options.tsx | 42 ++++- apps/console/app/messages.fr.ts | 22 ++- apps/console/app/preview-scene.css | 190 ++++++++++++++++++++++ apps/console/app/preview-scene.tsx | 72 ++++++++ apps/console/app/root.tsx | 10 +- apps/console/app/routes/invitations.tsx | 7 +- apps/console/app/routes/setup-install.tsx | 101 ++++++++++++ apps/console/app/routes/setup.tsx | 33 ++-- docs/decisions/console.md | 18 +- e2e/console-setup.spec.ts | 150 ++++++++++++++++- 10 files changed, 608 insertions(+), 37 deletions(-) create mode 100644 apps/console/app/preview-scene.css create mode 100644 apps/console/app/preview-scene.tsx create mode 100644 apps/console/app/routes/setup-install.tsx diff --git a/apps/console/app/install-options.tsx b/apps/console/app/install-options.tsx index f0be0655..051a31c7 100644 --- a/apps/console/app/install-options.tsx +++ b/apps/console/app/install-options.tsx @@ -26,12 +26,30 @@ export function snippetFor(site: Site): string { const CONNECT_PROBLEMS: Readonly> = {}; const BROWSER_NOT_CONNECTED = msg('This browser was not connected. Try again.'); -export function InstallOptions({ workspace, site }: { workspace: Workspace; site: Site }) { - useLocale(); +/** + * Where the extension is explained. It is in no store yet (FRU-130): until it is, « Add to Chrome » + * and « Get the Firefox add-on » lead to the page that says how to install it. + */ +export const EXTENSION_GUIDE = 'https://fruitback.com/reviewing.html#1-install-it'; + +/** A text put in the clipboard: whether the browser took it. « refused »: it stays on screen to select. */ +export function useCopy(text: string): { copied: 'no' | 'yes' | 'refused'; copy: () => void } { const [copied, setCopied] = useState<'no' | 'yes' | 'refused'>('no'); + + return { copied, copy: () => void copyText(text).then((taken) => setCopied(taken ? 'yes' : 'refused')) }; +} + +/** + * Connects the browser of the person: a code is made, and the pairing page of the worker opens with + * it in another tab. `link` is that page, for a browser that blocked the tab. + */ +export function useConnect(workspace: Workspace): { + link: string | undefined; + problem: string | undefined; + connect: () => void; +} { const [link, setLink] = useState(); const [problem, setProblem] = useState(); - const snippet = snippetFor(site); async function connect() { // Open the tab in the click, before the await: a browser blocks a window opened after one. @@ -42,6 +60,7 @@ export function InstallOptions({ workspace, site }: { workspace: Workspace; site if (!minted.ok) { // The tab that just opened closes: the reason is said under the button (FRU-164). tab?.close(); + return setProblem(problemOf(minted, CONNECT_PROBLEMS, BROWSER_NOT_CONNECTED)); } const target = pairingLink(minted.data.code); @@ -52,6 +71,15 @@ export function InstallOptions({ workspace, site }: { workspace: Workspace; site } } + return { link, problem, connect: () => void connect() }; +} + +export function InstallOptions({ workspace, site }: { workspace: Workspace; site: Site }) { + useLocale(); + const snippet = snippetFor(site); + const { copied, copy } = useCopy(snippet); + const { link, problem, connect } = useConnect(workspace); + return (
@@ -59,11 +87,7 @@ export function InstallOptions({ workspace, site }: { workspace: Workspace; site

{t('Paste it before the closing body tag of the site.')}

{/* The box of the script takes focus, so a keyboard scrolls its long lines (axe, FRU-239). */} {snippet} - {copied === 'refused' ? ( @@ -79,7 +103,7 @@ export function InstallOptions({ workspace, site }: { workspace: Workspace; site 'Nothing to change on the site. Connect this browser, then click the Fruitback icon on the page that opens.', )}

- {problem === undefined ? null : ( diff --git a/apps/console/app/messages.fr.ts b/apps/console/app/messages.fr.ts index c760d396..ff736ca4 100644 --- a/apps/console/app/messages.fr.ts +++ b/apps/console/app/messages.fr.ts @@ -281,7 +281,6 @@ export const FRENCH: Readonly> = { 'Paste the full address of the site, starting with https://.': 'Collez l’adresse complète du site, en commençant par https://.', 'Site address': 'Adresse du site', - Finish: 'Terminer', Script: 'Script', 'Paste it before the closing body tag of the site.': 'Collez-le avant la balise body fermante du site.', Copied: 'Copié', @@ -515,4 +514,25 @@ export const FRENCH: Readonly> = { 'Open {site}': 'Ouvrir {site}', 'Using Firefox?': 'Vous utilisez Firefox ?', 'Get the Firefox add-on': 'Obtenir le module pour Firefox', + // The last step of the setup, and the example page of its preview (FRU-242). + 'An example page. Pins show the same way on your site.': + 'Une page d’exemple. Les pins s’affichent de la même façon sur votre site.', + 'Leave feedback': 'Laisser un feedback', + 'Click Leave feedback, then any part of the page.': + 'Cliquez sur Laisser un feedback, puis sur n’importe quelle partie de la page.', + 'Use one, or both.': 'Utilisez l’un, ou les deux.', + 'Browser extension': 'Extension de navigateur', + "Nothing to deploy. Right for a client's site you cannot touch.": + 'Rien à déployer. Convient au site d’un client auquel vous ne pouvez pas toucher.', + 'Add it, then connect this browser: a page opens, and you click the Fruitback icon there.': + 'Ajoutez-la, puis connectez ce navigateur : une page s’ouvre, et vous y cliquez sur l’icône Fruitback.', + 'Script tag': 'Balise de script', + 'Before the closing body tag. Members see their pins on every visit.': + 'Avant la balise body fermante. Les membres voient leurs pins à chaque visite.', + 'Before the closing body tag. Every visitor sees the pins.': + 'Avant la balise body fermante. Chaque visiteur voit les pins.', + 'Copy script': 'Copier le script', + 'Open the site, click Leave feedback, then any part of the page.': + 'Ouvrez le site, cliquez sur Laisser un feedback, puis sur n’importe quelle partie de la page.', + 'Open the console': 'Ouvrir la console', }; diff --git a/apps/console/app/preview-scene.css b/apps/console/app/preview-scene.css new file mode 100644 index 00000000..7ff3b680 --- /dev/null +++ b/apps/console/app/preview-scene.css @@ -0,0 +1,190 @@ +/* + The example page of the setup (`preview-scene.tsx`, FRU-242): a shop that does not exist, drawn in + the window of the preview. + + WARNING: the colours and the serif here are the example site's, not Fruitback's. They are no tokens, + and nothing of the console takes them. What is Fruitback's on this picture (the two buttons of the + widget, the hint) is drawn with the tokens of `app.css`. +*/ +.scene { + position: relative; + height: 100%; + background: #f6f6f3; + color: #22302a; + font-family: Georgia, 'Times New Roman', serif; + /* A picture: nothing in it is selected or pressed. */ + user-select: none; +} + +.scene__nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 28px; + line-height: normal; +} + +.scene__name { + font-size: 17px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.scene__links { + display: flex; + gap: 20px; + font-family: var(--font-sans); + font-size: 13px; +} + +.scene__hero { + position: relative; + margin: 0 28px; + padding: 44px 36px 40px; + border-radius: 6px; + background: #2f4a3a; + color: #f6f6f3; +} + +.scene__headline { + position: relative; + max-width: 340px; + font-size: 40px; + line-height: 1.06; +} + +.scene__tagline { + margin: 14px 0 22px; + color: #dfe6df; + font-family: var(--font-sans); + font-size: 14px; + line-height: normal; +} + +.scene__action { + display: inline-block; + padding: 10px 16px; + border-radius: 4px; + background: #f6f6f3; + color: #22302a; + font-family: var(--font-sans); + font-size: 13px; + font-weight: 600; + line-height: normal; +} + +.scene__products { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 16px; + padding: 24px 28px; +} + +.scene__photo { + display: flex; + align-items: flex-end; + box-sizing: border-box; + height: 120px; + padding: 8px; + border-radius: 4px; + font-family: var(--font-sans); + font-size: 11px; + line-height: normal; +} + +.scene__photo[data-tone='blue'] { + background: #d8dee8; + color: #3f4652; +} + +.scene__photo[data-tone='green'] { + background: #cfe0d6; + color: #35483c; +} + +.scene__photo[data-tone='rose'] { + background: #ecd5cf; + color: #5c3a36; +} + +.scene__product { + margin-top: 8px; + font-size: 14px; + line-height: normal; +} + +/* What is Fruitback's on the picture: the hint, and the two buttons of the widget. */ +.scene__hint { + position: absolute; + right: 18px; + bottom: 74px; + max-width: 230px; + padding: 10px 14px; + border-radius: var(--radius-box); + background: var(--color-ink); + color: var(--surface); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18); + font-family: var(--font-sans); + font-size: var(--text-hint); + line-height: 1.45; +} + +.scene__hint::after { + position: absolute; + right: 52px; + bottom: -5px; + width: 10px; + height: 10px; + background: var(--color-ink); + transform: rotate(45deg); + content: ''; +} + +.scene__dock { + position: absolute; + right: 18px; + bottom: 18px; + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-sans); +} + +.scene__settings { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 999px; + background: var(--color-avatar); + color: var(--surface); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.25); +} + +.scene__settings svg { + width: 16px; + height: 16px; +} + +.scene__launch { + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-radius: 999px; + background: var(--accent); + color: var(--accent-foreground); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.25); + font-size: var(--text-hint); + font-weight: 600; + line-height: normal; + letter-spacing: var(--tracking-button); +} + +.scene__launch svg { + width: 12px; + height: 14px; +} diff --git a/apps/console/app/preview-scene.tsx b/apps/console/app/preview-scene.tsx new file mode 100644 index 00000000..b92ba4d7 --- /dev/null +++ b/apps/console/app/preview-scene.tsx @@ -0,0 +1,72 @@ +import './preview-scene.css'; + +/** + * The example page of the setup (FRU-242): the scene of screen 01 of the mockup of 2026-10-13, in + * the window of the preview. + * + * It is a picture, not the site of the person: nothing here asks their site for anything, and no + * frame loads it (decided on 2026-10-13: a fixed illustration). So it is hidden from a screen + * reader and `inert`: its two buttons are drawn, and take no focus and no press. The caption under + * the window says what the picture is. + * + * The words of the shop are the picture's, in English as on the mockup. The two sentences that are + * Fruitback's come from the screen, in the language of the person. + */ +export function PreviewScene({ launch, hint }: { launch: string; hint: string }) { + return ( + + ); +} diff --git a/apps/console/app/root.tsx b/apps/console/app/root.tsx index 709eec37..b2de8a6f 100644 --- a/apps/console/app/root.tsx +++ b/apps/console/app/root.tsx @@ -154,7 +154,10 @@ function Unreachable() { if (reachable) return null; return ( -
+
{t('Fruitback cannot reach its server. Check your connection.')} +
+ {t('Connect this browser')} +
+
+ {problem === undefined ? null : {t(problem)}} + {link === undefined ? null : ( +

+ {t('A page opened in a new tab. If it did not, open')}{' '} + + {t('this link')} + + {t(': it works once, for fifteen minutes.')} +

+ )} + + +

+ {site.visibility === 'members' + ? t('Before the closing body tag. Members see their pins on every visit.') + : t('Before the closing body tag. Every visitor sees the pins.')} +

+ + {snippet} + +
+ +
+ {copied === 'refused' ? ( +

+ {t('The browser refused the copy. Select the script above and copy it by hand.')} +

+ ) : null} +
+ +

+ {t('Open the site, click Leave feedback, then any part of the page.')}{' '} + + {t('Open {site}', { site: host })} + +

+
+ +
+ + ); +} + +/** The arrow of a link that leaves the console. */ +function Away() { + return ( + + ); +} diff --git a/apps/console/app/routes/setup.tsx b/apps/console/app/routes/setup.tsx index 6af3d956..afe77ae2 100644 --- a/apps/console/app/routes/setup.tsx +++ b/apps/console/app/routes/setup.tsx @@ -3,10 +3,12 @@ import { useNavigate } from 'react-router'; import { type Me, type Site, type Workspace, call, myInvitations, saveLanguage } from '../api'; import { adoptLanguage, t } from '../i18n'; import { InstallOptions } from '../install-options'; +import { PreviewScene } from '../preview-scene'; import { type SetupCalls, type Stage, firstWorkspace, leaveFor, resolveStage, siteStage } from '../setup-stage'; import { BrowserFrame, Button, EntryPage, Lead, Step, type StepState, Steps, Title } from '../ui'; import { useLocale } from '../use-locale'; import { Account, Ask, Sent, Unnamed } from './setup-account'; +import { PutOnSite } from './setup-install'; import { SiteForm, SiteSummary } from './setup-site'; /** @@ -210,14 +212,7 @@ export default function Setup() { {stage.kind === 'install' ? ( - // No `items-start` here: the script would take the width of its longest line, and the page - // would scroll sideways. It scrolls in its own box. -
- -
- -
-
+ ) : (

{t('The extension, or a script tag.')}

)} @@ -226,13 +221,21 @@ export default function Setup() {
- + {stage.kind === 'install' ? ( + + + + ) : ( + + )}
); diff --git a/docs/decisions/console.md b/docs/decisions/console.md index 01a98a11..79e8c932 100644 --- a/docs/decisions/console.md +++ b/docs/decisions/console.md @@ -83,7 +83,7 @@ The console of the Cloud, `apps/console`: its words, its kit, and what it shows the keyboard, the contrasts and axe. - **The setup is one screen, and resumes where the worker says the person is** (FRU-239, screen 01 of the mockup of 2026-10-13). `setup.tsx` shows three steps together (step 1 in `setup-account.tsx`, - step 2 in `setup-site.tsx`; where the person is, decided from the answers of the worker, in + step 2 in `setup-site.tsx`, step 3 in `setup-install.tsx`; where the person is, decided from the answers of the worker, in `setup-stage.ts`, which knows no React and no network and has its own `setup-stage.test.ts`): « Your account », « Your site », « Put it on the site ». The step is read from the worker at each visit: nobody signed in, no workspace, no site, a site. So a visit goes on anywhere, and the address has no `?step=`. **The @@ -113,8 +113,20 @@ The console of the Cloud, `apps/console`: its words, its kit, and what it shows - **What the setup does not say, on purpose.** The hint of « Site address » stops at « Any page of it will do. »: the mockup goes on with a pattern for the previews, which a domain that is proven covers (FRU-103, decided on 2026-10-13), and the worker takes no pattern as an address. So what is - under the hint is 19.5 px higher than on the mockup. The last step keeps the script and the - browser of `install-options.tsx` until FRU-242 draws it. + under the hint is 19.5 px higher than on the mockup. +- **The last step is two cards, and the preview is a picture** (FRU-242). « Browser extension » and + « Script tag » share the logic of the Sites screen (`useCopy`, `useConnect` in + `install-options.tsx`). **The preview asks the site of the person for nothing**: it is the example + page of the mockup (`preview-scene.tsx`), with the address of the site in its bar, and no frame + (decided on 2026-10-13: a fixed illustration). Its caption says « An example page ». The picture + is `aria-hidden` and `inert`: its two buttons are drawn, and take no focus. **Its colours are the + example site's, in `preview-scene.css`, and are no tokens**: nothing of the console takes them. + **Two things of the mockup wait, each for a decision that is open** (FRU-230): « Signs in with + this account. No code to type. » (Q8: a browser is still connected from here, with « Connect this + browser »), and the first note that ends the setup (FRU-237: nothing tells the console that a + note came). So the step does not say « Waiting for your first note. », and « Open the console » + is always there. **The script keeps `data-fruitback-endpoint`** (inventory A24): the widget finds + its worker by that attribute only. - **An invitation has a screen, and joining says what comes next** (FRU-241, screen 03 of the mockup of 2026-10-13). `/invitations` shows the most recent invitation of the address that is signed in: the workspace that invites, the role and what it lets its person do, « Join », and « Decline » diff --git a/e2e/console-setup.spec.ts b/e2e/console-setup.spec.ts index 6fff8a4c..bd5e1add 100644 --- a/e2e/console-setup.spec.ts +++ b/e2e/console-setup.spec.ts @@ -122,6 +122,58 @@ test.describe('the setup, against a worker with accounts (FRU-239)', () => { expect(made).toHaveLength(1); }); + test('the last step gives the script of the site, copies it, and connects the browser', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + const toSite: string[] = []; + await context.route('https://staging.acme.dev/**', (route) => { + toSite.push(route.request().url()); + + return route.abort(); + }); + await page.goto(`${CONSOLE}/setup`); + await page.getByRole('textbox', { name: 'Work email' }).fill('ana@acme.dev'); + await page.getByRole('button', { name: 'Email me a link' }).click(); + await expect.poll(async () => (await worker.sent()).length).toBe(1); + await page.goto(await linkIn(worker)); + await page.getByRole('textbox', { name: 'Site address' }).fill('staging.acme.dev'); + await page.getByRole('button', { name: 'Add site' }).click(); + await expect(page.getByRole('heading', { name: `${CURRENT}Put it on the site` })).toBeVisible(); + + // The script names the site as the worker made it, and « Copy script » copies that script. + const [workspace] = await workspaces(page); + const site = await page.evaluate( + async ([origin, id]) => { + const answer = await fetch(`${origin}/console/workspaces/${id}/sites`, { credentials: 'include' }); + + return ((await answer.json()) as { sites: { id: string }[] }).sites[0]!.id; + }, + [WORKER_ORIGIN, workspace!.id], + ); + const script = page.getByRole('region', { name: 'Script tag' }); + await expect(script).toContainText(`data-fruitback-client="${site}"`); + await expect(script).toContainText(`data-fruitback-endpoint="${WORKER_ORIGIN}"`); + await page.getByRole('button', { name: 'Copy script' }).click(); + await expect(page.getByRole('button', { name: 'Copied' })).toBeVisible(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(await script.innerText()); + + // « Connect this browser »: the pairing page of the worker opens, with a code after the `#`. + const opened = context.waitForEvent('page'); + await page.getByRole('button', { name: 'Connect this browser' }).click(); + const pairing = await opened; + await pairing.waitForURL(/\/pair#/); + expect(new URL(pairing.url()).hash.slice(1)).toMatch(/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/); + await pairing.close(); + + // The preview is a picture: the site of the person was asked for nothing. + await expect( + page.getByRole('region', { name: 'Preview of your site' }).getByText('staging.acme.dev'), + ).toBeVisible(); + expect(toSite).toEqual([]); + // The way on is always there: nothing tells the console that a first note came. + await page.getByRole('link', { name: 'Open the console' }).click(); + await expect(page).toHaveURL(`${CONSOLE}/w/${workspace!.id}/sites`); + }); + test('a name the worker refuses stays in its field, with the reason', async ({ page }) => { await page.goto(`${CONSOLE}/setup`); await page.getByRole('textbox', { name: 'Work email' }).fill('ana@acme.dev'); @@ -241,6 +293,8 @@ type Stub = { /** What the worker answers when a workspace is made or a site is added. */ refuses?: { workspace?: boolean | 'invalid-name'; site?: number; sites?: boolean; invitations?: 503 | 404 }; locale?: 'en' | 'fr'; + /** Who sees the notes of the site of « sited ». */ + audience?: 'members' | 'everyone'; }; const LONG_HOST = 'preview-branch-feature-very-long-name-1234.staging.acme.dev'; @@ -256,7 +310,7 @@ async function stubWorker(page: Page, stub: Stub): Promise { const asked: string[] = []; const workspace = { id: 'w1', name: NAME, role: stub.role ?? 'owner' }; const account = { id: 'a1', email: EMAIL, name: 'Marianne', locale: stub.locale ?? 'en' }; - const site = { id: 's1', origin: 'https://staging.acme.dev', visibility: 'members' }; + const site = { id: 's1', origin: 'https://staging.acme.dev', visibility: stub.audience ?? 'members' }; let made = stub.as !== 'nobody'; await page.context().route(`${WORKER_ORIGIN}/**`, (route) => { const request = route.request(); @@ -505,6 +559,100 @@ test.describe('the states of the setup, against the mockup (FRU-239)', () => { expect(await violations(page)).toEqual([]); }); + test('« Put it on the site »: the two cards, the example page, and the boxes of the mockup at 1440 px', async ({ + page, + }) => { + await stubWorker(page, { as: 'sited' }); + await page.goto(`${CONSOLE}/setup`); + await expect(page.getByRole('heading', { name: `${CURRENT}Put it on the site` })).toBeVisible(); + + expectNear( + await boxOf(page.getByRole('heading', { name: `${CURRENT}Put it on the site` })), + { x: 160, y: 466, width: 352.2, height: 26 }, + 'step 3', + ); + const change = await boxOf(page.getByRole('button', { name: 'Change' })); + expectNear( + { ...change, y: change.y + 6, height: change.height - 12 }, + { x: 405, y: 412, width: 51.8, height: 21 }, + '« Change »', + ); + expectNear( + await boxOf(page.getByRole('heading', { name: 'Browser extension' })), + { x: 179, y: 553, width: 314.2, height: 18 }, + 'card of the extension', + ); + // The cards are one under the other in this column, as on the mockup, and as wide. + const cards = page.locator('.kit-option'); + await expect(cards).toHaveCount(2); + expectNear( + await boxOf(cards.nth(0)), + { x: 160, y: 534, width: 352.2, height: (await boxOf(cards.nth(0))).height }, + 'first card', + ); + expect.soft((await boxOf(cards.nth(1))).x).toBe(160); + await expect(page.getByRole('link', { name: 'Add to Chrome' })).toHaveAttribute( + 'href', + 'https://fruitback.com/reviewing.html#1-install-it', + ); + // The step says what to do on the site, and does not say that it waits: nothing would tell it. + await expect(page.getByText('Open the site, click Leave feedback, then any part of the page.')).toBeVisible(); + await expect(page.getByText(/Waiting for your first note/)).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Open staging.acme.dev' })).toHaveAttribute( + 'href', + 'https://staging.acme.dev', + ); + await expect(page.getByRole('link', { name: 'Open the console' })).toHaveAttribute('href', '/w/w1/sites'); + await expect(page.getByText('Before the closing body tag. Members see their pins on every visit.')).toBeVisible(); + + // The example page: the address of the site in the bar, the scene of the mockup, its caption. + const preview = page.getByRole('region', { name: 'Preview of your site' }); + expectNear(await boxOf(preview), { x: 568, y: 113, width: 759.8, height: 698.5 }, 'preview'); + expectNear( + await boxOf(preview.locator('.scene__settings')), + { x: 1117, y: 723, width: 36, height: 36 }, + 'settings of the picture', + ); + expectNear( + await boxOf(preview.locator('.scene__launch')), + { x: 1161, y: 721, width: 147.8, height: 40 }, + 'launch of the picture', + ); + await expect(preview.getByText('An example page. Pins show the same way on your site.')).toBeVisible(); + await expect(preview.locator('.scene__hint')).toHaveText('Click Leave feedback, then any part of the page.'); + // A picture: hidden from a screen reader, and nothing in it takes focus or a press. + const scene = preview.locator('.scene'); + await expect(scene).toHaveAttribute('aria-hidden', 'true'); + expect(await scene.evaluate((element) => (element as HTMLElement).inert)).toBe(true); + await expect(scene.locator('button, a, input, [tabindex]')).toHaveCount(0); + expect(await violations(page)).toEqual([]); + }); + + test('the script card says who sees the pins of a site that everyone sees', async ({ page }) => { + await stubWorker(page, { as: 'sited', audience: 'everyone' }); + await page.goto(`${CONSOLE}/setup`); + + await expect(page.getByText('Before the closing body tag. Every visitor sees the pins.')).toBeVisible(); + await expect(page.getByText('Everyone who visits sees the notes')).toBeVisible(); + }); + + test('a copy the browser refuses says so, and the script stays to select', async ({ page }) => { + await stubWorker(page, { as: 'sited' }); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: () => Promise.reject(new Error('denied')) }, + configurable: true, + }); + }); + await page.goto(`${CONSOLE}/setup`); + await page.getByRole('button', { name: 'Copy script' }).click(); + + await expect(page.getByRole('alert')).toHaveText( + 'The browser refused the copy. Select the script above and copy it by hand.', + ); + await expect(page.getByRole('region', { name: 'Script tag' })).toContainText('data-fruitback-client="s1"'); + }); + test('the keyboard walks the setup in the order it reads', async ({ page }) => { await stubWorker(page, { as: 'nobody' }); await page.goto(`${CONSOLE}/setup`);