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
5 changes: 5 additions & 0 deletions .changeset/filtergroup-label-contrast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": patch
---

`FilterGroup` draws its label with `--ca-text-secondary` instead of `--ca-text-hint`. The hint role can be set low by a custom preset, and the label failed the 4.5:1 axe check on a raised surface in dark mode.
5 changes: 5 additions & 0 deletions .changeset/kpicard-on-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": patch
---

`KpiCard` is built on `Card` instead of the deprecated `GlassCard`, so rendering it no longer logs the `[ca-ui]` deprecation warning in dev. The look is unchanged (elevation 3).
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ site/src/generated/

# wrangler local state
.wrangler/

# docs sample type-check output
/site/.cache/
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"!**/.turbo",
"!**/.svelte-kit",
"!**/.astro",
"!**/.cache",
"!**/__screenshots__",
"!**/.vitest",
"!**/test-results",
Expand Down
184 changes: 184 additions & 0 deletions packages/ui/scripts/check-docs-samples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Type-checks the `svelte` code fences of the docs site with svelte-check, against the built package
* types (`dist`), exactly as a consumer app would compile them.
*
* A fence is checked when it imports from `@codeatlasdev/ui*`: those are the ones that claim API
* usage. Docs samples are often fragments, so the extractor makes them compilable without hiding
* real mistakes:
* - `…` and `/* … *\/` placeholders are removed;
* - names the sample uses but never declares (`users`, `save`, `page`, a `bind:` target) become
* ambient `any` declarations: a docs sample may assume app state;
* - a bare markup fence (no `<script>`) gets an import-free wrapper only when it names no package
* component, so it is skipped.
* Unknown components and exports, props a component does not declare, wrong prop types, unclosed
* blocks and TypeScript syntax in a non-`lang="ts"` script all fail.
*
* bun scripts/check-docs-samples.ts
*/
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const DOCS = join(REPO, "site/src/content/docs");
const OUT = join(REPO, "site/.cache/docs-samples");
const SVELTE_CHECK = join(REPO, "packages/ui/node_modules/.bin/svelte-check");

export interface Sample {
/** Docs page, relative to the docs root. */
page: string;
/** 0-based position among the page's checked fences. */
index: number;
/** 1-based line of the opening fence in the MDX file. */
line: number;
file: string;
}

const walk = (dir: string): string[] =>
readdirSync(dir).flatMap((f) => {
const p = join(dir, f);
return statSync(p).isDirectory() ? walk(p) : p.endsWith(".mdx") ? [p] : [];
});

