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
8 changes: 4 additions & 4 deletions .agents/skills/codeatlas-ui/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -3051,7 +3051,7 @@ function formatRelativeTime(value: Date | number | string, locale?: string): str

`import { getLocales } from "@codeatlasdev/ui"`

Registered locale ids.
Registered locale ids (built-ins first).

```ts
function getLocales(): Locale[]
Expand All @@ -3061,7 +3061,7 @@ function getLocales(): Locale[]

`import { getMessages } from "@codeatlasdev/ui"`

Messages for a locale, with optional deep overrides. Pure: no global state read or written, so it is safe on the server. Region subtags fall back to the base language, then to English.
Messages for a locale, with optional deep overrides. Pure, so safe on the server.

```ts
function getMessages(id: Locale, override?: MessagesOverride): Messages
Expand Down Expand Up @@ -3534,7 +3534,7 @@ function useLocale(): () => Locale

`import { useT } from "@codeatlasdev/ui"`

Message accessor for the component being initialised. Call it at the top level of `<script>` (it reads Svelte context), then use `t().group.key` anywhere, including `$derived` and markup.
Full-catalog accessor for the component being initialised (provider first, client-global otherwise). Prefer `useMessages(group, …)` inside this package: it ships only what it reads.

```ts
function useT(): () => Messages
Expand Down Expand Up @@ -3618,7 +3618,7 @@ Types:
| `LayerKind` | `type LayerKind = "modal" \| "popover" \| "hint";` | The single layer stack. Every overlay (dialog, sheet, menu, popover, tooltip) goes through `openLayer`, so there is one place for the top layer, Escape / outside press / focus-out, focus restoration, scroll lock and the `layerchange` event hosts listen to. |
| `LayoutProps` | `interface LayoutProps { gap?: Responsive<Space>; p?: Responsive<Space>; px?: Responsive<Space>; py?: Responsive<Space>; align?: Responsive<Align>; justify?: Responsive<Justify>; direction?: Responsive<Direction>; columns?: Responsive<Columns>; wrap?: Responsive<boolean>; }` | Props shared by every layout primitive. Each one also takes a responsive object. |
| `Locale` | `type Locale = string;` | BCP 47 language tag. `en` and `pt-BR` are built in; add others with `registerLocale`. |
| `Messages` | `type Messages = Widen<typeof en>;` | Full message shape (string literals widened so other locales can translate). |
| `Messages` | `type Messages = { -readonly [K in keyof typeof groups]: GroupMessages<(typeof groups)[K]>; };` | Full message shape: every group's messages, keyed by group id. |
| `MessagesOverride` | `type MessagesOverride = DeepPartial<Messages>;` | Partial, deep override accepted by `setLocale`, `registerLocale` and `I18nProvider`. |
| `MotionCtx` | `interface MotionCtx { playing: boolean; reduced: boolean; takeover(): void; play(): void; pause(): void; cycle: number; }` | What a motion demo receives from `MotionStage`. Demos render from this and never run their own timers. |
| `MotionOptions` | `interface MotionOptions { scale?: number; easing?: "standard" \| "emphasized" \| "spring" \| "linear" \| "steps"; enter?: "default" \| "none"; }` | |
Expand Down
5 changes: 5 additions & 0 deletions .changeset/i18n-per-group-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": minor
---

i18n: the message catalog is split per group and each component bundles only its own strings. `root: Button only` drops from 27.89 to 23.91 kB gzip and `AppShellLayout` from 39.88 to 36.07 kB. `Messages`, `getMessages`, `useT`, `I18nProvider`, `setLocale` and `registerLocale` behave as before; `useT()` and `getMessages()` still return the full catalog, which is bundled only when you import them.
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -728,8 +728,9 @@ setLocale("es");
- The client `$state` behind `setLocale` is module-level: all islands on the same page share the language and re-render on change. `locale.value` reads it; inside a provider use `useLocale()`.
- **An explicit prop always wins** over the catalog: `<TableOfContents label="In this article" />`. Text props (`label`, `placeholder`, `confirmLabel`, `emptyText`, `ofLabel`…) no longer have a default; without the prop, the component uses the catalog.
- **Override phrases** without creating a locale: `<I18nProvider locale="pt-BR" messages={{ docs: { toc: "In this article" } }}>` or `setLocale("pt-BR", { … })`. Deep-merged over the catalog; a new `setLocale` call replaces the previous overrides.
- Catalog: `packages/ui/src/lib/i18n/en.ts` (source of truth, grouped by component, e.g. `docs.search.placeholder`) and `pt-BR.ts` (typed as `Messages`: a missing key is a type error). Strings with a value are functions: `notificationBell.label(unread)`. Registry and `getMessages(locale, override?)` live in the rune-free `catalog.ts`.
- New text in a component: add the key to `en.ts` **and** `pt-BR.ts`, call `const t = useT();` at the top level of `<script>` (or of a `.svelte.ts` helper called at component init; `useT` reads Svelte context, so it cannot run in event handlers or module scope), read `t().group.key` in markup/`$derived`, and use `label ?? t().group.key` when there is a prop. Never import a global `t`.
- Catalog: one module per group in `packages/ui/src/lib/i18n/groups/<name>.ts` (e.g. `docs`, `dialog`), each a `defineGroup("docs", { en: {…}, "pt-BR": {…} })`. `en` is the source of truth; `pt-BR` is typed against it (a missing key is a type error). Strings with a value are functions: `notificationBell.label(unread)`. Groups are registered in `all.ts` (the `Messages` type, `getMessages`, public `useT()`); the rune-free `catalog.ts` holds `defineGroup`, `getGroup(group, locale, override?)` and the `registerLocale` registry.
- **A component pays only for its own strings.** The built-in locales travel inside each group and a component imports only the groups it reads, so tree-shaking drops the rest. Never import `all.ts`, `getMessages` or the public `useT()` inside the package: they name every group and ship the whole catalog (`bun run size` fails if Button's bundle contains another component's text).
- New text in a component: add the key to the group's `en` **and** `pt-BR` (new group: new file in `groups/` plus one line in `all.ts`), then in `<script>` `import { group } from "../../i18n/groups/<name>.js"` and call `const t = useMessages(group);` at the top level (or in a `.svelte.ts` helper called at component init; `useMessages` reads Svelte context, so it cannot run in event handlers or module scope). Read `t().group.key` in markup/`$derived`, and use `label ?? t().group.key` when there is a prop. Several groups: `useMessages(a, b)`. Never import a global `t`.
- In browser tests, call `setLocale("pt-BR")` where the test checks Portuguese text and `setLocale("en")` in `afterEach`; the default is English. SSR tests (`*.ssr.test.ts`, `bun run test:ssr`) wrap the tree in `I18nProvider`.
- The date/number `locale` (`DatePicker locale="pt-BR"`, masks) is independent of the UI language.

Expand Down
104 changes: 104 additions & 0 deletions packages/ui/scripts/size-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Shared by size-limit.ts and the bundle-content tests: bundle an entry of dist/ the way a consumer's
// bundler would (esbuild, ESM, minified, tree-shaken, Svelte runtime included).
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { gzipSync } from "node:zlib";
import { build, type Plugin } from "esbuild";
import { compile, compileModule } from "svelte/compiler";

export const dist = join(import.meta.dir, "..", "dist");

export interface Entry {
name: string;
/** JS entry contents (resolved from dist) or a CSS file path under dist. */
js?: string;
css?: string;
/** Skipped when this path under dist does not exist (an entry that has not landed yet). */
requires?: string;
}

/** Compiles .svelte and .svelte.js/.ts files from dist the way vite-plugin-svelte would. */
export const sveltePlugin: Plugin = {
name: "svelte",
setup(b) {
b.onLoad({ filter: /\.svelte$/ }, ({ path }) => {
const r = compile(readFileSync(path, "utf8"), {
filename: path,
generate: "client",
css: "external",
});
return { contents: r.js.code, loader: "js", resolveDir: dirname(path) };
});
b.onLoad({ filter: /\.svelte\.(js|ts)$/ }, ({ path }) => {
const r = compileModule(readFileSync(path, "utf8"), { filename: path, generate: "client" });
return { contents: r.js.code, loader: "js", resolveDir: dirname(path) };
});
},
};

/** Gzip size, plus the package modules that ended up in the bundle (for the tree-shaking guard). */
export async function measure(
e: Entry
): Promise<{ gzip: number; modules: string[]; code: string }> {
const res = await build({
metafile: true,
stdin: e.css
? { contents: `@import "./${e.css}";`, resolveDir: dist, loader: "css" }
: { contents: e.js ?? "", resolveDir: dist, loader: "js" },
bundle: true,
minify: true,
format: "esm",
write: false,
outdir: "out",
treeShaking: true,
conditions: ["svelte", "browser", "import", "default"],
mainFields: ["svelte", "browser", "module", "main"],
plugins: [sveltePlugin],
logLevel: "error",
// Remote/relative assets (fonts) are not part of the byte budget.
external: e.css ? ["*.woff2"] : [],
// esm-env picks its production build from the "development" condition being absent.
});
// A CSS bundle yields one .css file; JS bundles yield one .js (esbuild extracts no css for stdin js).
const out = res.outputFiles.find((f) => f.path.endsWith(e.css ? ".css" : ".js"));
if (!out) throw new Error(`no output for ${e.name}`);
const meta =
res.metafile.outputs[
Object.keys(res.metafile.outputs).find((k) => k.endsWith(e.css ? ".css" : ".js")) ?? ""
];
const modules = Object.keys(meta?.inputs ?? {}).filter((k) => k.startsWith("dist/"));
return { gzip: gzipSync(out.contents, { level: 9 }).length, modules, code: out.text };
}

/**
* Strings of groups that a Button-only bundle must not contain (#325): each component pays only for
* its own i18n group. One sample per group, English and pt-BR where the text differs.
*/
export const FOREIGN_STRINGS = [
// commandPalette
"Command palette",
"Paleta de comandos",
"Search failed",
// combobox
"Selected values",
"Valores selecionados",
// appShell
"Module navigation",
"Navegação de módulos",
"Resize navigation panel",
// motionStage
"Replay",
// docs / copyButton
"On this page",
"Could not copy",
];

/** esbuild escapes non-ASCII (`…`, `ã`) in minified output; compare decoded text. */
const decode = (code: string) =>
code.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(Number.parseInt(h, 16)));

/** The sampled strings of other components' groups found in a bundle. */
export function foreignStrings(code: string): string[] {
const text = decode(code);
return FOREIGN_STRINGS.filter((s) => text.includes(s));
}
80 changes: 13 additions & 67 deletions packages/ui/scripts/size-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,16 @@
// with the Svelte runtime and @floating-ui included (that is what ships). The metric is gzip bytes.
// The ratchet only moves down: --update never raises a limit, raise one by hand in a PR that explains why.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { gzipSync } from "node:zlib";
import { build, type Plugin } from "esbuild";
import { compile, compileModule } from "svelte/compiler";
import { join } from "node:path";
import { dist, type Entry, foreignStrings, measure } from "./size-lib.js";

const root = join(import.meta.dir, "..");
const dist = join(root, "dist");
const limitsFile = join(root, "size-limit.json");
const HEADROOM = 1.05;

interface Entry {
name: string;
/** JS entry contents (resolved from dist) or a CSS file path under dist. */
js?: string;
css?: string;
/** Skipped when this path under dist does not exist (an entry that has not landed yet). */
requires?: string;
}

// Divider has no package dependencies, so "Divider only" is the Svelte client runtime plus ~250 B
// of ours: the floor any Svelte component costs. "Button only" minus that floor is what the
// package adds (Spinner, the i18n catalogs, clsx).
// package adds (Spinner, its own i18n groups, clsx).
const ENTRIES: Entry[] = [
{
name: "floor: Divider only",
Expand All @@ -41,57 +29,6 @@ const ENTRIES: Entry[] = [
{ name: "styles/global.css", css: "styles/global.css" },
];

/** Compiles .svelte and .svelte.js/.ts files from dist the way vite-plugin-svelte would. */
const sveltePlugin: Plugin = {
name: "svelte",
setup(b) {
b.onLoad({ filter: /\.svelte$/ }, ({ path }) => {
const r = compile(readFileSync(path, "utf8"), {
filename: path,
generate: "client",
css: "external",
});
return { contents: r.js.code, loader: "js", resolveDir: dirname(path) };
});
b.onLoad({ filter: /\.svelte\.(js|ts)$/ }, ({ path }) => {
const r = compileModule(readFileSync(path, "utf8"), { filename: path, generate: "client" });
return { contents: r.js.code, loader: "js", resolveDir: dirname(path) };
});
},
};

/** Gzip size, plus the package modules that ended up in the bundle (for the tree-shaking guard). */
async function measure(e: Entry): Promise<{ gzip: number; modules: string[] }> {
const res = await build({
metafile: true,
stdin: e.css
? { contents: `@import "./${e.css}";`, resolveDir: dist, loader: "css" }
: { contents: e.js ?? "", resolveDir: dist, loader: "js" },
bundle: true,
minify: true,
format: "esm",
write: false,
outdir: "out",
treeShaking: true,
conditions: ["svelte", "browser", "import", "default"],
mainFields: ["svelte", "browser", "module", "main"],
plugins: [sveltePlugin],
logLevel: "error",
// Remote/relative assets (fonts) are not part of the byte budget.
external: e.css ? ["*.woff2"] : [],
// esm-env picks its production build from the "development" condition being absent.
});
// A CSS bundle yields one .css file; JS bundles yield one .js (esbuild extracts no css for stdin js).
const out = res.outputFiles.find((f) => f.path.endsWith(e.css ? ".css" : ".js"));
if (!out) throw new Error(`no output for ${e.name}`);
const meta =
res.metafile.outputs[
Object.keys(res.metafile.outputs).find((k) => k.endsWith(e.css ? ".css" : ".js")) ?? ""
];
const modules = Object.keys(meta?.inputs ?? {}).filter((k) => k.startsWith("dist/"));
return { gzip: gzipSync(out.contents, { level: 9 }).length, modules };
}

/** Importing `Button` must not drag in other components, stores or the theme engine. */
// announce: Button announces when `loading` ends (screen readers).
const BUTTON_ALLOWED =
Expand Down Expand Up @@ -119,7 +56,7 @@ for (const e of ENTRIES) {
console.log(`skip ${e.name} (not built)`);
continue;
}
const { gzip: size, modules } = await measure(e);
const { gzip: size, modules, code } = await measure(e);
if (e.name === "root: Button only") {
const leaked = modules.filter((m) => !BUTTON_ALLOWED.test(m));
if (leaked.length) {
Expand All @@ -128,6 +65,15 @@ for (const e of ENTRIES) {
);
failed++;
}
// i18n/ is allowed above, so a stray group import is only visible in the text: Button must
// carry its own strings (and Spinner's), not those of other components.
const foreign = foreignStrings(code);
if (foreign.length) {
console.error(
`FAIL ${e.name}: i18n leak, strings of other components' groups in the bundle:\n ${foreign.join("\n ")}\n Import only the groups a component reads (useMessages(group)); see AGENTS.md > Language (i18n).`
);
failed++;
}
}
const limit = limits[e.name];
const target = Math.ceil(size * HEADROOM);
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/size-limit.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"floor: Divider only": 18863,
"root: Button only": 28600,
"root: AppShellLayout": 40900,
"root: Button only": 25709,
"root: AppShellLayout": 38786,
"styles/core.css": 14160,
"styles/global.css": 17100,
"./seo": 32190
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/lib/charts/HeatmapChart.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
<script lang="ts">
import { useT } from '../i18n/state.svelte.js';
import { charts } from '../i18n/groups/charts.js';
import { useMessages } from '../i18n/state.svelte.js';
// ─── HeatmapChart — calendar or matrix heatmap ───────────────────────────────
import type { EChartsOption } from "echarts";
import ChartCanvas from "./ChartCanvas.svelte";
import { withChartTheme } from "./theme.js";
import { chartPalette, withAlpha } from "./palette.js";
import { chartTokens } from "./tokens.js";
import type { CalendarHeatmapPoint, MatrixHeatmapPoint } from "./types.js";
const msg = useT();
const msg = useMessages(charts);

