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/code-snippet-any-language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": patch
---

`CodeSnippet` highlights any language shiki bundles (`markdown`, `yaml`, `diff`, ...), loading it on first use, and falls back to plain text for a name it does not know. Before, a language outside the eight preloaded ones made the highlighter throw inside the promise and the snippet stayed on its unhighlighted fallback for good.
5 changes: 5 additions & 0 deletions .changeset/code-snippet-raw-focusable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": patch
---

`CodeSnippet`: a code block that scrolls sideways is keyboard-reachable. The raw fallback shown before the highlighter loads (and the highlighted block) now use `scrollFocusable`, so a long line no longer leaves a scroll region that only a mouse can reach (axe `scrollable-region-focusable`).
5 changes: 5 additions & 0 deletions .changeset/design-style-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": minor
---

Design style pages (#583): `/styles/<id>/` for `swiss`, `editorial`, `brutalism`, `sketchbook`, `glassmorphism` and `terminal`, each rendered in its own style, with the origin, the traits (type, colour, shape, depth, motion), do and don't, accessibility pitfalls with their sources, before and after on real components, the `designStyle()` recipe and a prompt. The `ca-ui-mcp` server gains `get_style` (a style as markdown, or the digest of all of them) and `llms.txt` links the digest at `/llms/styles.txt`. `designDefaults()` is exported: the stylesheet default of every knob a theme can write, so a scoped preview can reset what an outer theme set.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ Every numbered demo (`site/src/demos/<group>/<page>/<Variant>.svelte`, id in `si

**A11y tests**: test every state (open, error, empty, light, dark) with `await expectNoA11yViolations(container)` from `@codeatlasdev/ui/testing` (needs the optional peer `axe-core`). Inside this repo `packages/ui/tests/a11y.ts` re-exports the same `checkA11y`; never fork the axe options.

13. **Discovering the API**: never guess a prop. Use the `ca-ui-mcp` MCP server (`get_component`, `get_export`, `get_equivalent`, `list_components`, `list_parts`, `get_migration`, `search_tokens`, `list_surfaces`, `get_guide`, `list_concepts`, `get_concept`, and for the design atlas and its verified sources `search_terms`, `get_term`, `get_reference`) or `.agents/skills/codeatlas-ui/API.md`; both cover components and every non-component export (stores, `kit`, `vite`, `astro`, `actions`, `seo`, `forms`, `tokens`: signature + summary) and are generated from the code by `packages/ui/scripts/build-manifest.ts` (runs on `build`). `[ca-ui]` dev warnings (via `devWarn`, `esm-env`) flag an `IconButton` without `aria-label` and a `Dialog` without `title`.
13. **Discovering the API**: never guess a prop. Use the `ca-ui-mcp` MCP server (`get_component`, `get_export`, `get_equivalent`, `list_components`, `list_parts`, `get_migration`, `search_tokens`, `list_surfaces`, `get_guide`, `get_style`, `list_concepts`, `get_concept`, and for the design atlas and its verified sources `search_terms`, `get_term`, `get_reference`) or `.agents/skills/codeatlas-ui/API.md`; both cover components and every non-component export (stores, `kit`, `vite`, `astro`, `actions`, `seo`, `forms`, `tokens`: signature + summary) and are generated from the code by `packages/ui/scripts/build-manifest.ts` (runs on `build`). `[ca-ui]` dev warnings (via `devWarn`, `esm-env`) flag an `IconButton` without `aria-label` and a `Dialog` without `title`.

14. **Agent skills layout** (`npx skills add codeatlasdev/ui`): one folder per skill under `.agents/skills/`, named exactly like the `name:` in its `SKILL.md` (`codeatlas-ui`, `codeatlas-dx`, `codeatlas-writing`). Never put a `SKILL.md` directly in `.agents/skills/` (the CLI then treats the directory as a single skill and lists nothing else), and quote any `description:` that contains `: ` (an unquoted one is a YAML error and the CLI silently skips the skill). `packages/ui/tests/skills.test.ts` enforces this.

Expand Down
26 changes: 25 additions & 1 deletion packages/ui/bin/ca-ui-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* claude mcp add codeatlas-ui -- npx -y -p @codeatlasdev/ui ca-ui-mcp
*
* Tools: get_recipe, list_components, get_component, get_export, get_equivalent, list_parts, search_tokens, list_surfaces, get_guide,
* list_concepts, get_concept (the Concepts pages with their claims, limits and sources), search_terms, get_term, get_reference (the design atlas and its verified sources), search_demos, get_demo, search_ui, list_facets, get_related, report_issue (drafts a sanitized GitHub issue from a library problem; it never posts anything).
* list_concepts, get_concept (the Concepts pages with their claims, limits and sources), search_terms, get_term, get_reference (the design atlas and its verified sources), search_demos, get_demo, get_style (a design style: traits, pitfalls with sources, the designStyle recipe), search_ui, list_facets, get_related, report_issue (drafts a sanitized GitHub issue from a library problem; it never posts anything).
*/
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
Expand All @@ -33,6 +33,7 @@ import {
relatedEntries,
searchIndex,
} from "../scripts/index-search.ts";
import type { StyleFile } from "../scripts/styles-markdown.ts";
import { type Equivalent, findEquivalent } from "../src/cli/equivalents.ts";
import { type MigrationFile, migrationMarkdown } from "../src/cli/migrations.ts";
import { buildReport } from "../src/cli/report.ts";
Expand Down Expand Up @@ -66,6 +67,9 @@ const entries = indexEntries(demos);
const atlas = existsSync(join(DIST, "atlas.json")) ? load<AtlasFile>("atlas.json") : undefined;
const noAtlas = "No design atlas in this build (dist/atlas.json is missing).";
const noDemos = "No demo catalog in this build (dist/demos.json is missing).";
// Like demos: a build from the tarball has no site/ data, and the other tools must keep working.
const styles = existsSync(join(DIST, "styles.json")) ? load<StyleFile>("styles.json") : undefined;
const noStyles = "No design styles in this build (dist/styles.json is missing).";

const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] });
const fail = (s: string) => ({ ...text(s), isError: true });
Expand Down Expand Up @@ -600,6 +604,26 @@ server.registerTool(
}
);

