// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // The tenant brand in a browser (plan item AT2.6): an administrator chooses colours and a logo and a // member sees them, legibly in both themes, from the first byte; a member who is not an administrator // cannot change them; and a logo that could run script is refused. import { expect, type Locator, test } from "@playwright/test" import { call, callRaw, makeOrganization, signIn, unique, violations } from "./support/app" const channels = (color: string): [number, number, number] => { const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color) if (!match) throw new Error(`not an rgb colour: ${color}`) return [Number(match[1]), Number(match[2]), Number(match[3])] } const luminance = ([r, g, b]: [number, number, number]): number => { const lin = (v: number) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4) return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) } const contrast = (a: string, b: string): number => { const [hi, lo] = [luminance(channels(a)), luminance(channels(b))].sort((x, y) => y - x) as [number, number] return (hi + 0.05) / (lo + 0.05) } type ColourProperty = "backgroundColor" | "color" | "textDecorationColor" | "outlineColor" /** A computed colour as sRGB, whatever space the stylesheet wrote it in (the theme roles are OKLCH). */ const rgbOf = (locator: Locator, property: ColourProperty): Promise => locator.evaluate((el, name) => { const canvas = document.createElement("canvas") canvas.width = 1 canvas.height = 1 const context = canvas.getContext("2d") as CanvasRenderingContext2D context.fillStyle = getComputedStyle(el)[name] context.fillRect(0, 0, 1, 1) const [r, g, b] = context.getImageData(0, 0, 1, 1).data return `rgb(${r}, ${g}, ${b})` }, property) test("a tenant brand applies in both themes, from the first paint", async ({ page }) => { await signIn(page, `${unique("admin")}@example.test`) const { org } = await makeOrganization(page, "brand") await page.goto(`/${org}/branding`) await expect(page.getByRole("heading", { level: 2, name: "Brand" })).toBeVisible() await page.getByLabel("Display name").fill("Acme Review") await page.getByLabel("Primary colour").fill("#1a1a8c") await page.getByLabel("Accent colour").fill("#f4c430") // the preview answers before anything is saved, and says what the contrast is await expect(page.getByText(/Text on the colour: \d+\.\d to 1/).first()).toBeVisible() expect(await violations(page)).toEqual([]) // Confirm the write and loader refresh without losing the document or its open panels. const documentStarted = await page.evaluate(() => performance.timeOrigin) await page.getByRole("button", { name: "Save the brand" }).click() await expect(page.getByText("The brand was saved.", { exact: true })).toBeVisible() expect(await page.evaluate(() => performance.timeOrigin)).toBe(documentStarted) await expect(page.getByLabel("Primary colour")).toHaveValue("#1a1a8c") // server-rendered: the raw HTML of the organization page already carries the brand layer const html = await (await page.request.get(`/${org}`)).text() // The brand layer is the runtime theme roles (brand-kit BK6): the primary action, the focus ring and // the sidebar's primary, never the kit's internal --brand-* tier. expect(html).toMatch(/data-tenant-brand="#1a1a8c"[^>]*style="[^"]*--primary:light-dark\(#1a1a8c, #[0-9a-f]{6}\)/) expect(html).toMatch( /data-tenant-brand="#1a1a8c"[^>]*style="[^"]*--sidebar-primary:light-dark\(#1a1a8c, #[0-9a-f]{6}\)/, ) expect(html).toMatch(/data-tenant-brand="#1a1a8c"[^>]*style="[^"]*--ring:light-dark\(/) expect(html).not.toMatch(/data-tenant-brand="#1a1a8c"[^>]*style="[^"]*--brand-accent/) expect(html).toContain("Acme Review") // Agent/Help deliberately ship their localized attribution label in loader // data. It must not brand the app's own screen, metadata or asset URLs. const appHtml = html.replaceAll('poweredBy:"Powered by ConusAI"', "").replaceAll(/\/@fs\/[^"]+/g, "") expect(appHtml.toLowerCase()).not.toContain("conusai") expect((await page.locator("body").innerText()).toLowerCase()).not.toContain("conusai") // the organization's colour moves the sidebar and the focus ring of the shell, not only the preview await page.goto(`/${org}`) const sidebar = await rgbOf(page.locator('[data-sidebar="sidebar"]').first(), "backgroundColor") const navLink = page.getByRole("navigation", { name: "Main navigation" }).getByRole("link").first() await page.keyboard.press("Tab") await navLink.focus() expect(await navLink.evaluate((el) => el.matches(":focus-visible"))).toBe(true) const ring = await rgbOf(navLink, "outlineColor") // #1a1a8c is 1.9:1 on the dark page, so the ring is the brand colour moved towards white: the dark // half of the light-dark() pair the organization layout wrote, not the ConusAI teal. const declared = await navLink.evaluate((el) => getComputedStyle(el).getPropertyValue("--sidebar-ring").trim()) const darkRing = /^light-dark\(#[0-9a-f]{6}, ?#([0-9a-f]{6})\)$/.exec(declared)?.[1] expect(darkRing, `--sidebar-ring is ${declared}`).toBeDefined() expect(channels(ring), "the focus ring follows the brand").toEqual( [0, 2, 4].map((at) => Number.parseInt((darkRing as string).slice(at, at + 2), 16)), ) expect(channels(ring)).not.toEqual([0x80, 0xcd, 0xc6]) expect(contrast(ring, sidebar), "the focus ring on the sidebar").toBeGreaterThanOrEqual(3) expect(await violations(page)).toEqual([]) // the primary action's fill is the brand's colour on both themes, and what is written as text on it reads await page.goto(`/${org}/branding`) const panels = page.locator(".kit-brand-preview-panel") await expect(panels).toHaveCount(2) for (const [index, theme] of ["dark", "light"].entries()) { const panel = panels.nth(index) const action = panel.locator('[data-slot="brand-preview-action"]') const fill = await rgbOf(action, "backgroundColor") const onFill = await rgbOf(action, "color") expect(channels(fill), theme).toEqual(theme === "dark" ? [0x5f, 0x5f, 0xaf] : [0x1a, 0x1a, 0x8c]) expect(contrast(fill, onFill), `${theme} text on the fill`).toBeGreaterThanOrEqual(4.5) const link = panel.locator(".kit-brand-preview-link") const ink = await rgbOf(link, "color") const page_ = await rgbOf(panel, "backgroundColor") expect(contrast(ink, page_), `${theme} link on the page`).toBeGreaterThanOrEqual(4.5) } // one element carries the theme class: the light preview, and nothing else on the page expect(await page.evaluate(() => document.querySelectorAll(".light").length)).toBeLessThanOrEqual(1) }) test("a member who is not an administrator reads the brand and cannot change it", async ({ page, browser }) => { const adminEmail = `${unique("admin")}@example.test` await signIn(page, adminEmail) const { org } = await makeOrganization(page, "perm") const memberEmail = `${unique("member")}@example.test` const invite = await call<{ token: string }>(page, "tenancy.member.invite", { org, email: memberEmail, role: "member", }) const member = await browser.newPage() await signIn(member, memberEmail) await call(member, "tenancy.invitation.accept", { org, token: invite.token }) await member.goto(`/${org}/branding`) await expect(member.getByText("Only administrators can change the brand.")).toBeVisible() await expect(member.getByRole("button", { name: "Save the brand" })).toHaveCount(0) // and the procedure answers them as it answers a stranger const csrf = (await member.locator('meta[name="csrf-token"]').getAttribute("content")) ?? "" const refused = await callRaw(member.request, csrf, "branding.brand.set", { org, primary: "#000000" }) expect(refused.status()).toBe(404) await member.close() }) test("a logo that carries a script is refused, and nothing is stored", async ({ page }) => { await signIn(page, `${unique("admin")}@example.test`) const { org } = await makeOrganization(page, "logo") await page.goto(`/${org}/branding`) await page.getByLabel("Logo for the light theme").setInputFiles({ name: "logo.svg", mimeType: "image/svg+xml", buffer: Buffer.from(''), }) await page.getByRole("button", { name: "Save the brand" }).click() await expect(page.getByRole("alert").filter({ hasText: "That did not work" })).toBeVisible() await page.reload() await expect(page.getByAltText("Current logo")).toHaveCount(0) })