Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/codeatlas-ui/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AvatarFallbackTokens, "fallbackSize" \| "fallbackWeight"> > >; }` | |
| `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. |
Expand Down
37 changes: 30 additions & 7 deletions packages/ui/scripts/lint-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -104,6 +119,15 @@ export function lintSource(file: string, source: string): Violation[] {
'backdrop-filter belongs to styles/surface.css: set data-ca-blur="<role>" 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(/(?<![\w.-])100(?:vh|dvh|svh|lvh)\b/g))
Expand All @@ -118,14 +142,13 @@ export function lintSource(file: string, source: string): Violation[] {
for (const m of r.text.matchAll(
/(?<=@media[^{;]*)\((min|max)-width:\s*([\d.]+)(px|rem|em)\)/g
)) {
const px = Number(m[2]) * (m[3] === "px" ? 1 : 16);
const ok = BREAKPOINTS.some((b) => (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,
});
}

Expand Down Expand Up @@ -189,7 +212,7 @@ function* walk(dir: string): Generator<string> {
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;
}
}

Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/lib/stores/responsive.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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 });
});
});
13 changes: 5 additions & 8 deletions packages/ui/src/lib/stores/responsive.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<Breakpoint, number> = {
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<Breakpoint, number> = { xs: 0, ...SCALE };

function createResponsiveStore() {
// undefined on the server and on the first client render (no hydration mismatch);
Expand Down
20 changes: 19 additions & 1 deletion packages/ui/tests/lint-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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 <style> block and in a matchMedia string", () => {
const astro = "<div/>\n<style>\n@media (max-width: 900px) { .a { gap: 0 } }\n</style>";
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 = '<script is:inline>if (matchMedia("(max-width: 900px)").matches) go();</script>';
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) {}";
Expand Down
2 changes: 1 addition & 1 deletion site/src/lib/motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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");
})();
</script>
</Fragment>
Expand Down