server.registerTool(
"get_style",
{
description:
'A design style of the library as markdown: where it comes from, its traits (type, colour, shape, depth, motion), do and don\'t, accessibility pitfalls with their sources and strength labels, where it fits, the `designStyle("<id>")` ThemeOptions recipe and a prompt. `name` is the style id or title (swiss, editorial, brutalism, sketchbook, glassmorphism, terminal; case-insensitive, forgives spaces and dashes). Without `name`, returns the digest of every style. A recipe is a proposal, not a measurement, and no claim says a style performs better.',
inputSchema: { name: z.string().optional() },
},
({ name }) => {
if (!styles) return fail(noStyles);
if (!name) return text(styles.index);
const n = squash(name);
const ids = Object.keys(styles.styles);
const hit =
ids.find((id) => squash(id) === n || squash(styles.names[id] ?? "") === n) ??
ids.find((id) => squash(id).includes(n) || squash(styles.names[id] ?? "").includes(n));
if (!hit) return fail(`No style "${name}". Styles: ${ids.join(", ")}.`);
return text(styles.styles[hit] ?? "");
}
);

server.registerTool(
"report_issue",
{
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/scripts/ai-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@ export function buildLlmsIndex(i: {
},
],
},
{
heading: "Design styles",
links: [
{
title: "Design styles",
href: `${SITE}/llms/styles.txt`,
description:
'six design-style recipes (Swiss, Editorial, Brutalism, Sketchbook, Glassmorphism, Terminal): traits, do and don\'t, accessibility pitfalls with their sources, the `designStyle("<id>")` recipe and a prompt; one file per style at /llms/styles/<id>.txt, or ask the MCP server (`get_style`). A recipe is a proposal, not a measurement, and no claim says a style performs better',
},
],
},
{
heading: "Machine-readable endpoints",
links: [
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/scripts/build-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* dist/guides.json the docs site pages as markdown (offline `get_guide`)
* dist/atlas.json the design atlas as one markdown text per term + the verified references (offline
* `search_terms`, `get_term`, `get_reference`); only in the monorepo (needs site/)
* dist/styles.json the design-style pages as markdown, one per style (offline `get_style`)
* --skill also rewrites .agents/skills/codeatlas-ui/API.md (components, then non-component exports)
*
* bun scripts/build-manifest.ts [--check]
Expand All @@ -33,6 +34,7 @@ import { buildAtlasFile } from "./atlas-markdown.ts";
import { buildIndexFiles } from "./index-build.ts";
import { checkIdRange, checkIndexFacets, ID_PATTERN, type IndexFacets } from "./index-schema.ts";
import { demoSources, mdxToMarkdown } from "./mdx-to-markdown.ts";
import { buildStyleFile } from "./styles-markdown.ts";

// CJS require keeps svelte2tsx out of Vite's transform pipeline (its sourceMappingURL points at a file it does not ship).
const { svelte2tsx }: typeof import("svelte2tsx") = createRequire(import.meta.url)("svelte2tsx");
Expand Down Expand Up @@ -1686,6 +1688,8 @@ if (import.meta.main) {
});
}
if (existsSync(DEMOS_DIR)) write("atlas.json", buildAtlasFile());
// The design-style pages need site/ (their data lives in site/src/data/styles): not in a tarball build.
if (existsSync(DEMOS_DIR)) write("styles.json", buildStyleFile());
const { demos, files } = catalog.length
? buildDemos(manifest, catalog)
: { demos: [], files: {} };
Expand Down
152 changes: 152 additions & 0 deletions packages/ui/scripts/styles-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// The design-style pages as plain markdown for agents: one file per style (`/llms/styles/<id>.txt`), a digest of all
// of them (`/llms/styles.txt`) and the file the MCP `get_style` tool serves offline (`dist/styles.json`). Pure functions
// over the same data the pages render, so the site endpoints, the MCP build and the tests share one text.
import { STRENGTH_LABEL } from "../../../site/src/data/evidence/references.ts";
import {
recipeKnobs,
recipeSource,
STYLE_LIST,
type StyleDoc,
stylePrompt,
styleReferences,
TRAIT_AXES,
} from "../../../site/src/data/styles/styles.ts";

export const SITE = "https://ui.codeatlas.com.br";

/** Path of the digest of every style. */
export const STYLES_INDEX_PATH = "/llms/styles.txt";

/** Path of a style's markdown twin. */
export const stylePath = (id: string) => `/llms/styles/${id}.txt`;

const AXIS_LABEL = {
type: "Type",
colour: "Colour",
shape: "Shape",
depth: "Depth",
motion: "Motion",
} as const;

/** One style: everything the page shows, with each claim next to its source, strength and scope. */
export function buildStyleMarkdown(s: StyleDoc): string {
return [
`# ${s.name}`,
"",
`${s.tagline} Page: ${SITE}/styles/${s.id}/. Index: ${SITE}${STYLES_INDEX_PATH}. Recipe: \`designStyle("${s.id}")\`. Atlas terms: ${s.atlas.join(", ")}.`,
"",
"## Where it comes from",
"",
...s.origin.flatMap((p) => [p, ""]),
"Our own account, as background. It is not a claim about users.",
"",
"## Traits",
"",
...TRAIT_AXES.map(
(a) =>
`- ${AXIS_LABEL[a]}: ${s.traits[a].summary} Tokens: ${s.traits[a].tokens.map((t) => `\`${t}\``).join(", ")}.`
),
"",
"## Do",
"",
...s.dos.map((d) => `- ${d}`),
"",
"## Do not",
"",
...s.donts.map((d) => `- ${d}`),
"",
"## Accessibility pitfalls",
"",
...s.pitfalls.flatMap((p) => [
`### ${p.title}`,
"",
`${STRENGTH_LABEL[p.claim.strength]}${p.claim.refs.length ? ` (${p.claim.refs.join(", ")})` : ""}: ${p.claim.text}`,
...(p.claim.scope ? ["", `Scope: ${p.claim.scope}`] : []),
"",
`What to do: ${p.fix}`,
"",
]),
"## Where it fits",
"",
`${s.fitsWhen} (our judgement, not a measured result)`,
"",
`Avoid it when: ${s.avoidWhen}`,
"",
"Components that suit it:",
...s.fits.map((f) => `- ${f.component} (${SITE}${f.href}): ${f.why}`),
"",
"## The ThemeOptions recipe",
"",
"```ts",
recipeSource(s.id).trimEnd(),
"```",
"",
"Every knob the recipe sets:",
"",
"```json",
recipeKnobs(s.id),
"```",
"",
"## Prompt for your AI",
"",
"```text",
stylePrompt(s.id),
"```",
"",
"## Limits",
"",
...s.limits.map((l) => `- ${l}`),
"",
`Last reviewed ${s.reviewed}.`,
"",
"## References",
"",
...styleReferences(s).map(
(r) =>
`- [${r.id}] ${r.authors} (${r.year}). ${r.title}. ${r.url} (${STRENGTH_LABEL[r.strength]}). Supports: ${r.supports} Does not support: ${r.doesNotSupport}`
),
"",
`How we use evidence: ${SITE}/llms/concepts/evidence.txt`,
"",
].join("\n");
}

