Cover remaining Sass families and fix autocomplete hover
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-14 13:58:40 +02:00
parent 77f357e056
commit e52d6e66eb
13 changed files with 617 additions and 286 deletions

View file

@ -1,14 +1,9 @@
// Rôle : vérifie les surfaces de cartes à partir du Sass compilé et des styles calculés.
import { test, expect } from "@playwright/test";
import { compile } from "sass";
let css;
test.beforeAll(() => {
css = compile("website/src/styles/main.scss").css;
});
import { mountStyles } from "./fixtures/compiled-styles.js";
async function mountCards(page) {
await page.setContent(`
await mountStyles(page, `
<main style="display:grid;gap:24px;padding:24px">
<article id="global" class="nds-card" data-variant="interactive">
<div class="nds-card-cover">Cover</div><button>Open</button>
@ -20,6 +15,7 @@ async function mountCards(page) {
<div class="mhwilds-card-art">Art</div><div class="mhwilds-card-body">Endemic</div>
</article>
<article id="monster" class="mhwilds-card monster-card" tabindex="0">
<span class="mhwilds-flip-indicator">Flip</span>
<div class="mhwilds-card-inner">
<div class="mhwilds-card-face mhwilds-card-front"><div class="mhwilds-card-art">Front</div></div>
<div class="mhwilds-card-face mhwilds-card-back"><div class="mhwilds-card-body">Back</div></div>
@ -27,8 +23,6 @@ async function mountCards(page) {
</article>
</main>
`);
await page.addStyleTag({ content: css });
await page.mouse.move(0, 0);
}
async function expectSurface(card, { hovered = false, face = false } = {}) {
@ -78,6 +72,8 @@ for (const width of [1280, 390]) {
await global.getByRole("button").focus();
await expectSurface(global, { hovered: true });
const monster = page.locator("#monster");
await expect(monster.locator(".mhwilds-flip-indicator")).toHaveCSS("pointer-events", "none");
await expect(monster.locator(".mhwilds-flip-indicator")).toHaveCSS("width", "34px");
for (const face of await monster.locator(".mhwilds-card-face").all()) {
await expectSurface(face, { face: true });
await expect(face).toHaveCSS("backface-visibility", "hidden");

View file

@ -0,0 +1,32 @@
// Rôle : monte le CSS complet compilé et lit les styles calculés des fixtures visuelles.
import { compile } from "sass";
import { expect } from "@playwright/test";
let css;
export async function mountStyles(page, html) {
css ??= compile("website/src/styles/main.scss").css;
await page.setContent(`<!doctype html>${html}`);
await page.addStyleTag({ content: css });
await page.mouse.move(0, 0);
}
export function computed(locator, property, pseudo = null) {
return locator.evaluate((element, { property, pseudo }) =>
element.ownerDocument.defaultView.getComputedStyle(element, pseudo).getPropertyValue(property),
{ property, pseudo });
}
export async function expectPseudo(locator, pseudo, property, value) {
await expect.poll(() => computed(locator, property, pseudo)).toEqual(value);
}
export async function expectFits(locator) {
await expect(locator).toBeVisible();
const viewport = locator.page().viewportSize();
await expect.poll(async () => (await locator.boundingBox()).x).toBeGreaterThanOrEqual(-1);
await expect.poll(async () => {
const bounds = await locator.boundingBox();
return bounds.x + bounds.width;
}).toBeLessThanOrEqual(viewport.width + 1);
expect(await locator.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
}

146
e2e/nds-controls.spec.js Normal file
View file

@ -0,0 +1,146 @@
// Rôle : vérifie les tailles et états rendus des familles de contrôles NDS.
import { test, expect } from "@playwright/test";
import { mountStyles, computed, expectPseudo } from "./fixtures/compiled-styles.js";
test("fields, selects and date triggers keep public sizes and keyboard focus", async ({ page }) => {
for (const [size, height, textareaHeight] of [["md", 44, 108], ["sm", 36, 88], ["xs", 32, 72]]) {
await mountStyles(page, `<div style="padding:24px;display:grid;gap:12px;width:300px">
<input class="nds-input" type="text" data-size="${size}" placeholder="Name">
<input class="nds-time" type="time" data-size="${size}">
<select class="nds-select" data-size="${size}"><option>Choice</option></select>
<textarea class="nds-textarea" data-size="${size}"></textarea>
<button class="nds-select-trigger" data-size="${size}">Choice</button>
<button class="nds-date-trigger" data-size="${size}">Date</button>
</div>`);
for (const field of await page.locator("input, select, textarea, button").all()) {
const isTextarea = await field.evaluate((element) => element.tagName === "TEXTAREA");
await expect(field).toHaveCSS("min-height", `${isTextarea ? textareaHeight : height}px`);
await field.focus();
await expect(field).toHaveCSS("border-top-color", "rgb(139, 92, 246)");
await expect(field).toHaveCSS("box-shadow", "rgba(139, 92, 246, 0.16) 0px 0px 0px 3px");
await expect(field).toHaveCSS("outline-style", "solid");
await field.evaluate((element) => { element.disabled = true; });
await expect(field).toHaveCSS("cursor", "not-allowed");
}
await expect(page.locator("textarea")).toHaveCSS("resize", "vertical");
await expect(page.locator("select")).toHaveCSS("appearance", "none");
await expect(page.locator("select")).toHaveCSS("background-image", /data:image\/svg/);
expect(await computed(page.locator(".nds-date-trigger"), "mask-image", "::after")).toContain("data:image/svg");
await expectPseudo(page.locator(".nds-date-trigger"), "::after", "pointer-events", "none");
}
});
test("text and icon buttons retain dimensions, variants, active and joined edges", async ({ page }) => {
await mountStyles(page, `<div style="padding:24px;display:flex;flex-wrap:wrap;gap:12px">
${[["md", 40], ["sm", 34], ["xs", 30], ["xxs", 26], ["micro", 22]].map(([size]) =>
`<button class="nds-icon-button" data-size="${size}"><i class="ui-icon"></i></button>`).join("")}
${["md", "sm", "xs"].map((size) => `<button class="nds-button" data-size="${size}">${size}</button>`).join("")}
${["primary", "secondary", "ghost", "dashed", "danger"].map((variant) => `<button class="nds-button" data-variant="${variant}">${variant}</button>`).join("")}
<button id="edge" class="nds-icon-button" data-edge="left" data-border="joined">L</button>
</div>`);
for (const [size, button, icon] of [["md", 40, 21], ["sm", 34, 19], ["xs", 30, 17], ["xxs", 26, 15], ["micro", 22, 12]]) {
const target = page.locator(`.nds-icon-button[data-size="${size}"]`);
await expect(target).toHaveCSS("width", `${button}px`);
await expect(target).toHaveCSS("height", `${button}px`);
await expect(target.locator("i")).toHaveCSS("width", `${icon}px`);
await expect(target.locator("i")).toHaveCSS("height", `${icon}px`);
await target.hover();
await expect(target).toHaveCSS("background-image", /139, 92, 246/);
await expect(target).toHaveCSS("width", `${button}px`);
}
await page.mouse.move(0, 0);
for (const [size, height] of [["md", 42], ["sm", 34], ["xs", 30]]) {
await expect(page.locator(`.nds-button[data-size="${size}"]`)).toHaveCSS("min-height", `${height}px`);
}
await expect(page.locator('[data-variant="primary"]')).toHaveCSS("background-image", /246, 196, 83/);
await expect(page.locator('[data-variant="danger"]')).toHaveCSS("color", "rgb(251, 113, 133)");
await expect(page.locator('[data-variant="ghost"]')).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(page.locator('[data-variant="dashed"]')).toHaveCSS("border-top-style", "dashed");
const toggle = page.locator('[data-variant="secondary"]');
await toggle.evaluate((element) => element.setAttribute("aria-pressed", "true"));
await expect(toggle).toHaveCSS("background-image", /246, 196, 83/);
await toggle.evaluate((element) => { element.disabled = true; });
await expect(toggle).toHaveCSS("cursor", "not-allowed");
await expect(page.locator("#edge")).toHaveCSS("border-top-right-radius", "0px");
await expect(page.locator("#edge")).toHaveCSS("border-right-color", "rgba(0, 0, 0, 0)");
});
test("switches and native checkboxes expose checked and disabled states at each size", async ({ page }) => {
for (const [size, height, width, trackHeight, off, on] of [["md", 34, 34, 18, 3, 17], ["lg", 40, 30, 16, 3, 15], ["sm", 28, 24, 14, 2, 12], ["xs", 26, 18, 12, 2, 7]]) {
await mountStyles(page, `<button style="margin:24px" class="nds-switch" data-size="${size}" aria-pressed="false"><span class="ui-icon"></span><i></i></button>`);
const control = page.locator("button");
const track = control.locator("i");
await expect(control).toHaveCSS("min-height", `${height}px`);
await expect(track).toHaveCSS("width", `${width}px`);
await expect(track).toHaveCSS("height", `${trackHeight}px`);
await expectPseudo(track, "::before", "left", `${off}px`);
await control.evaluate((element) => element.setAttribute("aria-pressed", "true"));
await expectPseudo(track, "::before", "left", `${on}px`);
await expectPseudo(track, "::before", "background-color", "rgb(246, 196, 83)");
}
await mountStyles(page, `<div style="padding:24px">
${["md", "sm", "xs"].map((size) => `<input type="checkbox" class="nds-checkbox" data-size="${size}" aria-label="${size}">`).join("")}
<label class="nds-native-switch"><input type="checkbox"><span></span><b>Native</b></label>
</div>`);
for (const [size, dimension] of [["md", 20], ["sm", 18], ["xs", 14]]) {
const checkbox = page.getByRole("checkbox", { name: size, exact: true });
await expect(checkbox).toHaveCSS("width", `${dimension}px`);
await expect(checkbox).toHaveCSS("height", `${dimension}px`);
await expectPseudo(checkbox, "::before", "transform", "matrix(0, 0, 0, 0, 0, 0)");
await checkbox.check();
await expectPseudo(checkbox, "::before", "transform", "matrix(1, 0, 0, 1, 0, 0)");
await checkbox.evaluate((element) => { element.disabled = true; });
await expect(checkbox).toHaveCSS("cursor", "not-allowed");
}
await page.getByText("Native", { exact: true }).click();
const native = page.locator(".nds-native-switch");
await expect(native.locator("input")).toBeChecked();
await expectPseudo(native.locator("span"), "::before", "left", "19px");
await native.locator("input").evaluate((element) => { element.disabled = true; });
await expect(native).toHaveCSS("cursor", "not-allowed");
});
test("steppers, radio groups and compound fields retain compact geometry", async ({ page }) => {
for (const [size, buttonSize, optionHeight] of [["md", 28, 34], ["sm", 18, 28], ["xs", 16, 26]]) {
await mountStyles(page, `<div style="padding:24px">
<div class="nds-quantity-stepper" data-size="${size}"><button>-</button><span class="nds-quantity-stepper-value"><input type="number" value="1" class="nds-quantity-stepper-input"></span><button>+</button></div>
<div class="nds-radio-button-group" data-size="${size}"><button>A</button><button class="active">B</button></div>
<div class="nds-compound-toggle-field"><button class="active">Hold</button><input type="number" class="nds-compound-toggle-field-input"></div>
</div>`);
for (const button of await page.locator(".nds-quantity-stepper button").all()) {
await expect(button).toHaveCSS("width", `${buttonSize}px`);
await expect(button).toHaveCSS("height", `${buttonSize}px`);
}
await expect(page.locator(".nds-radio-button-group > button").first()).toHaveCSS("min-height", `${optionHeight}px`);
await expect(page.locator(".nds-radio-button-group > .active")).toHaveCSS("background-image", /246, 196, 83/);
await expect(page.locator(".nds-compound-toggle-field-input")).toHaveCSS("min-height", "26px");
await expect(page.locator(".nds-compound-toggle-field-input")).toHaveCSS("width", "34px");
await expect(page.locator(".nds-compound-toggle-field > button")).toHaveCSS("background-image", /246, 196, 83/);
}
});
test("palettes, inline fields and autocomplete menus render their specialised states", async ({ page }) => {
await mountStyles(page, `<div style="padding:24px">
<button class="nds-swatch" style="--swatch-color:#22d3ee"></button>
<button class="nds-swatch" data-variant="rainbow"></button>
<button class="nds-large-icon-picker-trigger"><i class="ui-icon"></i></button>
<input class="nds-inline-input" type="text" value="Inline">
<textarea class="nds-textarea" data-variant="ghost">Memo</textarea>
<div class="nds-autocomplete-field"><input class="nds-input" type="text"><div class="nds-autocomplete-menu"><button class="nds-autocomplete-option">Choice</button></div></div>
</div>`);
await expectPseudo(page.locator(".nds-swatch").first(), "::before", "background-color", "rgb(34, 211, 238)");
expect(await computed(page.locator('[data-variant="rainbow"]'), "background-image", "::before")).toContain("conic-gradient");
await expect(page.locator(".nds-large-icon-picker-trigger")).toHaveCSS("width", "100px");
await expect(page.locator(".nds-large-icon-picker-trigger i")).toHaveCSS("width", "72px");
await expect(page.locator(".nds-inline-input")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await page.locator(".nds-inline-input").focus();
await expect(page.locator(".nds-inline-input")).toHaveCSS("border-top-color", "rgb(139, 92, 246)");
await expect(page.locator("textarea")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(page.locator(".nds-autocomplete-menu")).toHaveCSS("position", "absolute");
await expect(page.locator(".nds-autocomplete-menu")).toHaveCSS("overflow-y", "auto");
await page.locator(".nds-autocomplete-option").hover();
await expect(page.locator(".nds-autocomplete-option")).toHaveCSS("background-color", "rgba(246, 196, 83, 0.1)");
await page.mouse.move(0, 0);
await page.locator(".nds-autocomplete-option").evaluate((element) => element.classList.add("is-active"));
await expect(page.locator(".nds-autocomplete-option")).toHaveCSS("background-color", "rgba(246, 196, 83, 0.1)");
});

151
e2e/nds-patterns.spec.js Normal file
View file

@ -0,0 +1,151 @@
// Rôle : vérifie les surfaces, feedbacks, overlays et préférences visuelles compilés.
import { test, expect } from "@playwright/test";
import { mountStyles, computed, expectPseudo, expectFits } from "./fixtures/compiled-styles.js";
test("panels, tool surfaces and heroes keep layered non-intercepting decoration", async ({ page }) => {
await mountStyles(page, `<main>
<section class="nds-panel"><button>Panel</button></section>
<section class="nds-tool-panel" data-variant="interactive"><button>Tool</button></section>
<section class="nds-filter-panel"><button>Filter</button></section>
<header class="nds-page-hero"><h1>Hero</h1></header>
<div class="nds-toolbar"><button>A</button><button>B</button></div>
<div class="nds-section-heading"><h2>Heading</h2><button>Action</button></div>
<div class="nds-empty-state">Empty</div>
</main>`);
for (const selector of [".nds-panel", ".nds-tool-panel", ".nds-filter-panel", ".nds-page-hero"]) {
const surface = page.locator(selector);
await expect(surface).toHaveCSS("border-top-width", "1px");
await expect(surface).toHaveCSS("overflow", "hidden");
await expectPseudo(surface, "::before", "pointer-events", "none");
await expectPseudo(surface, "::after", "pointer-events", "none");
expect(await computed(surface, "background-image", "::after")).toContain("gradient");
await expect(surface.locator(":scope > *")).toHaveCSS("z-index", "1");
}
await expect(page.locator(".nds-tool-panel")).toHaveCSS("contain", "paint");
await page.locator(".nds-tool-panel").hover();
await expect(page.locator(".nds-tool-panel")).toHaveCSS("box-shadow", /139, 92, 246/);
await expect(page.locator(".nds-filter-panel")).toHaveCSS("backdrop-filter", "blur(10px)");
await expect(page.locator(".nds-page-hero")).toHaveCSS("min-height", "180px");
await expect(page.locator(".nds-toolbar")).toHaveCSS("flex-wrap", "wrap");
await expect(page.locator(".nds-section-heading")).toHaveCSS("justify-content", "space-between");
await expect(page.locator(".nds-empty-state")).toHaveCSS("min-height", "132px");
await expect(page.locator(".nds-empty-state")).toHaveCSS("align-content", "center");
});
test("badges, notices, progress and chips expose meaningful visual states", async ({ page }) => {
const tones = { success: "rgb(52, 211, 153)", warning: "rgb(251, 191, 36)", danger: "rgb(251, 113, 133)" };
await mountStyles(page, `<main>
${Object.keys(tones).map((tone) => `<span class="nds-badge" data-variant="${tone}">${tone}</span><div class="nds-inline-notice" data-variant="${tone}">${tone}</div><div class="nds-progress" data-variant="${tone}" style="width:200px"><span style="width:50%"></span></div>`).join("")}
<label class="nds-chip"><input type="checkbox">Choice</label>
</main>`);
for (const [tone, color] of Object.entries(tones)) {
await expect(page.locator(`.nds-badge[data-variant="${tone}"]`)).toHaveCSS("color", color);
await expect(page.locator(`.nds-inline-notice[data-variant="${tone}"]`)).toHaveCSS("color", color);
const progress = page.locator(`.nds-progress[data-variant="${tone}"]`);
await expect(progress).toHaveCSS("height", "8px");
await expect(progress).toHaveCSS("overflow", "hidden");
expect(await computed(progress.locator("span"), "background-image")).toContain(color);
await expect(progress.locator("span")).toHaveCSS("width", "99px");
await progress.locator("span").evaluate((element) => { element.style.width = "100%"; });
await expect(progress.locator("span")).toHaveCSS("width", "198px");
}
const chip = page.locator(".nds-chip");
await expect(chip).toHaveCSS("background-color", "rgba(5, 7, 17, 0.32)");
await chip.locator("input").check();
await page.mouse.move(0, 0);
await expect(chip).toHaveCSS("background-color", "rgba(139, 92, 246, 0.14)");
});
for (const width of [1280, 390]) {
test(`overlays fit, scroll internally and preserve stacking at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await mountStyles(page, `<div class="confirm-modal-root">
<div class="nds-modal-backdrop"></div>
<section class="nds-modal confirm-modal"><h2>Confirm</h2><div style="height:1200px">Long content</div><footer class="nds-modal-footer"><button>Close</button></footer></section>
</div>`);
const modal = page.locator(".nds-modal");
await expectFits(modal);
await expect(modal).toHaveCSS("overflow-y", "auto");
expect((await modal.boundingBox()).height).toBeLessThanOrEqual(752);
await modal.evaluate((element) => { element.scrollTop = 300; });
expect(await modal.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await expect(page.locator(".confirm-modal-root")).toHaveCSS("overscroll-behavior", "contain");
await expect(page.locator(".nds-modal-backdrop")).toHaveCSS("position", "fixed");
await expect(page.locator(".nds-modal-backdrop")).toHaveCSS("backdrop-filter", "blur(6px)");
await page.locator("body").evaluate((element) => element.classList.add("is-modal-open"));
await expect(page.locator("body")).toHaveCSS("overflow", "hidden");
await mountStyles(page, `<div class="drawer" aria-hidden="false"><div class="drawer-backdrop"></div>
<section class="drawer-panel nds-drawer-panel"><div style="height:1400px">Drawer</div></section>
<div class="drawer-resize-handle nds-drawer-resize-handle"></div></div>`);
const drawer = page.locator(".drawer-panel");
await expectFits(drawer);
await expect(drawer).toHaveCSS("top", width <= 760 ? "110px" : "68px");
await expect(drawer).toHaveCSS("position", "fixed");
await expect(drawer).toHaveCSS("overflow-y", "auto");
await expect(drawer).toHaveCSS("backdrop-filter", "blur(10px)");
await expect(page.locator(".drawer-resize-handle")).toHaveCSS("display", width <= 760 ? "none" : "block");
await page.locator(".drawer").evaluate((element) => element.setAttribute("aria-hidden", "true"));
await expect(drawer).toBeHidden();
await mountStyles(page, `<div class="tool-add-modal-root"><section class="nds-modal tool-add-modal">Tools</section></div>`);
await expectFits(page.locator(".tool-add-modal"));
await expect(page.locator(".tool-add-modal-root")).toHaveCSS("overscroll-behavior", "contain");
await expect(page.locator(".tool-add-modal-root")).toHaveCSS("z-index", "130");
});
test(`toast positions, popovers and reduced motion render correctly at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await mountStyles(page, `<div class="nds-popover">Popover</div>
<div class="nds-toast-stack notification-toast-stack" data-position="right"><div class="nds-toast notification-toast"><span>Notification</span><button>OK</button></div></div>`);
const stack = page.locator(".nds-toast-stack");
const toast = page.locator(".nds-toast");
await expect(stack).toHaveCSS("position", "fixed");
await expect(stack).toHaveCSS("pointer-events", "none");
await expect(toast).toHaveCSS("pointer-events", "auto");
await expect(toast).toHaveCSS("background-image", /conic-gradient/);
await expect.poll(async () => (await toast.boundingBox()).x + (await toast.boundingBox()).width).toBeLessThanOrEqual(width);
await expect(page.locator(".nds-popover")).toHaveCSS("overflow", "hidden");
await expect(page.locator(".nds-popover")).toHaveCSS("background-color", "rgba(7, 10, 24, 0.96)");
await stack.evaluate((element) => { element.dataset.position = "left"; element.classList.add("is-left"); });
await expect(stack).toHaveCSS("left", "20px");
await expect(toast).toHaveCSS("animation-name", /notification-toast-enter-left/);
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(toast).toHaveCSS("animation-duration", "1e-05s");
await expect(toast).toHaveCSS("animation-iteration-count", "1");
await expectFits(toast);
});
}
test("event sizes preserve metadata density and light/dark contrast", async ({ page }) => {
await mountStyles(page, `<main>${["md", "sm", "xs"].map((size) => `<div class="nds-event-surface" data-size="${size}"><button><strong>Title</strong><span>12:30</span></button></div>`).join("")}</main>`);
for (const [size, height] of [["md", 44], ["sm", 22], ["xs", 10]]) {
const event = page.locator(`[data-size="${size}"]`);
await expect(event).toHaveCSS("min-height", `${height}px`);
await expect(event.locator("span")).toHaveCSS("display", size === "md" ? "block" : "none");
await expect(event.locator("strong")).toHaveCSS("text-overflow", "ellipsis");
const light = await computed(event.locator("button"), "color");
await event.evaluate((element) => { element.dataset.variant = "dark"; element.style.setProperty("--nds-event-color", "#f6c453"); });
await expect.poll(() => computed(event.locator("button"), "color")).not.toBe(light);
await event.locator("button").hover();
await expect(event.locator("button")).toHaveCSS("background-color", size === "xs" ? "rgba(0, 0, 0, 0.18)" : "rgba(0, 0, 0, 0.14)");
}
});
test("scrollbars and nebula layers compile without blocking content", async ({ page }) => {
const classes = ["themed-scrollbar", "legacy-scrollbar", "nds-scrollbox", "module-content", "table-grid-wrap", "notepad-surface", "annotation-marker-list", "goal-tree-view-wrap", "filter-options"];
await mountStyles(page, classes.map((name) => `<div class="${name}" style="height:60px;overflow:auto"><div style="height:500px">Scroll</div></div>`).join(""));
for (const name of classes) {
const box = page.locator(`.${name}`);
await expect(box).toHaveCSS("scrollbar-width", "thin");
await expect(box).toHaveCSS("scrollbar-color", "rgb(88, 55, 174) rgba(0, 0, 0, 0)");
await expectPseudo(box, "::-webkit-scrollbar", "width", "6px");
await box.evaluate((element) => { element.scrollTop = 100; });
expect(await box.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
}
for (const pseudo of ["::before", "::after"]) {
await expectPseudo(page.locator("body"), pseudo, "position", "fixed");
await expectPseudo(page.locator("body"), pseudo, "pointer-events", "none");
expect(await computed(page.locator("body"), "background-image", pseudo)).toContain("radial-gradient");
}
});

95
e2e/style-layouts.spec.js Normal file
View file

@ -0,0 +1,95 @@
// Rôle : vérifie les layouts responsive réels et les contraintes de placement compilées.
import { test, expect } from "@playwright/test";
import { mountStyles, computed, expectFits } from "./fixtures/compiled-styles.js";
async function columns(locator, count) {
await expect(locator).toHaveCSS("display", "grid");
await expect.poll(async () => (await computed(locator, "grid-template-columns")).split(" ").length).toBe(count);
}
for (const width of [1280, 900, 390]) {
test(`home, about and library retain their responsive layout at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/");
const onboarding = page.locator(".home-onboarding");
await expectFits(onboarding);
await columns(page.locator(".home-onboarding-layout"), width <= 760 ? 1 : 2);
await expect(page.locator(".home-onboarding-slider figcaption")).toHaveCSS("position", "absolute");
await expect(page.locator(".home-onboarding-dots")).toHaveCSS("display", "flex");
await expect(page.locator(".home-onboarding-slider img")).toHaveCSS("object-fit", "cover");
const side = page.locator(".sidebar");
await expect(side).toHaveCSS("display", width <= 760 ? "none" : "flex");
await expect(page.locator(".sidebar-mascot")).toHaveCSS("display", width <= 1024 ? "none" : "block");
for (const link of await page.locator(".sidebar-about-link").all()) {
await expect(link).toHaveCSS("align-items", "center");
if (width <= 1024) await expect(link).toHaveCSS("justify-content", "center");
}
await page.goto("/#/about");
await expectFits(page.locator(".about-page"));
await expect(page.locator(".about-story-section")).toHaveCSS("display", "grid");
await columns(page.locator(".about-tools-list"), width <= 760 ? 1 : 2);
await expect(page.locator(".about-tool-item .module-icon").first()).toHaveCSS("width", "34px");
await page.goto("/#/library");
await expectFits(page.locator(".library-page"));
await columns(page.locator(".library-doc-layout"), width <= 760 ? 1 : 2);
expect(await page.locator("body").evaluate((element) => element.scrollWidth)).toBeLessThanOrEqual(width);
});
}
for (const width of [1280, 390]) {
test(`game grids and toolbox columns adapt without horizontal overflow at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await mountStyles(page, `<main>
<div class="game-layout"><aside class="game-filters"><div class="filter-panel nds-filter-panel">Filter</div></aside><div class="game-grid"><article>Game</article><article>Game</article></div></div>
<div class="modules" data-layout-cols="2"><div class="module-column"><article class="module">A</article></div><div class="module-column"><article class="module">B</article></div></div>
<div class="calculator-module"><div>Formula</div><div class="calculator-result"><strong>12</strong></div></div>
<div class="task-planner-module"><div class="task-planner-settings"><label>Reset</label><label>Time</label></div></div>
<div class="equipment-planner-summary"><div class="equipment-planner-summary-source-grid"><div>Equipment</div><div>Materials</div></div></div>
<div class="toolbox-actions-row"><div>Actions</div><div>Preferences</div></div>
</main>`);
await columns(page.locator(".game-layout"), width <= 760 ? 1 : 2);
await expect(page.locator(".game-grid")).toHaveCSS("display", "grid");
await columns(page.locator(".modules"), width <= 760 ? 1 : 2);
await expect(page.locator(".module-column").first()).toHaveCSS("display", width <= 760 ? "contents" : "grid");
await columns(page.locator(".calculator-module"), width <= 760 ? 1 : 2);
await expect(page.locator(".calculator-result")).toHaveCSS("min-height", "72px");
await expect(page.locator(".task-planner-module")).toHaveCSS("display", "grid");
await columns(page.locator(".task-planner-settings"), width <= 760 ? 1 : 2);
await expect(page.locator(".equipment-planner-summary")).toHaveCSS("padding", "12px");
await columns(page.locator(".equipment-planner-summary-source-grid"), width <= 760 ? 1 : 2);
await page.locator(".equipment-planner-summary-source-grid").evaluate((element) => element.classList.add("columns-1"));
await columns(page.locator(".equipment-planner-summary-source-grid"), 1);
await expect(page.locator(".toolbox-actions-row")).toHaveCSS("flex-direction", width <= 760 ? "column" : "row");
await page.locator(".modules").evaluate((element) => { element.dataset.layoutCols = "1"; });
await columns(page.locator(".modules"), 1);
await expectFits(page.locator("main"));
});
test(`cover crop and icon picker respect image geometry at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await mountStyles(page, `<main>
<section class="toolbox-cover-editor"><div class="toolbox-cover-crop-frame"></div></section>
<div class="toolbox-icon-picker"><button class="toolbox-icon-picker-button"><img alt="Icon"></button>
<div class="toolbox-icon-picker-menu"><button>A</button><button class="toolbox-cover-menu-action nds-button">Cover</button></div></div>
<div class="toolbox-game-icon"><img alt="Game"></div><a class="toolbox-hero-link">Game page</a>
<p class="dialogue-line"><i>Aside</i></p>
</main>`);
const frame = page.locator(".toolbox-cover-crop-frame");
await expectFits(frame);
const bounds = await frame.boundingBox();
expect(bounds.width / bounds.height).toBeCloseTo(620 / 248, 2);
await expect(frame).toHaveCSS("touch-action", "none");
await expect(frame).toHaveCSS("overflow", "hidden");
const icon = page.locator(".toolbox-icon-picker-button");
await expect(icon.locator("img")).toHaveCSS("object-fit", "contain");
await expect(icon.locator("img")).toHaveCSS("width", "72px");
expect(await computed(icon.locator("img"), "border-radius")).toBe(await computed(icon, "border-radius"));
await icon.evaluate((element) => element.classList.add("has-custom-cover"));
await expect(icon.locator("img")).toHaveCSS("object-fit", "cover");
await expect(page.locator(".toolbox-game-icon img")).toHaveCSS("object-fit", "cover");
await expect(page.locator(".toolbox-cover-menu-action")).toHaveCSS("grid-column", "1 / -1");
await expect(page.locator(".toolbox-icon-picker-menu")).toHaveCSS("position", "absolute");
await expect(page.locator(".toolbox-hero-link")).toHaveCSS("display", "flex");
await expect(page.locator(".dialogue-line i")).toHaveCSS("font-style", "italic");
});
}

