+ Report content loads here (corporate-skin render from the report engine).
+ This link is unguessable, not indexed, and expires.
+
+
+
+
+
diff --git a/apps/ariada-console/src/routes/subject/+page.svelte b/apps/ariada-console/src/routes/subject/+page.svelte
new file mode 100644
index 00000000..6162e211
--- /dev/null
+++ b/apps/ariada-console/src/routes/subject/+page.svelte
@@ -0,0 +1,39 @@
+
+
+Ariada — live subject
+
+
+
+ Ariada
+ live subject{heal ? ' — healed preview' : ''}
+ open subject ↗
+
+
+ Install the Ariada browser extension to see the remediated before/after
+ overlaid on this page. The extension receives the subject URL and the
+ heal command from here.
+
+ {#if url}
+
+ {:else}
+
No subject URL provided.
+ {/if}
+
+
+
diff --git a/apps/ariada-console/svelte.config.js b/apps/ariada-console/svelte.config.js
new file mode 100644
index 00000000..9559ed5a
--- /dev/null
+++ b/apps/ariada-console/svelte.config.js
@@ -0,0 +1,15 @@
+import adapter from '@sveltejs/adapter-static';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ // Static SPA: one index.html fallback, deployed to Cloudflare Pages
+ // (app.ariada.org). No server runtime; the scan/report/plugin API is a
+ // separate origin wired later.
+ adapter: adapter({ fallback: 'index.html', strict: false }),
+ },
+};
+
+export default config;
diff --git a/apps/ariada-console/tsconfig.json b/apps/ariada-console/tsconfig.json
new file mode 100644
index 00000000..43447105
--- /dev/null
+++ b/apps/ariada-console/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/apps/ariada-console/vite.config.ts b/apps/ariada-console/vite.config.ts
new file mode 100644
index 00000000..3d869f0d
--- /dev/null
+++ b/apps/ariada-console/vite.config.ts
@@ -0,0 +1,9 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ // @ariada-org/admin-svelte ships Svelte source (not a built bundle) and is linked
+ // via file:, so Vite must compile it rather than treat it as external.
+ ssr: { noExternal: ['@ariada-org/admin-svelte'] },
+});
diff --git a/packages/admin-surface/.gitignore b/packages/admin-surface/.gitignore
new file mode 100644
index 00000000..c2658d7d
--- /dev/null
+++ b/packages/admin-surface/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/packages/admin-surface/README.md b/packages/admin-surface/README.md
new file mode 100644
index 00000000..3a280dd9
--- /dev/null
+++ b/packages/admin-surface/README.md
@@ -0,0 +1,59 @@
+# `@ariada-org/admin-surface`
+
+Product-neutral contracts and helpers for KlarAds-based application admin
+surfaces. The package keeps field meaning in data so the same locale selectors,
+colour controls, contextual help, validation, and access metadata can be reused
+by Audiofirst and future Agonist applications.
+
+## What belongs here
+
+- semantic surface and block definitions;
+- grid, metric-column, row-action and dashboard-profile contracts;
+- the declarative chart contract (`AdminChartSpec`) shared by every renderer;
+- mandatory help for defaults, precedence, and observable effects;
+- locale options derived from a product language-support manifest;
+- capability-filtered locale selectors and the explicit `system` exception;
+- strict `RRGGBB` colour wire conversion;
+- framework-neutral validation and a starter template.
+
+Product copy, brand-specific defaults, product capability names, and the actual
+CMS/API authorization policy stay in the consuming application. UI components
+may render this contract, but hiding a field is never an authorization boundary.
+Locale-registry authoring and locale-keyed translation dictionaries are content
+schema editors rather than locale settings; they validate locale keys in their
+own domain and are intentionally outside the selector rule.
+
+## Add a surface
+
+1. Copy `templates/admin-surface.ts.template` into the product adapter.
+2. Build one locale registry with `createLocaleRegistryFromLanguageSupport`.
+3. Describe every section with `summary`, `defaultSemantics`, `precedence`, and
+ `effect`; validation rejects blocks without this help.
+4. Use `kind: 'locale'` for locale/language values and optionally specify a
+ provider capability. Never substitute a free-text input.
+5. Set `allowSystem: true` only for a field whose wire contract explicitly
+ supports `system`. Other locale fields remain registry-only.
+6. Use `kind: 'color', wireFormat: 'RRGGBB'` for colours and preserve uppercase
+ six-digit values on the wire.
+7. Render the definition through the shared KlarAds admin components and enforce
+ the corresponding server capability independently.
+
+Run `pnpm --filter @ariada-org/admin-surface test` and `typecheck` before adding the
+surface to an application build.
+
+## One contract, two renderers
+
+`AdminGridSurface`, `OperatorDashboardProfile` and `AdminChartSpec` are pure
+data — no React, no Svelte, no AG Grid, no chart library. Two render layers read
+the same declarations:
+
+| Renderer | Package | Consumer |
+|---|---|---|
+| React + Ant Design | `@ariada-org/admin-ui` | Projectology (React is load-bearing there) |
+| Svelte 5 | `@ariada-org/admin-svelte` | KlarAds (`klarads-app`, SvelteKit) |
+
+Declare a chart with `defineAdminChartSpec()` the same way a board declares
+columns. `column` / `line` / `funnel` plot rows; `graph` draws a relationship map
+from `nodes` + `edges`. Colours are literal CSS hex (series identity is data);
+anything that looks like a skin — `css`, `class`, `style`, `theme` — fails
+closed, exactly as it does on a dashboard profile.
diff --git a/packages/admin-surface/package.json b/packages/admin-surface/package.json
new file mode 100644
index 00000000..42ac2705
--- /dev/null
+++ b/packages/admin-surface/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@ariada-org/admin-surface",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Product-neutral contracts for validated KlarAds admin surfaces, locale registries, colour fields, and contextual help.",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "src",
+ "templates"
+ ],
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json",
+ "test": "vitest run src",
+ "typecheck": "tsc --noEmit -p tsconfig.json"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "typescript": "^5.8.3",
+ "vitest": "^2.1.9"
+ }
+}
diff --git a/packages/admin-surface/src/chart.test.ts b/packages/admin-surface/src/chart.test.ts
new file mode 100644
index 00000000..a368d79d
--- /dev/null
+++ b/packages/admin-surface/src/chart.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ ADMIN_CHART_DEFAULT_CATEGORY_KEY,
+ ADMIN_CHART_DEFAULT_HEIGHT,
+ ADMIN_CHART_DEFAULT_MAX_CATEGORIES,
+ AdminSurfaceValidationError,
+ defineAdminChartSpec,
+ validateAdminChartSpec,
+ type AdminChartSpec,
+} from './index.js';
+
+const COLUMN: AdminChartSpec = {
+ type: 'column',
+ title: 'Accepted vs blocked by source',
+ categoryKey: 'name',
+ valueKeys: ['accepted', 'blocked'],
+ colors: ['#059669', '#dc2626'],
+ height: 180,
+};
+
+const GRAPH: AdminChartSpec = {
+ type: 'graph',
+ title: 'Relationship map',
+ nodes: [
+ { id: 'set-1', label: 'Комплект 1', group: 'set' },
+ { id: 'item-1', label: 'Item 1', group: 'item' },
+ { id: 'item-2', label: 'Item 2', group: 'item' },
+ ],
+ edges: [
+ { from: 'set-1', to: 'item-1', label: 'contains' },
+ { from: 'set-1', to: 'item-2' },
+ ],
+};
+
+describe('AdminChartSpec contract — plot charts', () => {
+ it('accepts a well-formed column spec', () => {
+ expect(validateAdminChartSpec(COLUMN)).toHaveLength(0);
+ expect(() => defineAdminChartSpec(COLUMN)).not.toThrow();
+ });
+
+ it('accepts line and funnel with the same shape', () => {
+ expect(validateAdminChartSpec({ ...COLUMN, type: 'line' })).toHaveLength(0);
+ expect(validateAdminChartSpec({ ...COLUMN, type: 'funnel', valueKeys: ['raws'] })).toHaveLength(0);
+ });
+
+ it('accepts a spec that omits the optional categoryKey (renderer default applies)', () => {
+ expect(validateAdminChartSpec({ type: 'column', valueKeys: ['accepted'] })).toHaveLength(0);
+ });
+
+ it('freezes the defined spec', () => {
+ const spec = defineAdminChartSpec(COLUMN);
+ expect(Object.isFrozen(spec)).toBe(true);
+ expect(Object.isFrozen(spec.valueKeys)).toBe(true);
+ });
+
+ it('rejects an unknown chart type', () => {
+ expect(validateAdminChartSpec({ ...COLUMN, type: 'sankey' }).some((i) => i.code === 'chart.type.invalid')).toBe(true);
+ expect(() => defineAdminChartSpec({ ...COLUMN, type: 'sankey' })).toThrow(AdminSurfaceValidationError);
+ });
+
+ it('rejects a plot chart with no value keys', () => {
+ expect(validateAdminChartSpec({ type: 'column', categoryKey: 'name' }).some((i) => i.code === 'chart.valueKeys.invalid')).toBe(true);
+ expect(validateAdminChartSpec({ ...COLUMN, valueKeys: [] }).some((i) => i.code === 'chart.valueKeys.invalid')).toBe(true);
+ });
+
+ it('rejects duplicate value keys', () => {
+ const bad = { ...COLUMN, valueKeys: ['accepted', 'accepted'] };
+ expect(validateAdminChartSpec(bad).some((i) => i.code === 'chart.valueKey.duplicate')).toBe(true);
+ });
+
+ it('rejects graph data on a plot chart', () => {
+ const bad = { ...COLUMN, nodes: [{ id: 'a' }] };
+ expect(validateAdminChartSpec(bad).some((i) => i.code === 'chart.graph.forbidden')).toBe(true);
+ });
+
+ it('rejects a non-literal colour (a skin, not data)', () => {
+ for (const color of ['url(#g)', 'var(--brand)', 'red', 'linear-gradient(red, blue)', '#12345']) {
+ expect(validateAdminChartSpec({ ...COLUMN, colors: [color] }).some((i) => i.code === 'chart.color.invalid')).toBe(true);
+ }
+ expect(validateAdminChartSpec({ ...COLUMN, colors: ['#fff', '#0d9488', '#0d948880'] })).toHaveLength(0);
+ });
+
+ it('rejects out-of-range maxCategories and height', () => {
+ expect(validateAdminChartSpec({ ...COLUMN, maxCategories: 0 }).some((i) => i.code === 'chart.maxCategories.invalid')).toBe(true);
+ expect(validateAdminChartSpec({ ...COLUMN, maxCategories: 12.5 }).some((i) => i.code === 'chart.maxCategories.invalid')).toBe(true);
+ expect(validateAdminChartSpec({ ...COLUMN, height: -1 }).some((i) => i.code === 'chart.height.invalid')).toBe(true);
+ });
+
+ it('HARD INVARIANT: fails closed on any visual-skin key', () => {
+ for (const key of ['css', 'className', 'style', 'skin', 'stylesheet', 'theme']) {
+ const bad = { ...COLUMN, [key]: 'anything' };
+ expect(validateAdminChartSpec(bad).some((i) => i.code === 'chart.visual.forbidden')).toBe(true);
+ expect(() => defineAdminChartSpec(bad)).toThrow(AdminSurfaceValidationError);
+ }
+ });
+});
+
+describe('AdminChartSpec contract — graph (relationship map)', () => {
+ it('accepts a well-formed graph spec', () => {
+ expect(validateAdminChartSpec(GRAPH)).toHaveLength(0);
+ expect(() => defineAdminChartSpec(GRAPH)).not.toThrow();
+ });
+
+ it('rejects a graph with no nodes', () => {
+ expect(validateAdminChartSpec({ type: 'graph', nodes: [] }).some((i) => i.code === 'chart.nodes.invalid')).toBe(true);
+ expect(validateAdminChartSpec({ type: 'graph' }).some((i) => i.code === 'chart.nodes.invalid')).toBe(true);
+ });
+
+ it('rejects duplicate node ids', () => {
+ const bad = { ...GRAPH, nodes: [...GRAPH.nodes!, { id: 'item-1' }] };
+ expect(validateAdminChartSpec(bad).some((i) => i.code === 'chart.node.id.duplicate')).toBe(true);
+ });
+
+ it('rejects an edge referencing an undeclared node', () => {
+ const bad = { ...GRAPH, edges: [{ from: 'set-1', to: 'ghost' }] };
+ expect(validateAdminChartSpec(bad).some((i) => i.code === 'chart.edge.unknown_node')).toBe(true);
+ });
+
+ it('rejects series keys on a graph chart', () => {
+ const bad = { ...GRAPH, valueKeys: ['accepted'] };
+ expect(validateAdminChartSpec(bad).some((i) => i.code === 'chart.series.forbidden')).toBe(true);
+ });
+});
+
+describe('AdminChartSpec renderer defaults', () => {
+ it('exports the defaults both renderers must agree on', () => {
+ expect(ADMIN_CHART_DEFAULT_CATEGORY_KEY).toBe('name');
+ expect(ADMIN_CHART_DEFAULT_MAX_CATEGORIES).toBe(12);
+ expect(ADMIN_CHART_DEFAULT_HEIGHT).toBe(200);
+ });
+});
diff --git a/packages/admin-surface/src/grid.test.ts b/packages/admin-surface/src/grid.test.ts
new file mode 100644
index 00000000..cc407198
--- /dev/null
+++ b/packages/admin-surface/src/grid.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ ADMIN_GRID_SCHEMA,
+ OPERATOR_DASHBOARD_PROFILE_SCHEMA,
+ AdminSurfaceValidationError,
+ defineAdminGridSurface,
+ defineOperatorDashboardProfile,
+ validateAdminGridSurface,
+ validateOperatorDashboardProfile,
+ type AdminGridSurface,
+} from './index.js';
+
+const GRID: AdminGridSurface = {
+ schemaVersion: ADMIN_GRID_SCHEMA,
+ id: 'operator.traffic-board',
+ title: 'Source productivity',
+ rowKey: 'id',
+ columns: [
+ { key: 'name', label: 'Source', kind: 'text', pin: 'left' },
+ { key: 'productivity', label: 'Productivity', kind: 'score', renderer: 'bar', colorRamp: { good: 'high' } },
+ { key: 'owedRatio', label: 'Owed ratio', kind: 'ratio', renderer: 'ramp', colorRamp: { good: 'high' } },
+ { key: 'debt', label: 'Debt', kind: 'count', renderer: 'ramp', align: 'right' },
+ ],
+ rowActions: [
+ { key: 'stop_trade', label: 'Stop', confirm: { reasonRequired: true }, endpoint: '/api/traffic/stop' },
+ { key: 'ban', label: 'Ban', danger: true, confirm: { reasonRequired: true }, endpoint: '/api/traffic/ban' },
+ ],
+ defaultSort: { key: 'productivity', dir: 'desc' },
+};
+
+describe('AdminGridSurface contract', () => {
+ it('accepts a well-formed grid surface', () => {
+ expect(validateAdminGridSurface(GRID)).toHaveLength(0);
+ expect(() => defineAdminGridSurface(GRID)).not.toThrow();
+ });
+
+ it('freezes the defined surface', () => {
+ const g = defineAdminGridSurface(GRID);
+ expect(Object.isFrozen(g)).toBe(true);
+ expect(Object.isFrozen(g.columns)).toBe(true);
+ });
+
+ it('rejects duplicate column keys', () => {
+ const bad = { ...GRID, columns: [...GRID.columns, GRID.columns[0]] };
+ expect(validateAdminGridSurface(bad).some((i) => i.code === 'grid.column.key.duplicate')).toBe(true);
+ });
+
+ it('rejects an unknown metric kind', () => {
+ const bad = { ...GRID, columns: [{ key: 'x', label: 'X', kind: 'bogus' }] };
+ expect(validateAdminGridSurface(bad).some((i) => i.code === 'grid.column.kind.invalid')).toBe(true);
+ });
+
+ it('rejects an unknown renderer', () => {
+ const bad = { ...GRID, columns: [{ key: 'x', label: 'X', kind: 'count', renderer: 'neon' }] };
+ expect(validateAdminGridSurface(bad).some((i) => i.code === 'grid.column.renderer.invalid')).toBe(true);
+ });
+
+ it('rejects a non-same-origin or scheme endpoint (guarded runtime only)', () => {
+ const bad = { ...GRID, rowActions: [{ key: 'ban', label: 'Ban', confirm: { reasonRequired: true }, endpoint: 'https://evil.example/ban' }] };
+ expect(validateAdminGridSurface(bad).some((i) => i.code === 'grid.action.endpoint.invalid')).toBe(true);
+ const protoRel = { ...GRID, rowActions: [{ key: 'ban', label: 'Ban', confirm: { reasonRequired: true }, endpoint: '//evil/ban' }] };
+ expect(validateAdminGridSurface(protoRel).some((i) => i.code === 'grid.action.endpoint.invalid')).toBe(true);
+ });
+
+ it('rejects a defaultSort referencing an undeclared column', () => {
+ const bad = { ...GRID, defaultSort: { key: 'nope', dir: 'desc' } };
+ expect(validateAdminGridSurface(bad).some((i) => i.code === 'grid.sort.key.invalid')).toBe(true);
+ });
+});
+
+describe('OperatorDashboardProfile contract', () => {
+ const profile = {
+ schemaVersion: OPERATOR_DASHBOARD_PROFILE_SCHEMA,
+ id: 'smartcj',
+ label: 'SmartCJ',
+ columns: ['name', 'owedRatio', 'debt', 'productivity'],
+ actions: ['stop_trade', 'ban'],
+ sort: { key: 'owedRatio', dir: 'desc' as const },
+ terminology: { name: 'Trader' },
+ density: 'compact' as const,
+ };
+
+ it('accepts a profile that selects only declared columns/actions', () => {
+ expect(validateOperatorDashboardProfile(profile, GRID)).toHaveLength(0);
+ expect(() => defineOperatorDashboardProfile(profile, GRID)).not.toThrow();
+ });
+
+ it('validates without a grid (shape only)', () => {
+ expect(validateOperatorDashboardProfile(profile)).toHaveLength(0);
+ });
+
+ it('rejects a column the grid does not declare', () => {
+ const bad = { ...profile, columns: ['name', 'ghost'] };
+ expect(validateOperatorDashboardProfile(bad, GRID).some((i) => i.code === 'profile.column.unknown')).toBe(true);
+ });
+
+ it('rejects an action the grid does not declare', () => {
+ const bad = { ...profile, actions: ['nuke'] };
+ expect(validateOperatorDashboardProfile(bad, GRID).some((i) => i.code === 'profile.action.unknown')).toBe(true);
+ });
+
+ it('rejects a sort key that is not one of the profile columns', () => {
+ const bad = { ...profile, sort: { key: 'debt2', dir: 'desc' } };
+ expect(validateOperatorDashboardProfile(bad, GRID).some((i) => i.code === 'profile.sort.key.invalid')).toBe(true);
+ });
+
+ it('HARD INVARIANT: fails closed on any visual-skin key', () => {
+ for (const key of ['css', 'className', 'style', 'skin', 'stylesheet', 'theme']) {
+ const bad = { ...profile, [key]: 'anything' };
+ const issues = validateOperatorDashboardProfile(bad, GRID);
+ expect(issues.some((i) => i.code === 'profile.visual.forbidden')).toBe(true);
+ expect(() => defineOperatorDashboardProfile(bad, GRID)).toThrow(AdminSurfaceValidationError);
+ }
+ });
+
+ it('allows accent (the one permitted visual knob)', () => {
+ const withAccent = { ...profile, accent: '#0d9488' };
+ expect(validateOperatorDashboardProfile(withAccent, GRID)).toHaveLength(0);
+ });
+});
diff --git a/packages/admin-surface/src/index.test.ts b/packages/admin-surface/src/index.test.ts
new file mode 100644
index 00000000..bbee64f7
--- /dev/null
+++ b/packages/admin-surface/src/index.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ ADMIN_SURFACE_SCHEMA,
+ AdminSurfaceValidationError,
+ createLocaleRegistryFromLanguageSupport,
+ defineAdminSurface,
+ filterLocaleOptions,
+ fromColorInputValue,
+ isLocaleAllowed,
+ localeOptionsForField,
+ parseHexRgb,
+ toColorInputValue,
+} from './index.js';
+
+const languageSupport = {
+ productId: 'fixture',
+ manifestId: 'fixture.languages',
+ languages: [
+ {
+ locale: 'en-US', englishName: 'English', nativeName: 'English', enabled: true,
+ providers: [{ providerId: 'fixture.tts', capabilities: ['speech.synthesis'] }],
+ },
+ {
+ locale: 'ru-RU', englishName: 'Russian', nativeName: 'Русский', enabled: true,
+ providers: [{ providerId: 'fixture.stt', capabilities: ['speech.recognition'] }],
+ },
+ {
+ locale: 'sv-SE', englishName: 'Swedish', nativeName: 'Svenska', enabled: false,
+ providers: [{ providerId: 'fixture.all', capabilities: ['speech.synthesis', 'speech.recognition'] }],
+ },
+ ],
+} as const;
+
+const helper = {
+ summary: 'Controls the persisted application defaults.',
+ defaultSemantics: 'The base value is used only when no narrower value exists.',
+ precedence: 'Base is resolved before country, role, and exact user overrides.',
+ effect: 'The published value changes the next resolved profile response.',
+} as const;
+
+function validSurface() {
+ return {
+ schemaVersion: ADMIN_SURFACE_SCHEMA,
+ id: 'fixture.profile-defaults',
+ title: 'Fixture profile defaults',
+ localeRegistryId: 'fixture.languages',
+ blocks: [{
+ id: 'voice', title: 'Voice', helper,
+ fields: [
+ { key: 'locale', label: 'Voice locale', kind: 'locale', requiredCapability: 'speech.synthesis', allowSystem: true },
+ { key: 'accentHex', label: 'Accent colour', kind: 'color', wireFormat: 'RRGGBB' },
+ ],
+ }],
+ } as const;
+}
+
+describe('@ariada-org/admin-surface', () => {
+ it('builds one immutable locale registry from language support and filters it by capability', () => {
+ const registry = createLocaleRegistryFromLanguageSupport(languageSupport);
+ expect(registry.id).toBe('fixture.languages');
+ expect(registry.options.map(({ value }) => value)).toEqual(['en-US', 'ru-RU']);
+ expect(filterLocaleOptions(registry, 'speech.synthesis').map(({ value }) => value)).toEqual(['en-US']);
+ expect(Object.isFrozen(registry.options)).toBe(true);
+ });
+
+ it('adds the typed phone-system option only when locale field metadata explicitly allows it', () => {
+ const registry = createLocaleRegistryFromLanguageSupport(languageSupport);
+ const ordinary = localeOptionsForField(registry, { kind: 'locale', key: 'target', label: 'Target locale' });
+ const native = localeOptionsForField(registry, {
+ kind: 'locale', key: 'native', label: 'Native locale', allowSystem: true,
+ });
+ expect(ordinary.some(({ value }) => value === 'system')).toBe(false);
+ expect(native[0]).toMatchObject({ kind: 'system', value: 'system', label: 'Follow phone system' });
+ expect(isLocaleAllowed(registry, 'system', undefined, false)).toBe(false);
+ expect(isLocaleAllowed(registry, 'system', undefined, true)).toBe(true);
+ });
+
+ it('normalizes picker input while preserving a strict uppercase six-digit wire model', () => {
+ expect(parseHexRgb('#a1b2c3')).toBe('A1B2C3');
+ expect(toColorInputValue('A1B2C3')).toBe('#a1b2c3');
+ expect(fromColorInputValue('#f2f2f7')).toBe('F2F2F7');
+ expect(() => parseHexRgb('#12345')).toThrow(AdminSurfaceValidationError);
+ });
+
+ it('accepts a surface whose locale and colour fields are controlled by the shared contract', () => {
+ const surface = defineAdminSurface(validSurface());
+ expect(surface).toEqual(validSurface());
+ expect(Object.isFrozen(surface.blocks[0]?.fields)).toBe(true);
+ });
+
+ it.each([
+ ['missing contextual helper', () => ({ ...validSurface(), blocks: [{ ...validSurface().blocks[0], helper: undefined }] })],
+ ['free-text locale', () => ({ ...validSurface(), blocks: [{ ...validSurface().blocks[0], fields: [{ key: 'locale', label: 'Locale', kind: 'text' }] }] })],
+ ['free-text colour', () => ({ ...validSurface(), blocks: [{ ...validSurface().blocks[0], fields: [{ key: 'accentHex', label: 'Accent colour', kind: 'text' }] }] })],
+ ['locale without registry', () => ({ ...validSurface(), localeRegistryId: undefined })],
+ ])('rejects %s', (_label, fixture) => {
+ expect(() => defineAdminSurface(fixture())).toThrow(AdminSurfaceValidationError);
+ });
+});
diff --git a/packages/admin-surface/src/index.ts b/packages/admin-surface/src/index.ts
new file mode 100644
index 00000000..b786b572
--- /dev/null
+++ b/packages/admin-surface/src/index.ts
@@ -0,0 +1,847 @@
+// SPDX-FileCopyrightText: 2026 Agonist Development AB
+// SPDX-License-Identifier: EUPL-1.2
+
+export const ADMIN_SURFACE_SCHEMA = 'ariada-org.admin-surface/v1' as const;
+export const HEX_RGB_WIRE_FORMAT = 'RRGGBB' as const;
+export const SYSTEM_LOCALE_VALUE = 'system' as const;
+
+export type HexRgb = string & { readonly __hexRgb: unique symbol };
+
+export interface AdminContextualHelp {
+ readonly summary: string;
+ readonly defaultSemantics: string;
+ readonly precedence: string;
+ readonly effect: string;
+}
+
+interface AdminFieldBase {
+ readonly key: string;
+ readonly label: string;
+ readonly description?: string;
+}
+
+export interface AdminTextField extends AdminFieldBase {
+ readonly kind: 'text' | 'nullable-text';
+ readonly maxLength?: number;
+}
+
+export interface AdminNumberField extends AdminFieldBase {
+ readonly kind: 'number' | 'integer';
+ readonly min?: number;
+ readonly max?: number;
+ readonly step?: number;
+}
+
+export interface AdminBooleanField extends AdminFieldBase {
+ readonly kind: 'boolean';
+}
+
+export interface AdminSelectField extends AdminFieldBase {
+ readonly kind: 'select';
+ readonly options: readonly string[];
+}
+
+export interface AdminLocaleField extends AdminFieldBase {
+ readonly kind: 'locale';
+ readonly requiredCapability?: string;
+ readonly allowSystem?: true;
+}
+
+export interface AdminColorField extends AdminFieldBase {
+ readonly kind: 'color';
+ readonly wireFormat: typeof HEX_RGB_WIRE_FORMAT;
+}
+
+export type AdminFieldDefinition =
+ | AdminTextField
+ | AdminNumberField
+ | AdminBooleanField
+ | AdminSelectField
+ | AdminLocaleField
+ | AdminColorField;
+
+export interface AdminSemanticBlockDefinition {
+ readonly id: string;
+ readonly title: string;
+ readonly helper: AdminContextualHelp;
+}
+
+export interface AdminFieldBlockDefinition extends AdminSemanticBlockDefinition {
+ readonly fields: readonly AdminFieldDefinition[];
+}
+
+export interface AdminSurfaceDefinition {
+ readonly schemaVersion: typeof ADMIN_SURFACE_SCHEMA;
+ readonly id: string;
+ readonly title: string;
+ readonly localeRegistryId?: string;
+ readonly blocks: readonly AdminFieldBlockDefinition[];
+}
+
+export interface LocaleOption {
+ readonly kind: 'locale';
+ readonly value: string;
+ readonly englishName: string;
+ readonly nativeName: string;
+ readonly label: string;
+ readonly capabilities: readonly string[];
+}
+
+export interface SystemLocaleOption {
+ readonly kind: 'system';
+ readonly value: typeof SYSTEM_LOCALE_VALUE;
+ readonly label: 'Follow phone system';
+}
+
+export type LocaleSelectOption = LocaleOption | SystemLocaleOption;
+
+export const SYSTEM_LOCALE_OPTION: SystemLocaleOption = Object.freeze({
+ kind: 'system',
+ value: SYSTEM_LOCALE_VALUE,
+ label: 'Follow phone system',
+});
+
+export interface LocaleRegistry {
+ readonly id: string;
+ readonly productId: string;
+ readonly options: readonly LocaleOption[];
+}
+
+interface LanguageSupportLike {
+ readonly productId: string;
+ readonly manifestId: string;
+ readonly languages: readonly {
+ readonly locale: string;
+ readonly englishName: string;
+ readonly nativeName: string;
+ readonly enabled: boolean;
+ readonly providers: readonly {
+ readonly capabilities: readonly string[];
+ }[];
+ }[];
+}
+
+export interface AdminSurfaceIssue {
+ readonly code: string;
+ readonly path: string;
+ readonly message: string;
+}
+
+export class AdminSurfaceValidationError extends Error {
+ readonly code = 'ADMIN_SURFACE_VALIDATION_FAILED';
+ readonly issues: readonly AdminSurfaceIssue[];
+
+ constructor(issues: readonly AdminSurfaceIssue[]) {
+ super('Admin surface validation failed');
+ this.name = 'AdminSurfaceValidationError';
+ this.issues = Object.freeze(issues.map((issue) => Object.freeze({ ...issue })));
+ }
+}
+
+const ID = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
+const FIELD_KEY = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/;
+const LOCALE = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
+const CAPABILITY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/;
+const HEX_RGB = /^[0-9A-F]{6}$/;
+const SEMANTIC_LOCALE = /(?:^|[._:\s-])(locale|language)(?:$|[._:\s-])/i;
+const SEMANTIC_COLOR = /(?:^|[._:\s-])(colou?r|hex|foreground|background)(?:$|[._:\s-])/i;
+const FIELD_KINDS = new Set(['text', 'nullable-text', 'number', 'integer', 'boolean', 'select', 'locale', 'color']);
+
+type MutableRecord = Record;
+
+export function defineAdminSurface(value: T): T & AdminSurfaceDefinition {
+ const issues = validateAdminSurface(value);
+ if (issues.length > 0) throw new AdminSurfaceValidationError(issues);
+ return deepFreeze(structuredClone(value)) as T & AdminSurfaceDefinition;
+}
+
+export function defineAdminSemanticBlocks(value: T): T & readonly AdminSemanticBlockDefinition[] {
+ const issues: AdminSurfaceIssue[] = [];
+ const ids = new Set();
+ value.forEach((candidate, index) => validateSemanticBlock(candidate, `$[${index}]`, ids, issues, false));
+ if (issues.length > 0) throw new AdminSurfaceValidationError(issues);
+ return deepFreeze(structuredClone(value)) as T & readonly AdminSemanticBlockDefinition[];
+}
+
+export function validateAdminSurface(value: unknown): readonly AdminSurfaceIssue[] {
+ const issues: AdminSurfaceIssue[] = [];
+ const root = record(value, '$', issues);
+ if (!root) return freezeIssues(issues);
+ if (root.schemaVersion !== ADMIN_SURFACE_SCHEMA) {
+ add(issues, 'surface.schema.invalid', '$.schemaVersion', `Expected ${ADMIN_SURFACE_SCHEMA}.`);
+ }
+ token(root.id, ID, '$.id', 'surface.id.invalid', issues);
+ text(root.title, '$.title', 'surface.title.invalid', issues);
+ if (root.localeRegistryId !== undefined) {
+ token(root.localeRegistryId, ID, '$.localeRegistryId', 'locale_registry.id.invalid', issues);
+ }
+ if (!Array.isArray(root.blocks) || root.blocks.length < 1) {
+ add(issues, 'surface.blocks.invalid', '$.blocks', 'At least one semantic block is required.');
+ return freezeIssues(issues);
+ }
+ const ids = new Set();
+ let hasLocale = false;
+ root.blocks.forEach((candidate, index) => {
+ const block = validateSemanticBlock(candidate, `$.blocks[${index}]`, ids, issues, true);
+ if (!block || !Array.isArray(block.fields)) return;
+ const keys = new Set();
+ block.fields.forEach((field, fieldIndex) => {
+ const parsed = validateField(field, `$.blocks[${index}].fields[${fieldIndex}]`, keys, issues);
+ if (parsed?.kind === 'locale') hasLocale = true;
+ });
+ });
+ if (hasLocale && typeof root.localeRegistryId !== 'string') {
+ add(issues, 'locale_registry.required', '$.localeRegistryId', 'A surface with locale fields requires one locale registry.');
+ }
+ return freezeIssues(issues);
+}
+
+export function createLocaleRegistryFromLanguageSupport(value: LanguageSupportLike): LocaleRegistry {
+ const issues: AdminSurfaceIssue[] = [];
+ const root = record(value, '$', issues);
+ if (!root) throw new AdminSurfaceValidationError(issues);
+ const productId = token(root.productId, ID, '$.productId', 'locale_registry.product.invalid', issues);
+ const manifestId = token(root.manifestId, ID, '$.manifestId', 'locale_registry.id.invalid', issues);
+ if (!Array.isArray(root.languages)) {
+ add(issues, 'locale_registry.languages.invalid', '$.languages', 'Languages must be an array.');
+ }
+ const options: LocaleOption[] = [];
+ const locales = new Set();
+ if (Array.isArray(root.languages)) root.languages.forEach((candidate, index) => {
+ const language = record(candidate, `$.languages[${index}]`, issues);
+ if (!language || language.enabled !== true) return;
+ const locale = token(language.locale, LOCALE, `$.languages[${index}].locale`, 'locale.invalid', issues);
+ const englishName = text(language.englishName, `$.languages[${index}].englishName`, 'locale.english_name.invalid', issues);
+ const nativeName = text(language.nativeName, `$.languages[${index}].nativeName`, 'locale.native_name.invalid', issues);
+ if (!locale || !englishName || !nativeName) return;
+ if (locales.has(locale)) {
+ add(issues, 'locale.duplicate', `$.languages[${index}].locale`, 'Enabled locale values must be unique.');
+ return;
+ }
+ locales.add(locale);
+ const capabilities = new Set();
+ if (!Array.isArray(language.providers)) {
+ add(issues, 'locale.providers.invalid', `$.languages[${index}].providers`, 'Providers must be an array.');
+ return;
+ }
+ for (const providerCandidate of language.providers) {
+ const provider = record(providerCandidate, `$.languages[${index}].providers`, issues);
+ if (!provider || !Array.isArray(provider.capabilities)) continue;
+ for (const capability of provider.capabilities) {
+ if (typeof capability === 'string' && CAPABILITY.test(capability)) capabilities.add(capability);
+ }
+ }
+ options.push(Object.freeze({
+ kind: 'locale',
+ value: locale,
+ englishName,
+ nativeName,
+ label: englishName === nativeName ? `${englishName} (${locale})` : `${englishName} — ${nativeName} (${locale})`,
+ capabilities: Object.freeze([...capabilities].sort()),
+ }));
+ });
+ if (options.length < 1) add(issues, 'locale_registry.empty', '$.languages', 'At least one enabled locale is required.');
+ if (issues.length > 0) throw new AdminSurfaceValidationError(issues);
+ return Object.freeze({
+ id: manifestId!,
+ productId: productId!,
+ options: Object.freeze(options),
+ });
+}
+
+export function filterLocaleOptions(registry: LocaleRegistry, requiredCapability?: string): readonly LocaleOption[] {
+ if (!requiredCapability) return registry.options;
+ return Object.freeze(registry.options.filter(({ capabilities }) => capabilities.includes(requiredCapability)));
+}
+
+export function localeOptionsForField(
+ registry: LocaleRegistry,
+ field: Pick,
+): readonly LocaleSelectOption[] {
+ const locales = filterLocaleOptions(registry, field.requiredCapability);
+ return field.allowSystem === true
+ ? Object.freeze([SYSTEM_LOCALE_OPTION, ...locales])
+ : locales;
+}
+
+export function isLocaleAllowed(
+ registry: LocaleRegistry,
+ value: unknown,
+ requiredCapability?: string,
+ allowSystem = false,
+): value is string {
+ if (value === SYSTEM_LOCALE_VALUE) return allowSystem;
+ return typeof value === 'string'
+ && filterLocaleOptions(registry, requiredCapability).some((option) => option.value === value);
+}
+
+export function parseHexRgb(value: unknown): HexRgb {
+ const normalized = typeof value === 'string' ? value.replace(/^#/, '').toUpperCase() : '';
+ if (!HEX_RGB.test(normalized)) {
+ throw new AdminSurfaceValidationError([{
+ code: 'color.hex_rgb.invalid',
+ path: '$',
+ message: 'Expected exactly six hexadecimal digits, with an optional leading #.',
+ }]);
+ }
+ return normalized as HexRgb;
+}
+
+export function isHexRgbWire(value: unknown): value is HexRgb {
+ return typeof value === 'string' && HEX_RGB.test(value);
+}
+
+export function toColorInputValue(value: unknown): string {
+ return `#${parseHexRgb(value).toLowerCase()}`;
+}
+
+export function fromColorInputValue(value: unknown): HexRgb {
+ return parseHexRgb(value);
+}
+
+// ── Operator grid, metric column, row action and dashboard-profile contracts ──
+// Framework-neutral (no React / AntD / AG Grid). A concrete UI — e.g.
+// @ariada-org/admin-ui over AG Grid — renders these; the contract never carries a
+// visual skin. See the FAP operator-dashboard design spec, sections 5.1 / 5.1a.
+
+export const ADMIN_GRID_SCHEMA = 'ariada-org.admin-grid/v1' as const;
+export const OPERATOR_DASHBOARD_PROFILE_SCHEMA = 'ariada-org.operator-dashboard-profile/v1' as const;
+
+export type AdminMetricKind =
+ | 'count' | 'ratio' | 'currency' | 'percent' | 'duration' | 'score' | 'text' | 'enum';
+export type AdminColumnRenderer =
+ | 'plain' | 'bar' | 'ramp' | 'sparkline' | 'tag' | 'status-dot';
+
+export interface AdminColumnHelp {
+ /** one-line "what is this column". */
+ readonly description: string;
+ /** how it is computed, e.g. "accepted / raws". */
+ readonly formula?: string;
+ /** wiki page slug (language is chosen by the renderer), e.g. "owed-ratio". */
+ readonly wikiSlug?: string;
+ /** anchor within the wiki page. */
+ readonly wikiAnchor?: string;
+}
+
+export interface AdminMetricColumn {
+ readonly key: string;
+ readonly label: string;
+ readonly kind: AdminMetricKind;
+ readonly align?: 'left' | 'right' | 'center';
+ readonly renderer?: AdminColumnRenderer;
+ readonly colorRamp?: { readonly good: 'high' | 'low' };
+ readonly pin?: 'left' | 'right';
+ readonly width?: number;
+ /** optional header helper: description + formula + wiki link */
+ readonly help?: AdminColumnHelp;
+}
+
+export interface AdminRowAction {
+ readonly key: string;
+ readonly label: string;
+ readonly danger?: boolean;
+ readonly confirm: { readonly title?: string; readonly reasonRequired: boolean };
+ /** guarded runtime path the UI posts to; never a raw DB write */
+ readonly endpoint: string;
+}
+
+export interface AdminGridSurface {
+ readonly schemaVersion: typeof ADMIN_GRID_SCHEMA;
+ readonly id: string;
+ readonly title: string;
+ readonly rowKey: string;
+ readonly columns: readonly AdminMetricColumn[];
+ readonly rowActions?: readonly AdminRowAction[];
+ readonly liveChannel?: string;
+ readonly defaultSort?: { readonly key: string; readonly dir: 'asc' | 'desc' };
+}
+
+export interface OperatorDashboardProfile {
+ readonly schemaVersion: typeof OPERATOR_DASHBOARD_PROFILE_SCHEMA;
+ readonly id: string;
+ readonly label: string;
+ readonly landingPanel?: string;
+ readonly panels?: readonly string[];
+ /** subset + order of a grid's column keys */
+ readonly columns: readonly string[];
+ /** subset of a grid's row-action keys */
+ readonly actions: readonly string[];
+ readonly sort?: { readonly key: string; readonly dir: 'asc' | 'desc' };
+ readonly terminology?: Readonly>;
+ readonly density?: 'comfortable' | 'compact';
+ /** the ONLY visual knob — a brand accent within the shared theme, not a skin */
+ readonly accent?: string;
+}
+
+const METRIC_KINDS = new Set(['count', 'ratio', 'currency', 'percent', 'duration', 'score', 'text', 'enum']);
+const COLUMN_RENDERERS = new Set(['plain', 'bar', 'ramp', 'sparkline', 'tag', 'status-dot']);
+const ALIGNS = new Set(['left', 'right', 'center']);
+const PINS = new Set(['left', 'right']);
+const SORT_DIRS = new Set(['asc', 'desc']);
+const DENSITIES = new Set(['comfortable', 'compact']);
+// HARD INVARIANT (spec 5.1a): a profile changes content/functionality only — it
+// may never carry a visual skin. These keys fail closed.
+const FORBIDDEN_PROFILE_KEYS = new Set(['css', 'class', 'classname', 'style', 'skin', 'stylesheet', 'theme']);
+
+export function defineAdminGridSurface(value: T): T & AdminGridSurface {
+ const issues = validateAdminGridSurface(value);
+ if (issues.length > 0) throw new AdminSurfaceValidationError(issues);
+ return deepFreeze(structuredClone(value)) as T & AdminGridSurface;
+}
+
+export function defineOperatorDashboardProfile(value: T, grid?: AdminGridSurface): T & OperatorDashboardProfile {
+ const issues = validateOperatorDashboardProfile(value, grid);
+ if (issues.length > 0) throw new AdminSurfaceValidationError(issues);
+ return deepFreeze(structuredClone(value)) as T & OperatorDashboardProfile;
+}
+
+export function validateAdminGridSurface(value: unknown): readonly AdminSurfaceIssue[] {
+ const issues: AdminSurfaceIssue[] = [];
+ const root = record(value, '$', issues);
+ if (!root) return freezeIssues(issues);
+ if (root.schemaVersion !== ADMIN_GRID_SCHEMA) {
+ add(issues, 'grid.schema.invalid', '$.schemaVersion', `Expected ${ADMIN_GRID_SCHEMA}.`);
+ }
+ token(root.id, ID, '$.id', 'grid.id.invalid', issues);
+ text(root.title, '$.title', 'grid.title.invalid', issues);
+ token(root.rowKey, FIELD_KEY, '$.rowKey', 'grid.rowKey.invalid', issues);
+ const columnKeys = new Set();
+ if (!Array.isArray(root.columns) || root.columns.length < 1) {
+ add(issues, 'grid.columns.invalid', '$.columns', 'At least one column is required.');
+ } else {
+ root.columns.forEach((candidate, index) => {
+ const col = record(candidate, `$.columns[${index}]`, issues);
+ if (!col) return;
+ const key = token(col.key, FIELD_KEY, `$.columns[${index}].key`, 'grid.column.key.invalid', issues);
+ text(col.label, `$.columns[${index}].label`, 'grid.column.label.invalid', issues);
+ if (key && columnKeys.has(key)) add(issues, 'grid.column.key.duplicate', `$.columns[${index}].key`, 'Column keys must be unique.');
+ if (key) columnKeys.add(key);
+ if (typeof col.kind !== 'string' || !METRIC_KINDS.has(col.kind)) {
+ add(issues, 'grid.column.kind.invalid', `$.columns[${index}].kind`, 'Unknown metric column kind.');
+ }
+ if (col.renderer !== undefined && (typeof col.renderer !== 'string' || !COLUMN_RENDERERS.has(col.renderer))) {
+ add(issues, 'grid.column.renderer.invalid', `$.columns[${index}].renderer`, 'Unknown column renderer.');
+ }
+ if (col.align !== undefined && (typeof col.align !== 'string' || !ALIGNS.has(col.align))) {
+ add(issues, 'grid.column.align.invalid', `$.columns[${index}].align`, 'Align must be left, right or center.');
+ }
+ if (col.pin !== undefined && (typeof col.pin !== 'string' || !PINS.has(col.pin))) {
+ add(issues, 'grid.column.pin.invalid', `$.columns[${index}].pin`, 'Pin must be left or right.');
+ }
+ if (col.colorRamp !== undefined) {
+ const ramp = record(col.colorRamp, `$.columns[${index}].colorRamp`, issues);
+ if (ramp && ramp.good !== 'high' && ramp.good !== 'low') {
+ add(issues, 'grid.column.ramp.invalid', `$.columns[${index}].colorRamp.good`, 'colorRamp.good must be high or low.');
+ }
+ }
+ if (col.width !== undefined && (typeof col.width !== 'number' || !Number.isFinite(col.width) || col.width <= 0)) {
+ add(issues, 'grid.column.width.invalid', `$.columns[${index}].width`, 'Width must be a positive number.');
+ }
+ if (col.help !== undefined) {
+ const help = record(col.help, `$.columns[${index}].help`, issues);
+ if (help) {
+ text(help.description, `$.columns[${index}].help.description`, 'grid.column.help.description.invalid', issues);
+ if (help.formula !== undefined && (typeof help.formula !== 'string' || help.formula.length < 1 || help.formula.length > 512)) {
+ add(issues, 'grid.column.help.formula.invalid', `$.columns[${index}].help.formula`, 'formula must be a non-empty string.');
+ }
+ if (help.wikiSlug !== undefined) token(help.wikiSlug, ID, `$.columns[${index}].help.wikiSlug`, 'grid.column.help.wikiSlug.invalid', issues);
+ if (help.wikiAnchor !== undefined) token(help.wikiAnchor, ID, `$.columns[${index}].help.wikiAnchor`, 'grid.column.help.wikiAnchor.invalid', issues);
+ }
+ }
+ });
+ }
+ const actionKeys = new Set();
+ if (root.rowActions !== undefined) {
+ if (!Array.isArray(root.rowActions)) {
+ add(issues, 'grid.rowActions.invalid', '$.rowActions', 'rowActions must be an array.');
+ } else {
+ root.rowActions.forEach((candidate, index) => {
+ const action = record(candidate, `$.rowActions[${index}]`, issues);
+ if (!action) return;
+ const key = token(action.key, FIELD_KEY, `$.rowActions[${index}].key`, 'grid.action.key.invalid', issues);
+ text(action.label, `$.rowActions[${index}].label`, 'grid.action.label.invalid', issues);
+ if (key && actionKeys.has(key)) add(issues, 'grid.action.key.duplicate', `$.rowActions[${index}].key`, 'Row-action keys must be unique.');
+ if (key) actionKeys.add(key);
+ const confirm = record(action.confirm, `$.rowActions[${index}].confirm`, issues);
+ if (confirm && typeof confirm.reasonRequired !== 'boolean') {
+ add(issues, 'grid.action.confirm.invalid', `$.rowActions[${index}].confirm.reasonRequired`, 'confirm.reasonRequired must be boolean.');
+ }
+ if (typeof action.endpoint !== 'string' || !action.endpoint.startsWith('/') || action.endpoint.startsWith('//')) {
+ add(issues, 'grid.action.endpoint.invalid', `$.rowActions[${index}].endpoint`, 'endpoint must be a same-origin guarded-runtime path, never a raw write.');
+ }
+ });
+ }
+ }
+ if (root.defaultSort !== undefined) {
+ const sort = record(root.defaultSort, '$.defaultSort', issues);
+ if (sort) {
+ if (typeof sort.key !== 'string' || !columnKeys.has(sort.key)) {
+ add(issues, 'grid.sort.key.invalid', '$.defaultSort.key', 'defaultSort.key must reference a declared column.');
+ }
+ if (typeof sort.dir !== 'string' || !SORT_DIRS.has(sort.dir)) {
+ add(issues, 'grid.sort.dir.invalid', '$.defaultSort.dir', 'defaultSort.dir must be asc or desc.');
+ }
+ }
+ }
+ return freezeIssues(issues);
+}
+
+export function validateOperatorDashboardProfile(value: unknown, grid?: AdminGridSurface): readonly AdminSurfaceIssue[] {
+ const issues: AdminSurfaceIssue[] = [];
+ const root = record(value, '$', issues);
+ if (!root) return freezeIssues(issues);
+ if (root.schemaVersion !== OPERATOR_DASHBOARD_PROFILE_SCHEMA) {
+ add(issues, 'profile.schema.invalid', '$.schemaVersion', `Expected ${OPERATOR_DASHBOARD_PROFILE_SCHEMA}.`);
+ }
+ token(root.id, ID, '$.id', 'profile.id.invalid', issues);
+ text(root.label, '$.label', 'profile.label.invalid', issues);
+ // HARD INVARIANT: a profile is content/functionality only — any visual-skin
+ // key fails closed. Only `accent` (a brand colour within the shared theme) is
+ // allowed.
+ for (const key of Object.keys(root)) {
+ if (FORBIDDEN_PROFILE_KEYS.has(key.toLowerCase())) {
+ add(issues, 'profile.visual.forbidden', `$.${key}`, 'A dashboard profile may not carry a visual skin (css/class/style/skin/theme); only accent is allowed, within the shared theme.');
+ }
+ }
+ const gridColumnKeys = grid ? new Set(grid.columns.map((c) => c.key)) : null;
+ const gridActionKeys = grid ? new Set((grid.rowActions ?? []).map((a) => a.key)) : null;
+ const profileColumns = new Set();
+ if (!Array.isArray(root.columns) || root.columns.length < 1) {
+ add(issues, 'profile.columns.invalid', '$.columns', 'A profile must select at least one column.');
+ } else {
+ root.columns.forEach((key, index) => {
+ if (typeof key !== 'string') { add(issues, 'profile.column.invalid', `$.columns[${index}]`, 'Column keys must be strings.'); return; }
+ profileColumns.add(key);
+ if (gridColumnKeys && !gridColumnKeys.has(key)) {
+ add(issues, 'profile.column.unknown', `$.columns[${index}]`, `Column "${key}" is not declared by the grid.`);
+ }
+ });
+ }
+ if (root.actions !== undefined) {
+ if (!Array.isArray(root.actions)) {
+ add(issues, 'profile.actions.invalid', '$.actions', 'actions must be an array.');
+ } else {
+ root.actions.forEach((key, index) => {
+ if (typeof key !== 'string') { add(issues, 'profile.action.invalid', `$.actions[${index}]`, 'Action keys must be strings.'); return; }
+ if (gridActionKeys && !gridActionKeys.has(key)) {
+ add(issues, 'profile.action.unknown', `$.actions[${index}]`, `Action "${key}" is not declared by the grid.`);
+ }
+ });
+ }
+ }
+ if (root.sort !== undefined) {
+ const sort = record(root.sort, '$.sort', issues);
+ if (sort) {
+ if (typeof sort.key !== 'string' || (profileColumns.size > 0 && !profileColumns.has(sort.key))) {
+ add(issues, 'profile.sort.key.invalid', '$.sort.key', 'sort.key must be one of the profile columns.');
+ }
+ if (typeof sort.dir !== 'string' || !SORT_DIRS.has(sort.dir)) {
+ add(issues, 'profile.sort.dir.invalid', '$.sort.dir', 'sort.dir must be asc or desc.');
+ }
+ }
+ }
+ if (root.density !== undefined && (typeof root.density !== 'string' || !DENSITIES.has(root.density))) {
+ add(issues, 'profile.density.invalid', '$.density', 'density must be comfortable or compact.');
+ }
+ if (root.terminology !== undefined) {
+ const term = record(root.terminology, '$.terminology', issues);
+ if (term) {
+ for (const [k, v] of Object.entries(term)) {
+ if (typeof v !== 'string') add(issues, 'profile.terminology.invalid', `$.terminology.${k}`, 'Terminology overrides must be strings.');
+ }
+ }
+ }
+ return freezeIssues(issues);
+}
+
+// ── Chart contract (declarative, framework-neutral) ──────────────────────────
+// A board declares an AdminChartSpec exactly the way it declares columns; the
+// shared renderer draws it. TWO renderers read this ONE contract:
+// @ariada-org/admin-ui (React, for Projectology) and @ariada-org/admin-svelte (Svelte,
+// for KlarAds). The spec carries CONTENT only — series identity, categories,
+// relationships. It may never carry a visual skin (css/class/style/theme), the
+// same hard invariant the dashboard profile enforces.
+
+export type AdminChartType = 'column' | 'line' | 'funnel' | 'graph';
+
+/** a node in a relationship-map (`graph`) chart — e.g. one item of a комплект. */
+export interface GraphNode {
+ readonly id: string;
+ readonly label?: string;
+ /** optional grouping; the renderer maps a group to a palette slot. */
+ readonly group?: string;
+}
+
+/** an edge (relationship) between two declared nodes. */
+export interface GraphEdge {
+ readonly from: string;
+ readonly to: string;
+ readonly label?: string;
+}
+
+/**
+ * A board/surface-declared chart. `column` / `line` / `funnel` plot rows
+ * (category on X, numeric series on Y); `graph` renders a relationship map from
+ * nodes + edges. The contract is the stable seam: a light zero-dependency SVG
+ * default renders it today, and a richer charting backend can swap in behind the
+ * SAME spec for a consumer that opts into the heavy dependency — shared, not
+ * forked.
+ */
+export interface AdminChartSpec {
+ readonly type: AdminChartType;
+ readonly title?: string;
+ /** row key whose value labels each category (X axis). column/line/funnel. */
+ readonly categoryKey?: string;
+ /** row keys plotted as series (Y). Funnel uses the first key. column/line/funnel. */
+ readonly valueKeys?: readonly string[];
+ /** graph data (`type: 'graph'`) — the relationship map. */
+ readonly nodes?: readonly GraphNode[];
+ readonly edges?: readonly GraphEdge[];
+ /** optional fixed colours per series/group; falls back to the renderer palette. */
+ readonly colors?: readonly string[];
+ /** cap categories (default 12) — keeps a dense board readable. */
+ readonly maxCategories?: number;
+ readonly height?: number;
+ readonly unit?: string;
+}
+
+/** the category key a renderer falls back to when a spec omits `categoryKey`. */
+export const ADMIN_CHART_DEFAULT_CATEGORY_KEY = 'name' as const;
+/** the category cap a renderer falls back to when a spec omits `maxCategories`. */
+export const ADMIN_CHART_DEFAULT_MAX_CATEGORIES = 12 as const;
+/** the plot height a renderer falls back to when a spec omits `height`. */
+export const ADMIN_CHART_DEFAULT_HEIGHT = 200 as const;
+
+const CHART_TYPES = new Set(['column', 'line', 'funnel', 'graph']);
+const PLOT_CHART_TYPES = new Set(['column', 'line', 'funnel']);
+const NODE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
+// Colours are DATA (series identity), so they are restricted to literal CSS hex.
+// Anything else (a gradient, a url(), a var(), a class) would be a skin and is
+// rejected — the same reason a profile may not carry a stylesheet.
+const CSS_HEX = /^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
+const MAX_CHART_COLORS = 24;
+const MAX_GRAPH_NODES = 512;
+const MAX_GRAPH_EDGES = 2048;
+
+export function defineAdminChartSpec(value: T): T & AdminChartSpec {
+ const issues = validateAdminChartSpec(value);
+ if (issues.length > 0) throw new AdminSurfaceValidationError(issues);
+ return deepFreeze(structuredClone(value)) as T & AdminChartSpec;
+}
+
+export function validateAdminChartSpec(value: unknown): readonly AdminSurfaceIssue[] {
+ const issues: AdminSurfaceIssue[] = [];
+ const root = record(value, '$', issues);
+ if (!root) return freezeIssues(issues);
+
+ // HARD INVARIANT (same as the dashboard profile): content only, never a skin.
+ for (const key of Object.keys(root)) {
+ if (FORBIDDEN_PROFILE_KEYS.has(key.toLowerCase())) {
+ add(issues, 'chart.visual.forbidden', `$.${key}`, 'A chart spec may not carry a visual skin (css/class/style/skin/theme); only literal series colours are allowed.');
+ }
+ }
+
+ const type = typeof root.type === 'string' && CHART_TYPES.has(root.type) ? root.type : undefined;
+ if (!type) add(issues, 'chart.type.invalid', '$.type', 'type must be column, line, funnel or graph.');
+ if (root.title !== undefined) text(root.title, '$.title', 'chart.title.invalid', issues);
+ if (root.unit !== undefined) text(root.unit, '$.unit', 'chart.unit.invalid', issues);
+
+ const isGraph = type === 'graph';
+ const isPlot = type !== undefined && PLOT_CHART_TYPES.has(type);
+
+ if (isPlot) {
+ if (root.categoryKey !== undefined) {
+ token(root.categoryKey, FIELD_KEY, '$.categoryKey', 'chart.categoryKey.invalid', issues);
+ }
+ if (!Array.isArray(root.valueKeys) || root.valueKeys.length < 1) {
+ add(issues, 'chart.valueKeys.invalid', '$.valueKeys', 'A column/line/funnel chart must declare at least one value key.');
+ } else {
+ const seen = new Set();
+ root.valueKeys.forEach((key, index) => {
+ const parsed = token(key, FIELD_KEY, `$.valueKeys[${index}]`, 'chart.valueKey.invalid', issues);
+ if (!parsed) return;
+ if (seen.has(parsed)) add(issues, 'chart.valueKey.duplicate', `$.valueKeys[${index}]`, 'Value keys must be unique.');
+ seen.add(parsed);
+ });
+ }
+ if (root.nodes !== undefined || root.edges !== undefined) {
+ add(issues, 'chart.graph.forbidden', '$.nodes', 'nodes/edges belong to a graph chart only.');
+ }
+ }
+
+ if (isGraph) {
+ if (root.categoryKey !== undefined || root.valueKeys !== undefined) {
+ add(issues, 'chart.series.forbidden', '$.valueKeys', 'categoryKey/valueKeys belong to a column, line or funnel chart only.');
+ }
+ const nodeIds = new Set();
+ if (!Array.isArray(root.nodes) || root.nodes.length < 1) {
+ add(issues, 'chart.nodes.invalid', '$.nodes', 'A graph chart must declare at least one node.');
+ } else if (root.nodes.length > MAX_GRAPH_NODES) {
+ add(issues, 'chart.nodes.too_many', '$.nodes', `A graph chart may declare at most ${MAX_GRAPH_NODES} nodes.`);
+ } else {
+ root.nodes.forEach((candidate, index) => {
+ const node = record(candidate, `$.nodes[${index}]`, issues);
+ if (!node) return;
+ const id = token(node.id, NODE_ID, `$.nodes[${index}].id`, 'chart.node.id.invalid', issues);
+ if (id && nodeIds.has(id)) add(issues, 'chart.node.id.duplicate', `$.nodes[${index}].id`, 'Node ids must be unique.');
+ if (id) nodeIds.add(id);
+ if (node.label !== undefined) text(node.label, `$.nodes[${index}].label`, 'chart.node.label.invalid', issues);
+ if (node.group !== undefined) text(node.group, `$.nodes[${index}].group`, 'chart.node.group.invalid', issues);
+ });
+ }
+ if (root.edges !== undefined) {
+ if (!Array.isArray(root.edges)) {
+ add(issues, 'chart.edges.invalid', '$.edges', 'edges must be an array.');
+ } else if (root.edges.length > MAX_GRAPH_EDGES) {
+ add(issues, 'chart.edges.too_many', '$.edges', `A graph chart may declare at most ${MAX_GRAPH_EDGES} edges.`);
+ } else {
+ root.edges.forEach((candidate, index) => {
+ const edge = record(candidate, `$.edges[${index}]`, issues);
+ if (!edge) return;
+ const from = token(edge.from, NODE_ID, `$.edges[${index}].from`, 'chart.edge.from.invalid', issues);
+ const to = token(edge.to, NODE_ID, `$.edges[${index}].to`, 'chart.edge.to.invalid', issues);
+ if (edge.label !== undefined) text(edge.label, `$.edges[${index}].label`, 'chart.edge.label.invalid', issues);
+ if (nodeIds.size === 0) return;
+ if (from && !nodeIds.has(from)) add(issues, 'chart.edge.unknown_node', `$.edges[${index}].from`, `Edge references undeclared node "${from}".`);
+ if (to && !nodeIds.has(to)) add(issues, 'chart.edge.unknown_node', `$.edges[${index}].to`, `Edge references undeclared node "${to}".`);
+ });
+ }
+ }
+ }
+
+ if (root.colors !== undefined) {
+ if (!Array.isArray(root.colors) || root.colors.length < 1 || root.colors.length > MAX_CHART_COLORS) {
+ add(issues, 'chart.colors.invalid', '$.colors', `colors must be an array of 1 to ${MAX_CHART_COLORS} literal CSS hex values.`);
+ } else {
+ root.colors.forEach((color, index) => {
+ if (typeof color !== 'string' || !CSS_HEX.test(color)) {
+ add(issues, 'chart.color.invalid', `$.colors[${index}]`, 'Expected a literal CSS hex colour such as #059669.');
+ }
+ });
+ }
+ }
+ if (root.maxCategories !== undefined
+ && (typeof root.maxCategories !== 'number' || !Number.isInteger(root.maxCategories) || root.maxCategories < 1 || root.maxCategories > 200)) {
+ add(issues, 'chart.maxCategories.invalid', '$.maxCategories', 'maxCategories must be an integer between 1 and 200.');
+ }
+ if (root.height !== undefined
+ && (typeof root.height !== 'number' || !Number.isFinite(root.height) || root.height <= 0 || root.height > 4096)) {
+ add(issues, 'chart.height.invalid', '$.height', 'height must be a positive number of pixels.');
+ }
+ return freezeIssues(issues);
+}
+
+function validateSemanticBlock(
+ candidate: unknown,
+ path: string,
+ ids: Set,
+ issues: AdminSurfaceIssue[],
+ fieldsRequired: boolean,
+): MutableRecord | undefined {
+ const block = record(candidate, path, issues);
+ if (!block) return undefined;
+ const id = token(block.id, ID, `${path}.id`, 'block.id.invalid', issues);
+ if (id && ids.has(id)) add(issues, 'block.id.duplicate', `${path}.id`, 'Block ids must be unique.');
+ if (id) ids.add(id);
+ text(block.title, `${path}.title`, 'block.title.invalid', issues);
+ const helper = record(block.helper, `${path}.helper`, issues);
+ if (helper) {
+ for (const key of ['summary', 'defaultSemantics', 'precedence', 'effect'] as const) {
+ text(helper[key], `${path}.helper.${key}`, `block.helper.${key}.invalid`, issues);
+ }
+ }
+ if (fieldsRequired && (!Array.isArray(block.fields) || block.fields.length < 1)) {
+ add(issues, 'block.fields.invalid', `${path}.fields`, 'A field block requires at least one field.');
+ }
+ return block;
+}
+
+function validateField(
+ candidate: unknown,
+ path: string,
+ keys: Set,
+ issues: AdminSurfaceIssue[],
+): MutableRecord | undefined {
+ const field = record(candidate, path, issues);
+ if (!field) return undefined;
+ const key = token(field.key, FIELD_KEY, `${path}.key`, 'field.key.invalid', issues);
+ const label = text(field.label, `${path}.label`, 'field.label.invalid', issues);
+ if (key && keys.has(key)) add(issues, 'field.key.duplicate', `${path}.key`, 'Field keys must be unique within a block.');
+ if (key) keys.add(key);
+ if (typeof field.kind !== 'string' || !FIELD_KINDS.has(field.kind)) {
+ add(issues, 'field.kind.invalid', `${path}.kind`, 'Unknown admin field kind.');
+ return field;
+ }
+ const semantic = `${key ?? ''} ${label ?? ''}`;
+ if ((field.kind === 'text' || field.kind === 'nullable-text') && SEMANTIC_LOCALE.test(semantic)) {
+ add(issues, 'field.locale.text_forbidden', `${path}.kind`, 'Locale and language fields must use the shared locale kind.');
+ }
+ if ((field.kind === 'text' || field.kind === 'nullable-text') && SEMANTIC_COLOR.test(semantic)) {
+ add(issues, 'field.color.text_forbidden', `${path}.kind`, 'Colour fields must use the shared color kind.');
+ }
+ if (field.kind === 'locale' && field.requiredCapability !== undefined) {
+ token(field.requiredCapability, CAPABILITY, `${path}.requiredCapability`, 'field.locale.capability.invalid', issues);
+ }
+ if (field.kind !== 'locale' && field.allowSystem !== undefined) {
+ add(issues, 'field.locale.system_forbidden', `${path}.allowSystem`, 'Only locale fields can allow the phone-system option.');
+ }
+ if (field.kind === 'color' && field.wireFormat !== HEX_RGB_WIRE_FORMAT) {
+ add(issues, 'field.color.format.invalid', `${path}.wireFormat`, `Expected ${HEX_RGB_WIRE_FORMAT}.`);
+ }
+ if (field.kind === 'select' && (!Array.isArray(field.options) || field.options.length < 1
+ || field.options.some((option) => typeof option !== 'string'))) {
+ add(issues, 'field.select.options.invalid', `${path}.options`, 'Select fields require string options.');
+ }
+ return field;
+}
+
+function record(value: unknown, path: string, issues: AdminSurfaceIssue[]): MutableRecord | undefined {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ add(issues, 'object.required', path, 'Expected an object.');
+ return undefined;
+ }
+ return value as MutableRecord;
+}
+
+function token(
+ value: unknown,
+ pattern: RegExp,
+ path: string,
+ code: string,
+ issues: AdminSurfaceIssue[],
+): string | undefined {
+ if (typeof value !== 'string' || !pattern.test(value)) {
+ add(issues, code, path, 'Expected a canonical identifier.');
+ return undefined;
+ }
+ return value;
+}
+
+function text(
+ value: unknown,
+ path: string,
+ code: string,
+ issues: AdminSurfaceIssue[],
+): string | undefined {
+ if (typeof value !== 'string' || value.trim() !== value || value.length < 1 || value.length > 512) {
+ add(issues, code, path, 'Expected a non-empty trimmed string.');
+ return undefined;
+ }
+ return value;
+}
+
+function add(issues: AdminSurfaceIssue[], code: string, path: string, message: string): void {
+ issues.push(Object.freeze({ code, path, message }));
+}
+
+function freezeIssues(issues: AdminSurfaceIssue[]): readonly AdminSurfaceIssue[] {
+ return Object.freeze(issues.map((issue) => Object.freeze({ ...issue })));
+}
+
+function deepFreeze(value: T): T {
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
+ for (const child of Object.values(value as Record)) deepFreeze(child);
+ Object.freeze(value);
+ }
+ return value;
+}
diff --git a/packages/admin-surface/templates/admin-surface.ts.template b/packages/admin-surface/templates/admin-surface.ts.template
new file mode 100644
index 00000000..d768f8da
--- /dev/null
+++ b/packages/admin-surface/templates/admin-surface.ts.template
@@ -0,0 +1,25 @@
+import {
+ ADMIN_SURFACE_SCHEMA,
+ defineAdminSurface,
+} from '@ariada-org/admin-surface';
+
+export const exampleAdminSurface = defineAdminSurface({
+ schemaVersion: ADMIN_SURFACE_SCHEMA,
+ id: 'product.settings',
+ title: 'Product settings',
+ localeRegistryId: 'product.language-support',
+ blocks: [{
+ id: 'presentation',
+ title: 'Presentation',
+ helper: {
+ summary: 'Explain what this block owns.',
+ defaultSemantics: 'Explain when the default is used.',
+ precedence: 'Explain which narrower scopes replace this value.',
+ effect: 'Explain the observable application effect.',
+ },
+ fields: [
+ { key: 'locale', label: 'Locale', kind: 'locale' },
+ { key: 'accentHex', label: 'Accent colour', kind: 'color', wireFormat: 'RRGGBB' },
+ ],
+ }],
+});
diff --git a/packages/admin-surface/tsconfig.build.json b/packages/admin-surface/tsconfig.build.json
new file mode 100644
index 00000000..dca5799d
--- /dev/null
+++ b/packages/admin-surface/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["src/**/*.test.ts"]
+}
diff --git a/packages/admin-surface/tsconfig.json b/packages/admin-surface/tsconfig.json
new file mode 100644
index 00000000..7e679c75
--- /dev/null
+++ b/packages/admin-surface/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": true,
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["src/**/*.test.ts"]
+}
diff --git a/packages/admin-svelte/.gitignore b/packages/admin-svelte/.gitignore
new file mode 100644
index 00000000..c2658d7d
--- /dev/null
+++ b/packages/admin-svelte/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/packages/admin-svelte/README.md b/packages/admin-svelte/README.md
new file mode 100644
index 00000000..699a52d5
--- /dev/null
+++ b/packages/admin-svelte/README.md
@@ -0,0 +1,183 @@
+# `@ariada-org/admin-svelte`
+
+The Svelte 5 render layer for `@ariada-org/admin-surface` contracts, and the twin of
+`@ariada-org/admin-ui` (React + Ant Design).
+
+**One contract, two renderers.** A board declares an `AdminGridSurface`, an
+`OperatorDashboardProfile` and an `AdminChartSpec` in
+`@ariada-org/admin-surface` — pure data, no framework. Projectology renders those
+declarations through `@ariada-org/admin-ui` (React is load-bearing there:
+`@lexical/react`, `react-arborist`); a Svelte consumer renders the same
+declarations through this package. Neither renderer owns the contract, and a
+board never knows which one is drawing it.
+
+> **Status: no consumer yet.** This package was built ahead of the surface that
+> will use it — `klarads-app` currently declares only `@ariada-org/admin-surface`,
+> and the FAP.NU operator dashboard renders through `@ariada-org/admin-ui` (React).
+> The sentence above describes the intended architecture, not the current wiring.
+> It is stated here because a README that reads as though the migration already
+> happened is how the next agent concludes a job is done that nobody has started.
+>
+> **Intended first consumer:** a Svelte admin (Ariada, or the KlarAds admin when
+> it moves off React). Start from `@ariada-org/admin-surface` for the contract and
+> render through this package — see the proof-of-portability note below.
+
+- **No Ant Design, no React, no chart library, no icon library.**
+- **Zero runtime dependencies.** `svelte` and `ag-grid-community` are peers.
+- **No Tailwind.** `tokens.css` is plain CSS custom properties. A consumer may
+ use Tailwind; it is never required.
+- AG Grid ships no official Svelte wrapper, so the grid runs on the
+ framework-neutral `createGrid` API with vanilla DOM cell renderers.
+
+## Install
+
+```jsonc
+// package.json
+{
+ "dependencies": {
+ "@ariada-org/admin-surface": "workspace:*",
+ "@ariada-org/admin-svelte": "workspace:*",
+ "ag-grid-community": "^36.0.2"
+ }
+}
+```
+
+## Usage
+
+```svelte
+
+
+
+
+ save(row)}
+/>
+```
+
+Dark scheme: put `data-adm-scheme="dark"` on `` (or any ancestor) and pass
+`scheme="dark"` to `AdminGrid` so the grid theme follows the tokens.
+
+## What the components do
+
+### `AdminGrid.svelte`
+
+Turns a surface + profile + rows into a premium AG Grid. Everything is driven by
+CONTRACT fields — `renderer`, `kind`, `colorRamp`, `help`, `rowActions`,
+`terminology`, `sort`, `density`, `accent` — and never by a column name, so no
+board can be special-cased.
+
+| Contract | Rendered as |
+|---|---|
+| `renderer: 'status-dot'` | severity dot + name (a link when the row carries a `url`) |
+| `renderer: 'tag'` | a coloured chip |
+| `renderer: 'bar'` | track + ramp-coloured fill + value |
+| `renderer: 'ramp'` | a ramp chip — ratio (`kind: 'ratio'`), low-is-good percent (`colorRamp.good: 'low'`) or signed count |
+| `kind` (no renderer) | `count` / `percent` / `currency` / `duration` formatting |
+| `help` | an ⓘ header popover: description + formula + wiki link |
+| `rowActions` × `profile.actions` | a pinned column of icon buttons, each behind an anchored confirm popover with a reason field |
+
+Props: `surface`, `profile`, `rows`, `accent`, `scheme`, `height`, `wiki`,
+`i18n`, `theme`, `quickFilter`, `detailDrawer`, `onAction`, `onRowClick`,
+`onRowSave`, and a `detail` snippet rendered above the drawer fields.
+
+A quick filter above the grid searches every column and shows a `shown / total`
+counter. Clicking any cell outside the actions column opens the row drawer.
+
+### `RowDetailDrawer.svelte`
+
+Every parameter the surface declares, view + edit, with a `Save` that emits the
+edited row.
+
+Its formatting mirrors the **grid's** precedence: `renderer` wins over `kind`.
+This is not cosmetic. A column declared `kind: 'percent'` that carries a 0–100
+value renders `1%` in the grid (its `ramp` renderer is right) and `100.0%` in any
+drawer that formats from `kind` alone — a real defect the Svelte spike caught,
+and the reason `formatRowValue()` exists. Anything that shows a row's values next
+to the grid — a drawer, a CSV export, a tooltip — must call it.
+
+### `MetricChart.svelte`
+
+Draws an `AdminChartSpec`: `column`, `line`, `funnel`, and `graph` (a
+relationship map of `nodes` + `edges` laid out on a circle). Inline SVG with
+gradient fills, a grow-in animation, a hover crosshair band and a tooltip. The
+spec is the stable seam — a heavier charting backend can swap in behind it
+without touching a single board.
+
+### `tokens.css`
+
+One stylesheet: colour / radius / shadow / motion tokens, the primitives the
+components use (`.adm-card`, `.adm-btn`, `.adm-input`, `.adm-icon-btn`,
+`.adm-seg`, …), the classes the vanilla AG Grid renderers emit, the motion
+keyframes, and the dark scheme. Every custom property is namespaced `--adm-*`,
+so overriding one re-themes the surface without colliding with the consumer's
+own design tokens.
+
+Motion was measured from the Ant Design reference build (`0.2s
+cubic-bezier(.645,.045,.355,1)`) and then extended: drawer slide, popover pop,
+staggered entrance, hover elevation — all disabled under
+`prefers-reduced-motion`.
+
+## Also exported (framework-neutral)
+
+```ts
+import {
+ buildAdminColumnDefs, resolveRowActions, isActionDisabled, ACTIONS_COLUMN_ID,
+ formatRowValue, formatByKind, rampColor, rampContent, rampVariant, barContent,
+ statusColor, tagColor, wikiHref, rowLabel, ADMIN_GRID_ACTION_EFFECT,
+ plotLayout, graphLayout, chartColor, createAdminGridTheme, resolveI18n,
+} from '@ariada-org/admin-svelte';
+```
+
+`i18n` defaults to English; pass your own strings. No product copy lives in this
+package.
+
+## Verify
+
+```bash
+pnpm --filter @ariada-org/admin-svelte typecheck # tsc + svelte-check
+pnpm --filter @ariada-org/admin-svelte test # vitest
+pnpm --filter @ariada-org/admin-svelte build # tsc -> dist
+```
+
+77 tests, in three layers:
+
+1. **Pure helpers** — colour ramp, value formatting and its renderer-over-kind
+ precedence, column-def building from a contract, chart geometry.
+2. **Server render** — every component is rendered through `svelte/server` and
+ asserted on real markup (bars per series, funnel conversion labels, graph
+ nodes and edges, and the drawer printing `1%` rather than `100.0%`).
+3. **Structural guards** — every `.svelte` file compiles for client and server
+ with zero warnings; the import graph contains nothing but the contract, AG
+ Grid and Svelte; the stylesheet has no Tailwind directive; no product name
+ appears in the render layer.
+
+The suite does **not** mount components or dispatch events — this repo has no DOM
+test environment installed (jsdom / happy-dom / `@testing-library/svelte`).
+Interaction paths (the confirm popover, drawer editing, the hover crosshair)
+belong to the consuming app's Playwright suite.
diff --git a/packages/admin-svelte/package.json b/packages/admin-svelte/package.json
new file mode 100644
index 00000000..b94d9adc
--- /dev/null
+++ b/packages/admin-svelte/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "@ariada-org/admin-svelte",
+ "version": "0.1.0",
+ "description": "Shared Svelte 5 render layer for Agonist admin surfaces: contract-driven AG Grid, declarative charts, design tokens. The Svelte twin of @ariada-org/admin-ui.",
+ "license": "EUPL-1.2",
+ "author": "Agonist Development AB",
+ "keywords": [
+ "design-system",
+ "svelte",
+ "ag-grid",
+ "admin-ui",
+ "agonist"
+ ],
+ "type": "module",
+ "sideEffects": [
+ "*.css"
+ ],
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "svelte": "./src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ },
+ "./AdminGrid.svelte": {
+ "svelte": "./src/AdminGrid.svelte",
+ "default": "./src/AdminGrid.svelte"
+ },
+ "./MetricChart.svelte": {
+ "svelte": "./src/MetricChart.svelte",
+ "default": "./src/MetricChart.svelte"
+ },
+ "./RowDetailDrawer.svelte": {
+ "svelte": "./src/RowDetailDrawer.svelte",
+ "default": "./src/RowDetailDrawer.svelte"
+ },
+ "./tokens.css": "./src/tokens.css",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "dist",
+ "src",
+ "README.md"
+ ],
+ "peerDependencies": {
+ "@ariada-org/admin-surface": ">=0.1.0",
+ "ag-grid-community": ">=36",
+ "svelte": ">=5"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json",
+ "test": "vitest run src",
+ "typecheck": "tsc --noEmit -p tsconfig.json && svelte-check --tsconfig ./tsconfig.json --threshold error"
+ },
+ "devDependencies": {
+ "@ariada-org/admin-surface": "file:../admin-surface",
+ "@sveltejs/vite-plugin-svelte": "^4.0.4",
+ "ag-grid-community": "^36.0.2",
+ "svelte": "^5.1.0",
+ "svelte-check": "^4.4.6",
+ "typescript": "^5.8.3",
+ "vite": "^5.4.21",
+ "vitest": "^2.1.9"
+ }
+}
diff --git a/packages/admin-svelte/src/AdminGrid.svelte b/packages/admin-svelte/src/AdminGrid.svelte
new file mode 100644
index 00000000..a71d47e9
--- /dev/null
+++ b/packages/admin-svelte/src/AdminGrid.svelte
@@ -0,0 +1,234 @@
+
+
+