/** The digest: every style with its tagline, where it fits and where not, linking its own file. */
export function buildStylesIndex(styles: readonly StyleDoc[] = STYLE_LIST): string {
return [
"# Design styles",
"",
`> ${styles.length} design-style recipes of @codeatlasdev/ui, each a live page rendered in its own style. A recipe is one call: \`designStyle("<id>")\`. Page: ${SITE}/styles/. Index: ${SITE}/llms.txt`,
"",
"How to read it: a recipe is a proposal built from the library's design knobs, not a measurement of any named visual language. Accessibility claims name their source and strength; none says a style performs better. Method: " +
`${SITE}/llms/concepts/evidence.txt`,
"",
...styles.flatMap((s) => [
`## ${s.name}`,
"",
`${s.tagline}`,
"",
`- Recipe: \`designStyle("${s.id}")\`. File: ${SITE}${stylePath(s.id)}. Page: ${SITE}/styles/${s.id}/.`,
`- Fits: ${s.fitsWhen}`,
`- Avoid when: ${s.avoidWhen}`,
`- Accessibility to check: ${s.pitfalls.map((p) => p.title).join("; ")}.`,
"",
]),
].join("\n");
}

/** What `dist/styles.json` holds: enough for `get_style` to answer with no network. */
export interface StyleFile {
index: string;
styles: Record<string, string>;
/** Display names by id, so `get_style` also resolves "Swiss" or "neobrutalism". */
names: Record<string, string>;
}

