From 3af28dbf5d7007372f644365c684f5b7655e1de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 20:54:10 -0300 Subject: [PATCH 1/3] chore: start #649 From 3402cde2dddf9b0f1128fc8cb91a125dc8cc1180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 20:59:08 -0300 Subject: [PATCH 2/3] refactor: layout.ts owns the breakpoints; the lint and the store derive from it responsive.svelte.ts and lint-tokens.ts no longer repeat the scale. The breakpoint lint now also reads .astro/.ts/.js files and matchMedia strings, which found two off-scale (max-width: 900px) in site/src (motion.ts, index.astro), now 899.98px. Fail before: 2 new lint tests fail on the old lint; pass after. Closes #649 --- packages/ui/scripts/lint-tokens.ts | 37 +++++++++++++++---- .../src/lib/stores/responsive.svelte.test.ts | 6 ++- .../ui/src/lib/stores/responsive.svelte.ts | 13 +++---- packages/ui/tests/lint-tokens.test.ts | 20 +++++++++- site/src/lib/motion.ts | 2 +- site/src/pages/index.astro | 2 +- 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/packages/ui/scripts/lint-tokens.ts b/packages/ui/scripts/lint-tokens.ts index dd1967e1..782ee011 100644 --- a/packages/ui/scripts/lint-tokens.ts +++ b/packages/ui/scripts/lint-tokens.ts @@ -22,6 +22,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; +import { BREAKPOINTS } from "../src/lib/components/Layout/layout.ts"; export interface Violation { file: string; @@ -57,8 +58,22 @@ const BACKDROP_OWNERS = /(^|[\\/])styles[\\/](surface|materials)\.css$/; const INPUT_SELECTOR = /(^|[\s,>+~(])(input|textarea|select)\b|\[contenteditable|-input\b|__input\b/; -/** Allowed width breakpoints (px) for min-width; max-width uses the same minus 0.02. */ -const BREAKPOINTS = [600, 900, 1200, 1536]; +/** Allowed width breakpoints (px) for min-width; max-width uses the same minus 0.02. The scale is owned by layout.ts. */ +export const BREAKPOINT_WIDTHS: number[] = Object.values(BREAKPOINTS); + +/** The message when a width is off the shared scale, else undefined. */ +function offScale( + kind: string, + value: string, + unit: string, + text = `(${kind}-width: ${value}${unit})` +) { + const px = Number(value) * (unit === "px" ? 1 : 16); + const ok = BREAKPOINT_WIDTHS.some((b) => (kind === "min" ? px === b : px === b - 0.02)); + return ok + ? undefined + : `${text}: use the breakpoint scale (min-width ${BREAKPOINT_WIDTHS.join("/")}px, max-width N-0.02px) or a container query`; +} /** Pure: lint one file's source. Test harnesses skip the viewport-unit and breakpoint rules. */ export function lintSource(file: string, source: string): Violation[] { @@ -104,6 +119,15 @@ export function lintSource(file: string, source: string): Violation[] { 'backdrop-filter belongs to styles/surface.css: set data-ca-blur="" on the element (tiers: ultra-thin, thin, regular, thick, chrome, mat-thin, mat-regular, overlay, chrome-layer, tooltip) or use .ca-surface', }); + // A width in a `matchMedia("(max-width: …)")` string is the same viewport scale as one in a stylesheet. + if (!isHarness && /\.(ts|js|astro|svelte)$/.test(file)) + for (const m of src.matchAll( + /matchMedia\(\s*["'`][^"'`]*?\((min|max)-width:\s*([\d.]+)(px|rem|em)\)/g + )) { + const bad = offScale(m[1], m[2], m[3]); + if (bad) v.push({ file, line: lineOf(src, m.index ?? 0), rule: "breakpoint", message: bad }); + } + for (const r of regions) { if (!isHarness && !/styles[\\/]tokens\.css$/.test(file)) for (const m of r.text.matchAll(/(? (m[1] === "min" ? px === b : px === b - 0.02)); - if (!ok) + const bad = offScale(m[1], m[2], m[3], m[0]); + if (bad) v.push({ file, line: lineOf(src, r.offset + (m.index ?? 0)), rule: "breakpoint", - message: `${m[0]}: use the breakpoint scale (min-width ${BREAKPOINTS.join("/")}px, max-width N-0.02px) or a container query`, + message: bad, }); } @@ -189,7 +212,7 @@ function* walk(dir: string): Generator { const p = join(dir, e.name); if (e.isDirectory()) { if (e.name !== "node_modules" && e.name !== "__screenshots__") yield* walk(p); - } else if (/\.(css|svelte)$/.test(e.name)) yield p; + } else if (/\.(css|svelte|astro|ts|js)$/.test(e.name)) yield p; } } diff --git a/packages/ui/src/lib/stores/responsive.svelte.test.ts b/packages/ui/src/lib/stores/responsive.svelte.test.ts index 26e4b51d..31ea116a 100644 --- a/packages/ui/src/lib/stores/responsive.svelte.test.ts +++ b/packages/ui/src/lib/stores/responsive.svelte.test.ts @@ -1,6 +1,7 @@ import { afterAll, describe, expect, it } from "vitest"; import { page } from "vitest/browser"; import { VIEWPORTS } from "../../../tests/viewports"; +import { BREAKPOINTS } from "../components/Layout/layout.js"; import { responsive } from "./responsive.svelte.js"; responsive.init(); @@ -51,7 +52,8 @@ describe("responsive store", () => { expect(responsive.down("lg")).toBe(false); }); - it("exposes the breakpoint table", () => { - expect(responsive.BREAKPOINTS).toEqual({ xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536 }); + // layout.ts owns the scale (#649): the store adds `xs` (0) to it and nothing else. + it("exposes the breakpoint table of layout.ts plus xs", () => { + expect(responsive.BREAKPOINTS).toEqual({ xs: 0, ...BREAKPOINTS }); }); }); diff --git a/packages/ui/src/lib/stores/responsive.svelte.ts b/packages/ui/src/lib/stores/responsive.svelte.ts index 3dcdce81..6d6d76b1 100644 --- a/packages/ui/src/lib/stores/responsive.svelte.ts +++ b/packages/ui/src/lib/stores/responsive.svelte.ts @@ -1,14 +1,11 @@ // ─── Responsive breakpoint store (Svelte 5 runes) ──────────────────────────── -export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl"; +import { BREAKPOINTS as SCALE } from "../components/Layout/layout.js"; -const BREAKPOINTS: Record = { - xs: 0, - sm: 600, - md: 900, - lg: 1200, - xl: 1536, -}; +export type Breakpoint = "xs" | keyof typeof SCALE; + +// The scale is owned by components/Layout/layout.ts; the store adds the `xs` floor. +const BREAKPOINTS: Record = { xs: 0, ...SCALE }; function createResponsiveStore() { // undefined on the server and on the first client render (no hydration mismatch); diff --git a/packages/ui/tests/lint-tokens.test.ts b/packages/ui/tests/lint-tokens.test.ts index a4ffe778..e7aae508 100644 --- a/packages/ui/tests/lint-tokens.test.ts +++ b/packages/ui/tests/lint-tokens.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { lintSource } from "../scripts/lint-tokens.ts"; +import { BREAKPOINT_WIDTHS, lintSource } from "../scripts/lint-tokens.ts"; +import { BREAKPOINTS } from "../src/lib/components/Layout/layout.ts"; const rules = (file: string, src: string) => lintSource(file, src).map((v) => v.rule); @@ -129,6 +130,23 @@ describe("lint-tokens", () => { expect(lintSource("x.test.css", css).filter((x) => x.rule === "breakpoint")).toEqual([]); }); + // One owner of the scale (#649): layout.ts. The lint derives from it (the store: responsive.svelte.test.ts), so a new step is one edit. + it("the lint takes its scale from layout.ts", () => { + expect(BREAKPOINT_WIDTHS).toEqual(Object.values(BREAKPOINTS)); + }); + + it("flags an off-scale width in a .astro "; + expect(rules("Foo.astro", astro)).toEqual(["breakpoint"]); + expect(rules("Foo.astro", astro.replace("900px", "899.98px"))).toEqual([]); + const ts = 'const small = matchMedia("(max-width: 900px)").matches;'; + expect(rules("motion.ts", ts)).toEqual(["breakpoint"]); + expect(rules("motion.ts", 'matchMedia("(max-width: 899.98px)")')).toEqual([]); + expect(rules("motion.ts", 'matchMedia("(pointer: coarse)")')).toEqual([]); + const inline = ''; + expect(rules("index.astro", inline)).toEqual(["breakpoint"]); + }); + it("allows any width in a container query: it measures a box, not the viewport scale", () => { const cq = "@container (min-width: 21.5rem) { .a { gap: 0; } } @container card (max-width: 33rem) {}"; diff --git a/site/src/lib/motion.ts b/site/src/lib/motion.ts index cf98a85b..63000afd 100644 --- a/site/src/lib/motion.ts +++ b/site/src/lib/motion.ts @@ -15,7 +15,7 @@ ScrollTrigger.config({ ignoreMobileResize: true }); export const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches; /** Phones and tablets: native scroll only. Mirrors the `touch` class set by the inline script in index.astro. */ export const touch = () => - matchMedia("(pointer: coarse)").matches || matchMedia("(max-width: 900px)").matches; + matchMedia("(pointer: coarse)").matches || matchMedia("(max-width: 899.98px)").matches; export { gsap, ScrollTrigger }; let lenis: Lenis | undefined; diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 6ef6aaf8..ed27be1a 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -94,7 +94,7 @@ const jsonLd = graph( (function () { var d = document.documentElement; if (matchMedia("(prefers-reduced-motion: reduce)").matches) d.classList.add("calm"); - if (matchMedia("(pointer: coarse)").matches || matchMedia("(max-width: 900px)").matches) d.classList.add("touch"); + if (matchMedia("(pointer: coarse)").matches || matchMedia("(max-width: 899.98px)").matches) d.classList.add("touch"); })(); From 50d0c78ef38504377be82c90f7c96bc430bc744a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 21:30:47 -0300 Subject: [PATCH 3/3] chore: regenerate lockfile and manifest after rebase --- .agents/skills/codeatlas-ui/API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/codeatlas-ui/API.md b/.agents/skills/codeatlas-ui/API.md index d53fe9ca..feed74d8 100644 --- a/.agents/skills/codeatlas-ui/API.md +++ b/.agents/skills/codeatlas-ui/API.md @@ -4878,7 +4878,7 @@ Types: | `Align` | `type Align = (typeof ALIGN)[number];` | | | `AvatarTokens` | `interface AvatarTokens extends AvatarFallbackTokens { size?: Partial< Record< "xs" \| "sm" \| "md" \| "lg" \| "xl", Pick > >; }` | | | `BrandColors` | `interface BrandColors { main: string; light: string; dark: string; contrast: string; }` | | -| `Breakpoint` | `type Breakpoint = "xs" \| "sm" \| "md" \| "lg" \| "xl";` | | +| `Breakpoint` | `type Breakpoint = "xs" \| keyof typeof SCALE;` | | | `BuiltinPresetId` | `type BuiltinPresetId = \| "amber" \| "ocean" \| "crimson" \| "cyan" \| "violet" \| "emerald" \| "rose" \| "slate";` | Presets shipped in global.css. | | `BuiltinPresetSource` | `interface BuiltinPresetSource { id: "amber" \| "ocean" \| "crimson" \| "cyan" \| "violet" \| "emerald" \| "rose" \| "slate"; label: string; color: `#${string}`; hue: number; chroma: number; lightL: number; overrides: { light: TokenMap; dark: TokenMap }; }` | | | `ButtonSizeTokens` | `interface ButtonSizeTokens { height?: CssValue; paddingX?: CssValue; fontSize?: CssValue; weight?: CssValue; tracking?: CssValue; }` | Typography and box tokens of one Button size. |