interface Props {
variant: "calendar" | "matrix";
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/lib/components/Alert/Alert.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script lang="ts">
import { useT } from '../../i18n/state.svelte.js';
import { useMessages } from '../../i18n/state.svelte.js';
import { alert } from '../../i18n/groups/alert.js';
import type { Snippet } from 'svelte';
import { CheckCircle2, AlertTriangle, XCircle, Info, X } from '@lucide/svelte';
const t = useT();
const t = useMessages(alert);

interface Props {
severity: 'success' | 'warning' | 'error' | 'info';
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/lib/components/AlertDialog/AlertDialog.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script lang="ts">
import { useT } from '../../i18n/state.svelte.js';
import { useMessages } from '../../i18n/state.svelte.js';
import { alertDialog } from '../../i18n/groups/alertDialog.js';
import type { Snippet } from 'svelte';
import { createIds, createPresence } from '../../primitives/index.js';
import { modalLayer } from '../Dialog/modal-layer.js';
import Button from '../Button/Button.svelte';
import '../Dialog/dialog.css';
const t = useT();
const t = useMessages(alertDialog);

/**
* AlertDialog: confirmation that requires an explicit answer (role="alertdialog").
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/lib/components/AppForm/AppForm.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { useT } from '../../i18n/state.svelte.js';
import { useMessages } from '../../i18n/state.svelte.js';
import { appForm } from '../../i18n/groups/appForm.js';
/**
* AppForm: page-level form layout.
*
Expand Down Expand Up @@ -33,7 +34,7 @@
import Button from '../Button/Button.svelte';
import { provideSectionHeadingLevel } from '../PageShell/heading-context.js';
import { ArrowLeft } from '@lucide/svelte';
const t = useT();
const t = useMessages(appForm);

interface Props {
title?: string;
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/lib/components/AppList/AppList.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { useT } from '../../i18n/state.svelte.js';
import { useMessages } from '../../i18n/state.svelte.js';
import { appList } from '../../i18n/groups/appList.js';
/**
* AppList: fullscreen CRUD/list layout.
*
Expand Down Expand Up @@ -42,7 +43,7 @@
import TextField from '../TextField/TextField.svelte';
import Pagination from '../Pagination/Pagination.svelte';
import { Search } from '@lucide/svelte';
const t = useT();
const t = useMessages(appList);

type ListCtx<T> = UseListStateReturn<T>;

Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/lib/components/AppSettings/AppSettings.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { useT } from '../../i18n/state.svelte.js';
import { useMessages } from '../../i18n/state.svelte.js';
import { appSettings } from '../../i18n/groups/appSettings.js';
/**
* AppSettings: settings/preferences page layout.
*
Expand Down Expand Up @@ -45,7 +46,7 @@
import type { IconComponent } from '../../types.js';
import type { Snippet } from 'svelte';
import { provideSectionHeadingLevel } from '../PageShell/heading-context.js';
const t = useT();
const t = useMessages(appSettings);
// The page <h1> is rendered above; titles inside the page default to <h2>.
provideSectionHeadingLevel();

Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/lib/components/AppShell/BottomNav.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script lang="ts">
import { useT } from '../../i18n/state.svelte.js';
import { useMessages } from '../../i18n/state.svelte.js';
import { appShell } from '../../i18n/groups/appShell.js';
import type { NavModule } from './types.js';
const t = useT();
const t = useMessages(appShell);

interface Props {
modules: NavModule[];
Expand Down
Loading