export function buildStyleFile(styles: readonly StyleDoc[] = STYLE_LIST): StyleFile {
return {
index: buildStylesIndex(styles),
styles: Object.fromEntries(styles.map((s) => [s.id, buildStyleMarkdown(s)])),
names: Object.fromEntries(styles.map((s) => [s.id, s.name])),
};
}
16 changes: 7 additions & 9 deletions packages/ui/src/lib/components/CodeSnippet/CodeSnippet.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { codeSnippet } from '../../i18n/groups/codeSnippet.js';
import { Copy, Check } from '@lucide/svelte';
import { copyToClipboard } from '../../utils/index.js';
import { getHighlighter } from '../../utils/highlighter.js';
import { highlight } from '../../utils/highlighter.js';
import { scrollFocusable } from '../../actions/scroll-focusable.js';
const t = useMessages(codeSnippet);

interface Props {
Expand All @@ -22,12 +23,9 @@
$effect(() => {
const lang = language;
const src = code;
getHighlighter().then((hl) => {
highlightedHtml = hl.codeToHtml(src, {
lang,
themes: { light: 'github-light-high-contrast', dark: 'github-dark-high-contrast' },
defaultColor: false,
});
highlight(src, lang).then((html) => {
// A newer `code` or `language` may have started while this one loaded: keep the last request's result.
if (src === code && lang === language) highlightedHtml = html;
});
});

Expand Down Expand Up @@ -57,9 +55,9 @@
</button>
</div>
{#if highlightedHtml}
<div class="ca-code-snippet__pre">{@html highlightedHtml}</div>
<div class="ca-code-snippet__pre" use:scrollFocusable>{@html highlightedHtml}</div>
{:else}
<pre class="ca-code-snippet__pre ca-code-snippet__pre--raw"><code>{code}</code></pre>
<pre class="ca-code-snippet__pre ca-code-snippet__pre--raw" use:scrollFocusable><code>{code}</code></pre>
{/if}
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { render } from "vitest-browser-svelte";
import CodeSnippet from "./CodeSnippet.svelte";

// The docs ask for these languages (a prompt is markdown, a recipe is ts, its knobs are json). A language the
// highlighter does not know must not leave the raw fallback up forever.
describe("CodeSnippet languages", () => {
it.each(["markdown", "ts", "json", "yaml", "not-a-language"])(
"%s ends up highlighted",
async (language) => {
const screen = await render(CodeSnippet, { code: "# Title\n\nSome text", language });
await expect
.poll(() => screen.container.querySelector("pre.shiki") !== null, { timeout: 20_000 })
.toBe(true);
expect(screen.container.querySelector(".ca-code-snippet__pre--raw")).toBeNull();
},
60_000
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import { checkA11y } from "../../../../tests/a11y";
import CodeSnippet from "./CodeSnippet.svelte";

// The highlighter never loads: the raw `<pre>` fallback is what the user sees (a slow network, a failed
// chunk). It scrolls sideways on a long line, so it must be keyboard-reachable like any scroll region.
vi.mock("../../utils/highlighter.js", () => ({
highlight: () => new Promise(() => {}),
}));

const LONG = `const configuration = { ${"someVeryLongPropertyName: 'a value that keeps going', ".repeat(8)} };`;

describe("CodeSnippet raw fallback", () => {
it("a raw block that overflows is focusable and passes axe (scrollable-region-focusable)", async () => {
const screen = await render(CodeSnippet, {
code: LONG,
language: "typescript",
class: "narrow",
});
const pre = screen.container.querySelector<HTMLElement>(".ca-code-snippet__pre--raw");
expect(pre).not.toBeNull();
const el = pre as HTMLElement;
el.parentElement?.style.setProperty("max-width", "240px");
await expect.poll(() => el.scrollWidth > el.clientWidth + 1).toBe(true);
await expect.poll(() => el.tabIndex).toBe(0);
expect(await checkA11y(screen.container)).toHaveNoViolations();
});

it("a raw block that fits adds no tab stop", async () => {
const screen = await render(CodeSnippet, { code: "ls -la", language: "bash" });
const pre = screen.container.querySelector<HTMLElement>(".ca-code-snippet__pre--raw");
expect(pre?.hasAttribute("tabindex")).toBe(false);
});
});
19 changes: 19 additions & 0 deletions packages/ui/src/lib/utils/highlighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,22 @@ export function getHighlighter(): Promise<Highlighter> {
}
return _promise;
}

/** Both themes as `--shiki-light` / `--shiki-dark`; CSS picks one with `light-dark()` (no re-highlight on a scheme change). */
const THEMES = { light: "github-light-high-contrast", dark: "github-dark-high-contrast" } as const;

/**
* Highlighted HTML of `code`. A language the singleton did not preload is loaded on demand when shiki bundles
* it (`markdown`, `yaml`, ...); one it does not know falls back to plain text, still inside the highlighted
* block, so the caller never keeps showing its unhighlighted fallback.
*/
export async function highlight(code: string, language: string): Promise<string> {
const hl = await getHighlighter();
let lang = language;
if (!hl.getLoadedLanguages().includes(lang)) {
const { bundledLanguages } = await import("shiki");
if (lang in bundledLanguages) await hl.loadLanguage(lang as keyof typeof bundledLanguages);
else lang = "text";
}
return hl.codeToHtml(code, { lang, themes: THEMES, defaultColor: false });
}
Loading