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

@ -48,3 +48,4 @@
{"id":"int-6f399629fa8cc8212d36ca21853eaa52","kind":"field_change","created_at":"2026-09-13T17:46:00.430152831Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-6f399629fa8cc8212d36ca21853eaa52","kind":"field_change","created_at":"2026-09-13T17:46:00.430152831Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
{"id":"int-3823a391b02ae7a3d58956b24c10e998","kind":"field_change","created_at":"2026-09-13T18:43:29.457174095Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-3823a391b02ae7a3d58956b24c10e998","kind":"field_change","created_at":"2026-09-13T18:43:29.457174095Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
{"id":"int-e3a2bea901894d43a31eb628b588648a","kind":"field_change","created_at":"2026-09-14T07:08:33.027716428Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-e3a2bea901894d43a31eb628b588648a","kind":"field_change","created_at":"2026-09-14T07:08:33.027716428Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
{"id":"int-20d1e0d862e6155eadf2c36eced314db","kind":"field_change","created_at":"2026-09-14T11:56:36.962825878Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}

View file

@ -671,6 +671,7 @@ Select triggers :
Menus et palettes : Menus et palettes :
- utiliser `floating-control-menu` pour les menus flottants compacts ; - utiliser `floating-control-menu` pour les menus flottants compacts ;
- les suggestions `.nds-autocomplete-option` utilisent le même fond doré au survol et en état `.is-active`, sans être écrasées par le hover générique des boutons ;
- utiliser `compact-menu-button` pour les options d'icones ou d'actions compactes ; - utiliser `compact-menu-button` pour les options d'icones ou d'actions compactes ;
- utiliser `color-swatch`, `color-swatch-rainbow` et `color-swatch-active` pour les palettes couleur ; - utiliser `color-swatch`, `color-swatch-rainbow` et `color-swatch-active` pour les palettes couleur ;
- utiliser `large-icon-picker-trigger` pour les gros triggers de choix d'icone de toolbox ; - utiliser `large-icon-picker-trigger` pour les gros triggers de choix d'icone de toolbox ;

View file

@ -71,6 +71,14 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
bordures, séparateurs dorés, hover sans redimensionnement, focus clavier NDS bordures, séparateurs dorés, hover sans redimensionnement, focus clavier NDS
et faces MHW. Ils ne créent pas de snapshots visuels et ne remplacent pas les et faces MHW. Ils ne créent pas de snapshots visuels et ne remplacent pas les
parcours des pages réelles. parcours des pages réelles.
- Les suites `nds-controls`, `nds-patterns`, `style-layouts` et `tool-style-states`
complètent cette couverture : tailles et états NDS, panneaux, feedbacks,
modales/drawers/toasts, scrollbars, mouvement réduit, états des outils et
masques d'icônes décodables. Les layouts home/À propos/librairie sont aussi
vérifiés sur les pages réelles en desktop, tablette et mobile.
- `tests/static-styles.test.mjs` conserve les frontières d'architecture, les
conventions publiques et les limites de taille des fichiers, pas des preuves
de rendu basées sur la présence d'un nom de classe ou de mixin dans le Sass.
## Validation avant push ## Validation avant push

View file

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

View file

@ -9,6 +9,8 @@ export default [
ignores: [ ignores: [
"node_modules/**", "node_modules/**",
"website/dist/**", "website/dist/**",
"playwright-report/**",
"test-results/**",
".codex-home/**" ".codex-home/**"
] ]
}, },

View file

@ -1,4 +1,4 @@
// Rôle : vérifie que les contrôles React critiques restent actifs dans la configuration ESLint effective. // Rôle : vérifie les règles React actives et l'exclusion des artefacts générés du lint.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { ESLint } from "eslint"; import { ESLint } from "eslint";
@ -10,3 +10,11 @@ test("React effect and purity checks are enabled for application sources", async
assert.ok(config.rules[rule][0] > 0, `${rule} must remain enabled`); assert.ok(config.rules[rule][0] > 0, `${rule} must remain enabled`);
} }
}); });
test("browser reports are ignored without excluding authored E2E tests", async () => {
const eslint = new ESLint();
for (const path of ["playwright-report/trace/assets/viewer.js", "test-results/production/trace.js"]) {
assert.equal(await eslint.isPathIgnored(path), true, path);
}
assert.equal(await eslint.isPathIgnored("e2e/nds-controls.spec.js"), false);
});

