lib/board-layout.test.ts
Rama main · réplica de lectura del repositorio original.
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { compile } from "tailwindcss";
import { describe, expect, it } from "vitest";
import { BOARD_COLUMN_CLASS, BOARD_SCROLLER_CLASS } from "./board-layout";
/**
* The width of a board column is decided in the CASCADE, not in the
* file that writes it.
*
* `BOARD_COLUMN_CLASS` stacks four widths on the same element — one per
* level — and the only one that matters is the last one that applies. But the order
* writing of media queries is not that of classes: Tailwind sorts the
* break points by value but does not compare `px` and `rem`, so the
* `wide:` of the application (1200px) outputs BEFORE the `sm:`/`lg:` of the core (40rem,
* 64rem). A column can therefore be three times as wide as the file
* announcement, without anything announcing it anywhere — it happened, and it read
* in ticket cards stretched above 1200 px.
*
* Rereading the classes doesn't check that. What verifies it: compiling them for
* true, with the design tokens, then replay the cascade at five widths of
* window. The test fails both if someone removes the `max-desktop:` and if
* a token change moves the threshold.
*/
const require = createRequire(import.meta.url);
/** What 1rem is worth in a media query: the default ROOT font size.
This is what makes it possible to compare the levels in `rem` of the heart to the threshold in `px`
of app/tokens.css — the comparison that Tailwind, precisely, does not make. */
const REM = 16;
/**
* Compiles classes with the REAL theme: Tailwind plus breaking points
* that of the application (`--breakpoint-wide`), which is the whole point.
*
* The `@import` of `tokens.css` are removed rather than resolved: they pull
* `tw-animate-css`, whose export is only resolved by the condition `style` that
* `require.resolve` ne sait pas demander. Rien de ce qu'ils apportent (des
* keyframes) only weighs one width; the `@theme` block of the file is read
* tel quel.
*/
async function buildCss(candidates: string[]): Promise<string> {
const loadStylesheet = async (id: string, base: string) => {
const file = id.startsWith(".") ? path.resolve(base, id) : require.resolve(id);
return {
path: file,
base: path.dirname(file),
content: readFileSync(file, "utf8"),
};
};
const tokens = readFileSync(path.join(process.cwd(), "app/tokens.css"), "utf8").replace(
/^\s*@import\s[^;]*;\s*$/gm,
"",
);
const compiler = await compile(
[
'@import "tailwindcss/theme.css" layer(theme);',
'@import "tailwindcss/utilities.css" layer(utilities);',
tokens,
"@theme { --breakpoint-wide: 1200px; }",
].join("\n"),
{ base: process.cwd(), loadStylesheet },
);
return compiler.build(candidates);
}
/** `(width >= 40rem)` / `(width < 1200px)` → does the condition depend on `viewport`? */
function conditionHolds(condition: string, viewport: number): boolean {
const m = /\(\s*width\s*(>=|<=|>|<)\s*([\d.]+)(px|rem)\s*\)/.exec(condition);
// A condition that cannot be read (`print`, `hover`…) must not be
// silently treated as true: the reading would be false without saying it.
if (!m) throw new Error(`Condition de media query non reconnue : ${condition}`);
const bound = Number(m[2]) * (m[3] === "rem" ? REM : 1);
switch (m[1]) {
case ">=":
return viewport >= bound;
case ">":
return viewport > bound;
case "<=":
return viewport <= bound;
default:
return viewport < bound;
}
}
/**
* The rules of the sheet: `selector` → `body`, in document order.
*
* The `@layer` are CROSSED (the utilities are generated in
* `@layer utilities`): a layer does not change the order between two rules which
* live inside, and it is this order that we replay.
*/
function flatRules(css: string, conditions: string[] = []): { selector: string; body: string; conditions: string[] }[] {
const rules: { selector: string; body: string; conditions: string[] }[] = [];
let depth = 0;
let start = 0;
let openedAt = -1;
for (let i = 0; i < css.length; i++) {
const c = css[i];
if (c === "{") {
if (depth === 0) openedAt = i;
depth++;
} else if (c === "}") {
depth--;
if (depth === 0) {
const selector = css.slice(start, openedAt).trim();
const body = css.slice(openedAt + 1, i);
if (selector.startsWith("@layer")) rules.push(...flatRules(body, conditions));
else if (selector.startsWith("@media"))
rules.push(...flatRules(body, [...conditions, selector.slice(6).trim()]));
else rules.push({ selector, body, conditions });
start = i + 1;
}
}
}
return rules;
}
/** The `width` posed by a rule body, if all its media queries hold. */
function widthInBody(body: string, viewport: number): string | null {
const nested = /@media([^{]*)\{/.exec(body);
if (nested) {
if (!conditionHolds(nested[1].trim(), viewport)) return null;
// The nested body: from the opening brace to its closing brace.
let depth = 0;
for (let i = nested.index + nested[0].length - 1; i < body.length; i++) {
if (body[i] === "{") depth++;
else if (body[i] === "}" && --depth === 0) {
return widthInBody(body.slice(nested.index + nested[0].length, i), viewport);
}
}
return null;
}
const decl = /(?:^|;)\s*width:\s*([^;]+)/.exec(body);
return decl ? decl[1].trim() : null;
}
/** The width that WINS at this window width — the last one that applies. */
async function resolvedWidth(viewport: number): Promise<string | null> {
const candidates = BOARD_COLUMN_CLASS.split(" ").filter((c) => /(^|:)w-/.test(c));
expect(candidates.length).toBeGreaterThan(1);
const css = await buildCss(candidates);
// The generated selector is escaped (`.max-desktop\:sm\:w-\[…\]`): remove the
// backslashes return it to the class name.
const wanted = new Set(candidates.map((c) => `.${c}`));
let winner: string | null = null;
for (const rule of flatRules(css)) {
if (!wanted.has(rule.selector.replace(/\\/g, ""))) continue;
if (!rule.conditions.every((condition) => conditionHolds(condition, viewport))) continue;
const width = widthInBody(rule.body, viewport);
if (width) winner = width;
}
return winner;
}
describe("largeur d'une colonne de board", () => {
it("téléphone (375 px) : une colonne pleine, qu'on feuillette", async () => {
expect(await resolvedWidth(375)).toBe("100%");
});
it("640 px : deux colonnes, gouttière déduite", async () => {
expect(await resolvedWidth(800)).toBe("calc((100% - 0.75rem) / 2)");
});
it("1024 px : trois colonnes", async () => {
expect(await resolvedWidth(1100)).toBe("calc((100% - 1.5rem) / 3)");
});
it("au-dessus de 1200 px la colonne est FIXE — le partage ne déborde pas", async () => {
// The heart of the test: without `max-desktop:` on fractions, `lg:` won here
// and made a column a third of the window.
expect(await resolvedWidth(1400)).toBe("22rem");
expect(await resolvedWidth(2400)).toBe("22rem");
});
});
describe("board scroller gutter", () => {
it("keeps a trailing flex spacer below the wide breakpoint", () => {
expect(BOARD_SCROLLER_CLASS).toContain("after:w-1");
expect(BOARD_SCROLLER_CLASS).toContain("sm:after:w-3");
expect(BOARD_SCROLLER_CLASS).toContain("wide:after:hidden");
});
it("matches scroll snap padding to the responsive visual gutter", () => {
expect(BOARD_SCROLLER_CLASS).toContain("px-4 scroll-px-4");
expect(BOARD_SCROLLER_CLASS).toContain("sm:px-6 sm:scroll-px-6");
});
});