View file

@ -0,0 +1,135 @@
// Rôle : vérifie les états visuels des outils, leurs pickers et les masques d'icônes compilés.
import { test, expect } from "@playwright/test";
import { mountStyles, computed, expectPseudo, expectFits } from "./fixtures/compiled-styles.js";
test("table cells distinguish selection, move targets, editing and errors", async ({ page }) => {
await mountStyles(page, `<div class="table-module"><div class="table-grid-wrap"><input type="text" class="table-cell-input" value="12"></div></div>`);
await expect(page.locator(".table-module")).toHaveCSS("display", "grid");
const cell = page.locator("input");
await expect(cell).toHaveCSS("caret-color", "rgba(0, 0, 0, 0)");
await cell.evaluate((element) => element.classList.add("is-selected"));
await expect(cell).toHaveCSS("box-shadow", /34, 211, 238/);
await cell.evaluate((element) => { element.className = "table-cell-input is-move-target"; });
await expect(cell).toHaveCSS("box-shadow", /246, 196, 83/);
await expect(cell).toHaveCSS("cursor", "move");
await cell.evaluate((element) => { element.className = "table-cell-input is-editing"; });
await cell.focus();
await expect(cell).not.toHaveCSS("caret-color", "rgba(0, 0, 0, 0)");
await cell.evaluate((element) => { element.blur(); element.className = "table-cell-input has-error"; });
await expect(cell).toHaveCSS("color", "rgb(254, 202, 202)");
});
test("goal nodes and NDS reorder indicators follow orientation and placement", async ({ page }) => {
await mountStyles(page, `<main>
<div class="goal-tree-node is-drop-target" data-reorder-orientation="vertical"><div class="goal-tree-node-card"><div class="goal-tree-node-content-row"><span>1</span><span>Goal</span></div></div></div>
<div class="nds-reorder-indicator" style="width:200px;height:60px"></div>
</main>`);
const node = page.locator(".goal-tree-node");
const card = page.locator(".goal-tree-node-card");
const indicator = page.locator(".nds-reorder-indicator");
await expect(card).toHaveCSS("border-top-color", "rgba(246, 196, 83, 0.48)");
await expect(page.locator(".goal-tree-node-content-row")).toHaveCSS("display", "grid");
for (const axis of ["vertical", "horizontal"]) {
for (const placement of ["before", "after"]) {
await node.evaluate((element, { axis, placement }) => { element.dataset.reorderOrientation = axis; element.classList.toggle("drop-after", placement === "after"); }, { axis, placement });
await indicator.evaluate((element, { axis, placement }) => { element.dataset.axis = axis; element.dataset.placement = placement; }, { axis, placement });
const side = axis === "vertical" ? (placement === "before" ? "top" : "bottom") : (placement === "before" ? "left" : "right");
const thickness = axis === "vertical" ? "height" : "width";
await expectPseudo(card, "::after", side, "-8px");
await expectPseudo(card, "::after", thickness, "3px");
await expectPseudo(indicator, "::before", side, "0px");
await expectPseudo(indicator, "::before", thickness, "3px");
await expectPseudo(card, "::after", "pointer-events", "none");
}
}
});
test("calendar picker opens on focus and alert controls retain blocked states", async ({ page }) => {
await mountStyles(page, `<main><form class="calendar-event-form">
<div class="calendar-month-date-dropdown"><button type="button" class="calendar-month-date-trigger">Date</button><div class="calendar-month-date-picker"><button type="button">1</button></div></div>
<div class="timer-alert-mode-switch calendar-alert-mode-switch"><button type="button" class="timer-alert-mode-button">Alert</button></div>
<button type="button" class="tool-split-scroll-toggle"><span class="ui-icon"></span><i></i></button>
</form></main>`);
await expect(page.locator("form")).toHaveCSS("display", "grid");
await expect(page.locator("form")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const picker = page.locator(".calendar-month-date-picker");
await expect(picker).toHaveCSS("opacity", "0");
await expect(picker).toHaveCSS("pointer-events", "none");
await page.locator(".calendar-month-date-trigger").focus();
await expect(picker).toHaveCSS("opacity", "1");
await expect(picker).toHaveCSS("pointer-events", "auto");
expect((await computed(picker, "grid-template-columns")).split(" ")).toHaveLength(7);
const alert = page.locator(".timer-alert-mode-switch");
await expect(alert).toHaveCSS("align-self", "start");
await alert.evaluate((element) => element.classList.add("is-blocked"));
await expect(alert.locator("button")).toHaveCSS("opacity", "0.2");
await expect(alert.locator("button")).toHaveCSS("cursor", "not-allowed");
const scroll = page.locator(".tool-split-scroll-toggle");
await scroll.focus();
await expect(picker).toHaveCSS("opacity", "0");
await expectPseudo(scroll.locator("i"), "::before", "left", "3px");
await scroll.evaluate((element) => element.classList.add("active"));
await expectPseudo(scroll.locator("i"), "::before", "left", "17px");
});
test("combo tokens distinguish analog, directional and held inputs", async ({ page }) => {
await mountStyles(page, `<main>
<div class="combo-hold-field"><div class="combo-hold-controls"><button class="active">Hold</button><input type="number" value="1"></div></div>
<span id="analog" class="combo-input-token combo-input-direction combo-device-n64 combo-value-stick-up">Up</span>
<span id="direction" class="combo-input-token combo-input-direction combo-device-n64 combo-value-up">Up</span>
<span class="combo-input-token combo-value-grab has-hold"><i class="ui-icon"></i></span>
</main>`);
await expect(page.locator(".combo-hold-controls")).toHaveCSS("width", "92px");
await expect(page.locator(".combo-hold-controls button")).toHaveCSS("background-image", /246, 196, 83/);
await expect(page.locator("#analog")).toHaveCSS("color", "rgb(165, 243, 252)");
await expect(page.locator("#direction")).toHaveCSS("color", "rgb(248, 217, 130)");
await expect(page.locator("#direction")).toHaveCSS("border-radius", "10px");
await expect(page.locator(".combo-value-grab .ui-icon")).toHaveCSS("width", "24px");
await expect(page.locator(".has-hold")).toHaveCSS("box-shadow", /7px/);
});
for (const width of [1280, 390]) {
test(`text exchange and image annotation keep usable bounds at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await mountStyles(page, `<main>
<div class="text-import-modal-dialog nds-modal"><form class="text-import-modal-form"><textarea class="has-error"></textarea><footer class="text-import-actions"><button>Import</button><button>Export</button></footer></form></div>
<div id="image" style="position:relative;width:300px;height:200px"><button class="annotation-marker">1</button></div>
</main>`);
await expectFits(page.locator(".text-import-modal-dialog"));
await expect(page.locator(".text-import-actions")).toHaveCSS("flex-wrap", "wrap");
await expect(page.locator("textarea")).toHaveCSS("border-top-color", "rgba(248, 113, 113, 0.75)");
const marker = page.locator(".annotation-marker");
for (const position of ["0%", "50%", "100%"]) {
await marker.evaluate((element, position) => { element.style.setProperty("--marker-x", position); element.style.setProperty("--marker-y", position); }, position);
const box = await marker.boundingBox();
const frame = await page.locator("#image").boundingBox();
expect(box.width).toBe(30);
expect(box.height).toBe(30);
expect(box.x).toBeGreaterThanOrEqual(frame.x);
expect(box.y).toBeGreaterThanOrEqual(frame.y);
expect(box.x + box.width).toBeLessThanOrEqual(frame.x + frame.width);
expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height);
}
});
}
test("public icon masks resolve to decodable assets instead of empty shapes", async ({ page }) => {
const icons = ["drag", "dropdown", "enter", "calculator", "scrollable", "hide", "eye-open", "eye-closed", "chevron-down", "chevron-up", "flip", "add", "add-row", "add-column", "remove-row", "remove-column", "table", "map", "grab", "shield", "gun"];
const modules = ["abacus", "calculator", "columns", "table", "chest-armor"];
await page.route("**/__style-icons", (route) => route.fulfill({ contentType: "text/html", body: "<!doctype html><html><body></body></html>" }));
await page.goto("/__style-icons");
await mountStyles(page, `<main>${icons.map((name) => `<i class="ui-icon ui-icon-${name}"></i>`).join("")}${modules.map((name) => `<i class="module-icon-svg module-icon-${name}"></i>`).join("")}</main>`);
for (const icon of await page.locator("i").all()) {
await expect(icon).toHaveCSS("mask-size", "contain");
const mask = await computed(icon, "mask-image");
expect(mask).toMatch(/^url\(".+"\)$/);
const url = JSON.parse(mask.slice(4, -1));
const decoded = await icon.evaluate(async (element, url) => {
const image = new element.ownerDocument.defaultView.Image();
image.src = url;
await image.decode();
return image.naturalWidth > 0 && image.naturalHeight > 0;
}, url);
expect(decoded, url).toBe(true);
}
});