View file

@ -1,32 +1,33 @@
// Rôle : vérifie les conventions statiques des styles Sass. // Rôle : protège les frontières Sass, les conventions publiques et le découpage des fichiers.
import { readFile } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
async function readJoinedStyles(paths) {
return (await Promise.all(paths.map((path) => readFile(path, "utf8")))).join("\n");
}
function countLines(source) { function countLines(source) {
return source.trimEnd().split("\n").length; return source.trimEnd().split("\n").length;
} }
test("style entrypoint and theme layers are wired", async () => { test("Sass layers retain ownership boundaries and exclude removed APIs", async () => {
const styles = await readFile("website/src/styles/main.scss", "utf8"); const root = "website/src/styles";
const styleNebula = await readFile("website/src/styles/_nebula.scss", "utf8"); const paths = (await readdir(root, { recursive: true })).filter((path) => path.endsWith(".scss"));
const styleTokens = await readFile("website/src/styles/_tokens.scss", "utf8"); const sources = new Map(await Promise.all(paths.map(async (path) => [path, await readFile(`${root}/${path}`, "utf8")])));
const styleBase = await readFile("website/src/styles/_base.scss", "utf8"); assert.doesNotMatch(sources.get("_base.scss"), /\.(?:card-icon-button|drawer-action-button|import-icon-button)/);
const styleMixins = await readFile("website/src/styles/_mixins.scss", "utf8"); assert.doesNotMatch(sources.get("_cards.scss"), /@use "design-system\/surfaces"|controls\.card-pattern|\.compact-data-card\s*\{/);
const styleShell = await readFile("website/src/styles/_shell.scss", "utf8"); assert.doesNotMatch(sources.get("_mhwilds.scss"), /\.game-(home-card|layout|grid)/);
const styleCards = await readFile("website/src/styles/_cards.scss", "utf8"); const home = [...sources].filter(([path]) => path === "_home.scss" || path.startsWith("home/")).map(([, source]) => source).join("\n");
const styleControls = await readFile("website/src/styles/design-system/_controls.scss", "utf8"); assert.doesNotMatch(home, /\.nebula-panel\s*\{/);
const styleAttributes = await readFile("website/src/styles/design-system/_attributes.scss", "utf8"); assert.doesNotMatch(sources.get("toolboxes/_notepad.scss"), /lecture seule/);
const styleControlsPatterns = await readFile("website/src/styles/design-system/controls/_patterns.scss", "utf8"); assert.match(sources.get("design-system/_controls.scss"), /@forward "controls\/patterns"/);
const styleAttributesPatterns = await readFile("website/src/styles/design-system/attributes/_patterns.scss", "utf8"); assert.match(sources.get("design-system/_attributes.scss"), /@use "attributes\/patterns"/);
const styleControlsButtons = await readFile("website/src/styles/design-system/controls/_buttons.scss", "utf8"); const branch = await readFile("website/src/features/toolboxes/modules/GoalTreeBranch.jsx", "utf8");
const styleAttributesButtons = await readFile("website/src/styles/design-system/attributes/_buttons.scss", "utf8"); assert.doesNotMatch(
const goalTreeBranch = await readFile("website/src/features/toolboxes/modules/GoalTreeBranch.jsx", "utf8"); [...sources.values(), branch].join("\n"),
const ndsPatternConsumers = await readJoinedStyles([ /(?:data-size="tiny"|icon-button(?:-icon)?\(tiny\)|\$size == tiny)/
);
});
test("product components keep the public NDS pattern vocabulary", async () => {
const paths = [
"website/src/pages/HomePage.jsx", "website/src/pages/HomePage.jsx",
"website/src/pages/LibraryPage.jsx", "website/src/pages/LibraryPage.jsx",
"website/src/pages/AboutPage.jsx", "website/src/pages/AboutPage.jsx",
@ -40,259 +41,14 @@ test("style entrypoint and theme layers are wired", async () => {
"website/src/components/ToolboxModals.jsx", "website/src/components/ToolboxModals.jsx",
"website/src/features/toolboxes/modules/AddToolControls.jsx", "website/src/features/toolboxes/modules/AddToolControls.jsx",
"website/src/features/toolboxes/modules/TextImportModal.jsx" "website/src/features/toolboxes/modules/TextImportModal.jsx"
]); ];
const styleHome = await readJoinedStyles([ const consumers = (await Promise.all(paths.map((path) => readFile(path, "utf8")))).join("\n");
"website/src/styles/_home.scss", for (const family of [
"website/src/styles/home/_shared.scss", "page-hero", "panel", "card", "card-cover", "tool-panel", "filter-panel",
"website/src/styles/home/_onboarding.scss", "chip", "badge", "empty-state", "inline-notice", "progress", "modal", "drawer-panel", "toast"
"website/src/styles/home/_library.scss", ]) {
"website/src/styles/home/library/_page.scss", assert.ok(consumers.includes(`nds-${family}`), `public nds-${family} convention must remain in use`);
"website/src/styles/home/library/_summary.scss", }
"website/src/styles/home/library/_toc.scss",
"website/src/styles/home/library/_tool-cards.scss",
"website/src/styles/home/library/_examples.scss",
"website/src/styles/home/library/_previews.scss",
"website/src/styles/home/_story.scss",
"website/src/styles/home/_about.scss",
"website/src/styles/home/about/_page.scss",
"website/src/styles/home/about/_story.scss",
"website/src/styles/home/about/_contribute.scss",
"website/src/styles/home/about/_tools.scss",
"website/src/styles/home/about/_reminders.scss",
"website/src/styles/home/_stats.scss"
]);
const styleToolboxesEntry = await readFile("website/src/styles/_toolboxes.scss", "utf8");
const styleToolboxesCore = await readFile("website/src/styles/toolboxes/_core.scss", "utf8");
const styleToolboxLayout = await readJoinedStyles([
"website/src/styles/toolboxes/_layout.scss",
"website/src/styles/toolboxes/layout/_header.scss",
"website/src/styles/toolboxes/layout/_icon-picker.scss",
"website/src/styles/toolboxes/layout/_cover-editor.scss",
"website/src/styles/toolboxes/layout/_tutorial.scss",
"website/src/styles/toolboxes/layout/_add-tools.scss",
"website/src/styles/toolboxes/layout/_preferences.scss",
"website/src/styles/toolboxes/layout/_modules.scss"
]);
const styleToolboxShell = await readFile("website/src/styles/toolboxes/_module-shell.scss", "utf8");
const styleToolboxTextExchange = await readFile("website/src/styles/toolboxes/_text-exchange.scss", "utf8");
const styleToolboxNotepad = await readFile("website/src/styles/toolboxes/_notepad.scss", "utf8");
const styleToolboxChecklist = await readFile("website/src/styles/toolboxes/_checklist.scss", "utf8");
const styleToolboxCalculator = await readFile("website/src/styles/toolboxes/_calculator.scss", "utf8");
const styleToolboxCalculatorResults = await readFile("website/src/styles/toolboxes/_calculator-results.scss", "utf8");
const styleToolboxTable = await readFile("website/src/styles/toolboxes/_table.scss", "utf8");
const styleToolboxCombos = await readJoinedStyles([
"website/src/styles/toolboxes/_combos.scss",
"website/src/styles/toolboxes/combos/_editor.scss",
"website/src/styles/toolboxes/combos/_palette.scss",
"website/src/styles/toolboxes/combos/_list.scss",
"website/src/styles/toolboxes/combos/_sequence.scss",
"website/src/styles/toolboxes/combos/_tokens.scss",
"website/src/styles/toolboxes/combos/_hold.scss"
]);
const styleToolboxImages = await readFile("website/src/styles/toolboxes/_images.scss", "utf8");
const styleToolboxGoalTree = await readJoinedStyles([
"website/src/styles/toolboxes/_goal-tree.scss",
"website/src/styles/toolboxes/goal-tree/_editor.scss",
"website/src/styles/toolboxes/goal-tree/_view.scss",
"website/src/styles/toolboxes/goal-tree/_nodes.scss",
"website/src/styles/toolboxes/goal-tree/_node-card.scss",
"website/src/styles/toolboxes/goal-tree/_node-content.scss",
"website/src/styles/toolboxes/goal-tree/_node-actions.scss",
"website/src/styles/toolboxes/goal-tree/_node-drag.scss",
"website/src/styles/toolboxes/goal-tree/_node-responsive.scss"
]);
const styleToolboxCalendar = await readJoinedStyles([
"website/src/styles/toolboxes/_calendar.scss",
"website/src/styles/toolboxes/calendar/_base.scss",
"website/src/styles/toolboxes/calendar/_form.scss",
"website/src/styles/toolboxes/calendar/_form-layout.scss",
"website/src/styles/toolboxes/calendar/_date-picker.scss",
"website/src/styles/toolboxes/calendar/_event-controls.scss",
"website/src/styles/toolboxes/calendar/_form-actions.scss",
"website/src/styles/toolboxes/calendar/_week.scss",
"website/src/styles/toolboxes/calendar/_month.scss",
"website/src/styles/toolboxes/calendar/_responsive.scss"
]);
const styleToolboxSharedControls = await readJoinedStyles([
"website/src/styles/toolboxes/_shared-controls.scss",
"website/src/styles/toolboxes/shared-controls/_selects.scss",
"website/src/styles/toolboxes/shared-controls/_split-controls.scss",
"website/src/styles/toolboxes/shared-controls/_split-tree.scss",
"website/src/styles/toolboxes/shared-controls/_mix-tags.scss",
"website/src/styles/toolboxes/shared-controls/_entries.scss"
]);
const styleOverlays = await readJoinedStyles([
"website/src/styles/_overlays.scss",
"website/src/styles/overlays/_image-viewer.scss",
"website/src/styles/overlays/_drawers.scss",
"website/src/styles/overlays/_modals.scss",
"website/src/styles/overlays/_notifications.scss",
"website/src/styles/overlays/_tool-add.scss"
]);
const styleIcons = await readJoinedStyles([
"website/src/styles/_icons.scss",
"website/src/styles/icons/_base.scss",
"website/src/styles/icons/_navigation.scss",
"website/src/styles/icons/_toolbox.scss",
"website/src/styles/icons/_actions.scss",
"website/src/styles/icons/_equipment.scss"
]);
const styleGames = await readJoinedStyles([
"website/src/styles/_games.scss",
"website/src/styles/games/_home-cards.scss",
"website/src/styles/games/_heading.scss",
"website/src/styles/games/_lists.scss",
"website/src/styles/games/_filters.scss",
"website/src/styles/games/_cards.scss"
]);
const styleMhwilds = await readFile("website/src/styles/_mhwilds.scss", "utf8");
const styleResponsive = await readFile("website/src/styles/_responsive.scss", "utf8");
assert.match(styles, /@use "tokens"/);
assert.match(styles, /@use "nebula"/);
assert.match(styles, /@use "toolboxes"/);
assert.match(styleToolboxesEntry, /@use "toolboxes\/core"/);
assert.match(styleToolboxesCore, /@use "layout"/);
assert.match(styleToolboxesCore, /@use "module-shell"/);
assert.match(styleToolboxesCore, /@use "task-planner"/);
assert.match(styleToolboxesCore, /@use "equipment-planner"/);
assert.match(styleToolboxesCore, /@use "shared-controls"/);
assert.match(styles, /@use "mhwilds"/);
assert.match(styles, /@use "diablo4"/);
assert.match(styleTokens, /--gradient-brand/);
assert.match(styleBase, /:is\(/);
assert.doesNotMatch(styleBase, /\.card-icon-button/);
assert.doesNotMatch(styleBase, /\.drawer-action-button/);
assert.doesNotMatch(styleBase, /\.import-icon-button/);
assert.match(styleBase, /themed-scrollbar/);
assert.match(styleBase, /body\.is-modal-open/);
assert.match(styleNebula, /@function star-field/);
assert.match(styleNebula, /body::after/);
assert.match(styleHome, /\.dialogue-line i/);
assert.match(styleHome, /font-style: italic/);
assert.match(styleHome, /\.about-page/);
assert.doesNotMatch(styleHome, /\.nebula-panel\s*\{/);
assert.match(styleHome, /\.home-onboarding-layout/);
assert.match(styleHome, /\.home-onboarding-slider figcaption/);
assert.match(styleHome, /\.home-onboarding-dots/);
assert.match(styleHome, /\.about-story-section/);
assert.match(styleHome, /\.about-tools-list/);
assert.match(styleHome, /\.about-tool-item \.module-icon/);
assert.match(styleMixins, /@mixin gold-panel-frame/);
assert.match(styleShell, /\.sidebar-about-link/);
assert.match(styleShell, /\.sidebar-mascot/);
assert.match(styleToolboxShell, /\.module-icon-abacus/);
assert.match(styleToolboxShell, /\.module-icon-calculator/);
assert.match(styleToolboxShell, /\.module-icon-columns/);
assert.match(styleToolboxShell, /\.module-icon-table/);
assert.match(styleToolboxCalculator, /\.calculator-module/);
assert.match(styleToolboxTable, /\.table-module/);
assert.match(styleToolboxTable, /\.table-cell-input\.is-selected/);
assert.match(styleToolboxTable, /\.table-cell-input\.is-move-target/);
assert.match(styleToolboxGoalTree, /\.goal-tree-node-card/);
assert.match(styleToolboxGoalTree, /\.goal-tree-node-content-row/);
assert.match(styleToolboxGoalTree, /\.goal-tree-node.is-drop-target/);
assert.match(styleToolboxCalendar, /\.calendar-event-form/);
assert.match(styleToolboxCalendar, /\.calendar-month-date-picker/);
assert.match(styleToolboxCalendar, /\.calendar-alert-mode-switch/);
assert.match(styles, /@use "design-system\/attributes"/);
assert.match(styleToolboxLayout, /\.toolbox-icon-picker/);
assert.match(styleToolboxLayout, /border-radius: inherit/);
assert.match(styleToolboxLayout, /\.toolbox-icon-picker-button\.has-custom-cover img/);
assert.match(styleToolboxLayout, /grid-column: 1 \/ -1/);
assert.match(styleToolboxLayout, /@use "layout\/cover-editor"/);
assert.match(styleToolboxLayout, /\.toolbox-cover-editor/);
assert.match(styleToolboxLayout, /\.toolbox-cover-crop-frame/);
assert.match(styleToolboxLayout, /aspect-ratio: 620 \/ 248/);
assert.match(styleToolboxLayout, /\.toolbox-game-icon/);
assert.match(styleToolboxLayout, /\.toolbox-hero-link/);
assert.match(styleToolboxLayout, /\.toolbox-actions-row/);
assert.match(styleToolboxTextExchange, /\.text-import-actions/);
assert.match(styleToolboxTextExchange, /\.text-import-modal-dialog/);
assert.doesNotMatch(styleToolboxNotepad, /lecture seule/);
assert.match(styleIcons, /ui-icon-drag/);
assert.match(styleIcons, /ui-icon-dropdown/);
assert.match(styleIcons, /ui-icon-enter/);
assert.match(styleIcons, /ui-icon-calculator/);
assert.match(styleIcons, /ui-icon-scrollable/);
assert.match(styleIcons, /ui-icon-hide/);
assert.match(styleIcons, /ui-icon-eye-open/);
assert.match(styleIcons, /ui-icon-eye-closed/);
assert.match(styleIcons, /ui-icon-chevron-down/);
assert.match(styleIcons, /ui-icon-chevron-up/);
assert.match(styleIcons, /ui-icon-flip/);
assert.match(styleIcons, /ui-icon-add/);
assert.match(styleIcons, /ui-icon-add-row/);
assert.match(styleIcons, /ui-icon-add-column/);
assert.match(styleIcons, /ui-icon-remove-row/);
assert.match(styleIcons, /ui-icon-remove-column/);
assert.match(styleIcons, /ui-icon-table/);
assert.match(styleIcons, /ui-icon-map/);
assert.match(styleIcons, /ui-icon-grab/);
assert.match(styleIcons, /ui-icon-shield/);
assert.match(styleIcons, /ui-icon-gun/);
assert.match(styleToolboxShell, /module-icon-chest-armor/);
assert.match(styleToolboxCombos, /\.combo-hold-controls/);
assert.match(styleToolboxCombos, /\.combo-input-direction/);
assert.match(styleToolboxCombos, /\.combo-device-n64\.combo-value-stick-up/);
assert.match(styleToolboxCombos, /\.combo-device-n64\.combo-value-up/);
assert.match(styleToolboxCombos, /\.combo-value-grab \.ui-icon/);
assert.match(styleToolboxChecklist, /\.timer-alert-mode-button/);
assert.match(styleToolboxSharedControls, /\.timer-alert-mode-switch\.is-blocked/);
assert.match(styleToolboxCalculatorResults, /\.calculator-result/);
assert.match(styleToolboxSharedControls, /\.tool-split-scroll-toggle/);
assert.match(styleToolboxImages, /\.annotation-marker/);
assert.match(styleGames, /\.game-home-card/);
assert.match(styleGames, /\.game-layout/);
assert.match(styleGames, /\.game-grid/);
assert.match(styleGames, /\.filter-panel/);
assert.match(styleControls, /@forward "controls\/patterns"/);
assert.match(styleAttributes, /@use "attributes\/patterns"/);
assert.match(styleControlsPatterns, /@mixin panel-surface/);
assert.match(styleControlsPatterns, /@mixin toast-pattern/);
assert.match(styleAttributesPatterns, /\.nds-panel/);
assert.match(styleAttributesPatterns, /\.nds-card/);
assert.match(styleAttributesPatterns, /\.nds-tool-panel/);
assert.match(styleAttributesPatterns, /\.nds-badge/);
assert.match(styleAttributesPatterns, /\.nds-chip/);
assert.match(styleAttributesPatterns, /\.nds-empty-state/);
assert.match(styleAttributesPatterns, /\.nds-inline-notice/);
assert.match(styleAttributesPatterns, /\.nds-progress/);
assert.match(styleAttributesPatterns, /\.nds-filter-panel/);
assert.match(styleAttributesPatterns, /\.nds-modal/);
assert.match(styleAttributesPatterns, /\.nds-drawer-panel/);
assert.match(styleAttributesPatterns, /\.nds-toast/);
assert.match(ndsPatternConsumers, /nds-page-hero/);
assert.match(ndsPatternConsumers, /nds-panel/);
assert.match(ndsPatternConsumers, /nds-card/);
assert.match(ndsPatternConsumers, /nds-card-cover/);
assert.match(ndsPatternConsumers, /nds-tool-panel/);
assert.match(ndsPatternConsumers, /nds-filter-panel/);
assert.match(ndsPatternConsumers, /nds-chip/);
assert.match(ndsPatternConsumers, /nds-badge/);
assert.match(ndsPatternConsumers, /nds-empty-state/);
assert.match(ndsPatternConsumers, /nds-inline-notice/);
assert.match(ndsPatternConsumers, /nds-progress/);
assert.match(ndsPatternConsumers, /nds-modal/);
assert.match(ndsPatternConsumers, /nds-drawer-panel/);
assert.match(ndsPatternConsumers, /nds-toast/);
assert.doesNotMatch(styleCards, /@use "design-system\/surfaces"/);
assert.doesNotMatch(styleCards, /controls\.card-pattern/);
assert.match(styleMhwilds, /\.mhwilds-card/);
assert.match(styleMhwilds, /\.mhwilds-flip-indicator/);
assert.doesNotMatch(styleMhwilds, /\.game-(home-card|layout|grid)/);
assert.doesNotMatch(styleCards, /\.compact-data-card\s*\{/);
assert.doesNotMatch(
[
goalTreeBranch,
styleToolboxGoalTree,
styleControlsButtons,
styleAttributesButtons
].join("\n"),
/(?:data-size="tiny"|icon-button(?:-icon)?\(tiny\)|\$size == tiny)/
);
assert.match(styleOverlays, /overscroll-behavior: contain/);
assert.match(styleResponsive, /@media \(max-width: 760px\)/);
}); });
test("large sass files stay split by stable responsibility", async () => { test("large sass files stay split by stable responsibility", async () => {

View file

@ -153,7 +153,7 @@
white-space: nowrap; white-space: nowrap;
} }
.nds-autocomplete-option:hover, .nds-autocomplete-option:hover:not(:disabled),
.nds-autocomplete-option.is-active { .nds-autocomplete-option.is-active {
border-color: rgba(246, 196, 83, 0.36); border-color: rgba(246, 196, 83, 0.36);
background: rgba(246, 196, 83, 0.1); background: rgba(246, 196, 83, 0.1);