/** Free identifiers the sample reads but does not declare, found cheaply from the markup and script. */
function ambientNames(code: string): string[] {
const declared = new Set<string>();
for (const m of code.matchAll(
/\b(?:const|let|var|function|class|import|type|interface)\s+(?:\{([^}]*)\}|(\w+))/g
)) {
for (const n of (m[1] ?? m[2] ?? "").split(",")) {
const name = n
.trim()
.split(/\s+as\s+/)
.pop()
?.replace(/^type\s+/, "");
if (name) declared.add(name);
}
}
for (const m of code.matchAll(/\{#snippet\s+(\w+)/g)) declared.add(m[1]);
const used = new Set<string>();
// `bind:value={x}`, `onclick={x}`, `{x}`, `{@render x()}`, `data={x}`
for (const m of code.matchAll(
/(?:bind:\w+|\bon\w+|\b[a-z][\w-]*)=\{\s*([A-Za-z_$][\w$]*)\s*[}(.[]/g
))
used.add(m[1]);
for (const m of code.matchAll(/(?<![\w$.])\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) used.add(m[1]);
for (const m of code.matchAll(/\bbind:(\w+)(?![=\w])/g)) used.add(m[1]);
for (const m of code.matchAll(/<[A-Z]\w*\b[^>]*?\{(?:\.\.\.)?\s*([A-Za-z_$][\w$]*)\s*\}/g))
used.add(m[1]);
const skip = new Set(["children", "true", "false", "null", "undefined", "$props", "$state"]);
return [...used].filter((n) => !declared.has(n) && !skip.has(n) && !n.startsWith("$"));
}

/** Turns a docs fence into a compilable component, or `null` when it is not a checkable sample. */
export function normalize(raw: string): string | null {
if (!/from\s+["']@codeatlasdev\/ui/.test(raw)) return null;
let code = raw
.replace(/\/\*\s*…\s*\*\//g, "")
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/…/g, "");
// A fence that mixes several top-level scripts shows separate files; check only the first part.
const scripts = [...code.matchAll(/<script\b[^>]*>[\s\S]*?<\/script>/g)];
if (scripts.length > 1) {
const second = scripts[1].index ?? code.length;
code = code.slice(0, second);
}
// Placeholders for app state the sample assumes. `$state()` makes them valid `bind:` targets. In a
// `lang="ts"` script they are typed `any` so they fit any prop; a plain script gets no annotation,
// so TypeScript syntax written in it is still reported.
const names = ambientNames(code);
const tag = code.match(/<script\b[^>]*>/);
const isTs = !tag || /\blang=["']ts["']/.test(tag[0]);
const decl = names.map((n) => `let ${n}${isTs ? ": any" : ""} = $state();`).join("\n");
if (!tag) return `<script lang="ts">\n${decl}\n</script>\n${code}`;
return decl ? code.replace(tag[0], `${tag[0]}\n${decl}`) : code;
}

export function extractSamples(): Sample[] {
rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT, { recursive: true });
const samples: Sample[] = [];
for (const mdx of walk(DOCS)) {
const src = readFileSync(mdx, "utf8");
const page = relative(DOCS, mdx);
let index = 0;
for (const m of src.matchAll(/^([ \t]*)```svelte[^\n]*\n([\s\S]*?)^[ \t]*```/gm)) {
const indent = m[1].length;
const body = m[2]
.split("\n")
.map((l) => l.slice(Math.min(indent, l.length - l.trimStart().length)))
.join("\n");
const code = normalize(body);
if (code === null) continue;
const line = src.slice(0, m.index).split("\n").length;
const file = `${page.replace(/\.mdx$/, "").replace(/[/.]/g, "_")}_${index}.svelte`;
writeFileSync(join(OUT, file), code);
samples.push({ page, index: index++, line, file });
}
}
writeFileSync(
join(OUT, "tsconfig.json"),
JSON.stringify({ extends: "../../tsconfig.json", include: ["./*.svelte"] })
);
return samples;
}

export interface SampleError {
page: string;
line: number;
message: string;
}

/** Runs svelte-check over the extracted samples; returns one entry per error, mapped back to its docs page. */
export async function checkDocsSamples(
extra: Record<string, string> = {}
): Promise<{ checked: number; errors: SampleError[] }> {
const samples = extractSamples();
// Extra fences (tests feed known-bad ones to prove the check can fail), reported under their own name.
for (const [name, body] of Object.entries(extra)) {
const code = normalize(body);
if (code === null) continue;
const file = `extra_${name}.svelte`;
writeFileSync(join(OUT, file), code);
samples.push({ page: name, index: 0, line: 0, file });
}
const proc = Bun.spawn(
[
SVELTE_CHECK,
"--tsconfig",
join(OUT, "tsconfig.json"),
"--workspace",
OUT,
"--output",
"machine",
],
{ cwd: join(REPO, "site"), stdout: "pipe", stderr: "pipe" }
);
const [out, err] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
if (!/COMPLETED/.test(out)) throw new Error(`svelte-check did not complete:\n${out}${err}`);
const byFile = new Map(samples.map((s) => [s.file, s]));
const errors: SampleError[] = [];
for (const m of out.matchAll(/^\d+ ERROR "([^"]+)" (\d+):\d+ "((?:[^"\\]|\\.)*)"/gm)) {
const sample = byFile.get(m[1]);
errors.push({
page: sample?.page ?? m[1],
line: (sample?.line ?? 0) + Number(m[2]),
message: (JSON.parse(`"${m[3]}"`) as string)
.replace(/\n/g, " ")
.split("https://svelte.dev")[0]
.trim(),
});
}
return { checked: samples.length, errors };
}

if (import.meta.main) {
const { checked, errors } = await checkDocsSamples();
for (const e of errors) console.log(`${e.page}:${e.line} ${e.message}`);
console.log(`${checked} samples checked, ${errors.length} errors`);
process.exit(errors.length ? 1 : 0);
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
.ca-filter-group__label {
font-size: var(--ca-font-size-micro);
font-weight: var(--ca-font-weight-semibold);
color: var(--ca-text-hint);
color: var(--ca-text-secondary);
text-transform: uppercase;
letter-spacing: var(--ca-overline-tracking);
padding: 0 var(--ca-space-1-5) 0 var(--ca-space-1);
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/lib/components/KpiCard/KpiCard.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { useT } from '../../i18n/state.svelte.js';
import type { IconComponent } from '../../types.js';
import GlassCard from '../GlassCard/GlassCard.svelte';
import Card from '../Card/Card.svelte';
const t = useT();

interface Props {
Expand All @@ -23,7 +23,7 @@
);
</script>

<GlassCard class="ca-kpi {className}">
<Card elevation={3} class="ca-kpi {className}">
<div class="ca-kpi__inner">
<div class="ca-kpi__top">
<p class="ca-kpi__title">{title}</p>
Expand All @@ -50,7 +50,7 @@
</span>
{/if}
</div>
</GlassCard>
</Card>

<style>
@layer ca.reset, ca.tokens, ca.themes, ca.base, ca.materials, ca.utilities, ca.components;
Expand Down
16 changes: 15 additions & 1 deletion packages/ui/src/lib/components/KpiCard/KpiCard.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TrendingUp } from "@lucide/svelte";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../../tests/a11y";
import { q } from "../../../../tests/dom";
Expand Down Expand Up @@ -64,4 +64,18 @@ describe("KpiCard", () => {
expect(icon.querySelector("svg")).not.toBeNull();
expect(await checkA11y(screen.container)).toHaveNoViolations();
});

it("is built on Card, so it never triggers a deprecation warning", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const screen = await render(KpiCard, { title: "Revenue", value: 1 });
expect(screen.container.querySelector(".ca-glass-card")).toBeNull();
expect(screen.container.querySelector(".ca-card.ca-kpi")).not.toBeNull();
expect(
warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes("deprecated"))
).toEqual([]);
} finally {
warn.mockRestore();
}
});
});
2 changes: 1 addition & 1 deletion packages/ui/src/lib/components/Sheet/Sheet.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
/**
* Sheet: mobile-first bottom sheet.
*
* Below 768px it slides up from the bottom edge (drag handle, safe-area padding, rounded top);
* Below 900px it slides up from the bottom edge (drag handle, safe-area padding, rounded top);
* from 900px up it renders as a centered dialog. A native modal <dialog> supplies the top layer, inert
* page, focus restore, Escape, backdrop dismissal and scroll lock. Motion is off under reduced-motion.
*
Expand Down
26 changes: 26 additions & 0 deletions packages/ui/tests/contrast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,29 @@ describe("--ca-state-indicator: selected markers (#24)", () => {
}
});
});

// FilterGroup draws its caption on a `--ca-action-hover` pill that sits on any surface. The caption
// is readable content, and `--ca-text-hint` is the role for placeholders and disabled-adjacent hints
// that an app may set low (a custom preset gave it 4.1:1 on the raised surface and failed axe), so the
// caption must use the role that holds AA in every preset: `--ca-text-secondary`.
describe("FilterGroup label on its pill", () => {
const css = readFileSync(new URL("components/FilterGroup/FilterGroup.svelte", LIB), "utf8");
const token = css.match(/\.ca-filter-group__label\s*\{[^}]*?color:\s*var\((--ca-[\w-]+)\)/)?.[1];

it("is drawn with a text role that is not the hint role", () => {
expect(token).toBe("--ca-text-secondary");
});

for (const src of sources)
for (const scheme of ["light", "dark"] as const)
it(`${src.id} ${scheme}: its role is >= 4.5:1 on the pill over every surface`, () => {
const t = resolved(src, scheme);
const fill = resolve(t, "--ca-action-hover");
for (const s of SURFACES) {
const pill = over(fill, surfaceRgb(t, s));
expect(ratio(over(resolve(t, token as string), pill), pill), s).toBeGreaterThanOrEqual(
4.5
);
}
});
});
Loading
Loading