From 6d886947f2ec69c4f0dedbe3c960bf14612a7469 Mon Sep 17 00:00:00 2001 From: "Alexander Brichkin (Agonist Development AB)" Date: Thu, 30 Jul 2026 13:40:27 +0200 Subject: [PATCH] feat(console): add the Ariada accessibility audit console (OSS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SvelteKit (adapter-static) operator console for managing accessibility audits across projects — the whole GNU Taler ecosystem is the first project. Self-contained: depends only on its own vendored kit — @ariada-org/admin-surface (a framework-neutral surface/grid/profile contract) and @ariada-org/admin-svelte (a Svelte 5 renderer over ag-grid, zero runtime deps) — plus ag-grid-community. No external UI framework, no shared/private dependency. Live demo: app.ariada.org. Signed-off-by: Alexander Brichkin (Agonist Development AB) --- apps/ariada-console/.gitignore | 3 + apps/ariada-console/package.json | 27 + apps/ariada-console/pnpm-lock.yaml | 1084 +++++++++++++++++ apps/ariada-console/src/app.d.ts | 6 + apps/ariada-console/src/app.html | 13 + apps/ariada-console/src/lib/boards/audits.ts | 77 ++ apps/ariada-console/src/lib/projects/taler.ts | 84 ++ apps/ariada-console/src/routes/+layout.svelte | 60 + apps/ariada-console/src/routes/+layout.ts | 4 + apps/ariada-console/src/routes/+page.svelte | 52 + apps/ariada-console/src/routes/app.css | 24 + .../src/routes/r/[token]/+page.svelte | 38 + .../src/routes/subject/+page.svelte | 39 + apps/ariada-console/svelte.config.js | 15 + apps/ariada-console/tsconfig.json | 14 + apps/ariada-console/vite.config.ts | 9 + packages/admin-surface/.gitignore | 1 + packages/admin-surface/README.md | 59 + packages/admin-surface/package.json | 31 + packages/admin-surface/src/chart.test.ts | 132 ++ packages/admin-surface/src/grid.test.ts | 121 ++ packages/admin-surface/src/index.test.ts | 100 ++ packages/admin-surface/src/index.ts | 847 +++++++++++++ .../templates/admin-surface.ts.template | 25 + packages/admin-surface/tsconfig.build.json | 11 + packages/admin-surface/tsconfig.json | 18 + packages/admin-svelte/.gitignore | 1 + packages/admin-svelte/README.md | 183 +++ packages/admin-svelte/package.json | 67 + packages/admin-svelte/src/AdminGrid.svelte | 234 ++++ packages/admin-svelte/src/MetricChart.svelte | 170 +++ .../admin-svelte/src/RowDetailDrawer.svelte | 133 ++ packages/admin-svelte/src/chart.test.ts | 173 +++ packages/admin-svelte/src/chart.ts | 235 ++++ packages/admin-svelte/src/components.test.ts | 96 ++ packages/admin-svelte/src/format.test.ts | 158 +++ packages/admin-svelte/src/format.ts | 209 ++++ packages/admin-svelte/src/i18n.ts | 54 + packages/admin-svelte/src/icons.ts | 30 + packages/admin-svelte/src/index.ts | 85 ++ packages/admin-svelte/src/renderers.test.ts | 163 +++ packages/admin-svelte/src/renderers.ts | 415 +++++++ packages/admin-svelte/src/ssr.test.ts | 178 +++ packages/admin-svelte/src/theme.ts | 57 + packages/admin-svelte/src/tokens.css | 537 ++++++++ packages/admin-svelte/tsconfig.build.json | 15 + packages/admin-svelte/tsconfig.json | 29 + packages/admin-svelte/vitest.config.ts | 15 + 48 files changed, 6131 insertions(+) create mode 100644 apps/ariada-console/.gitignore create mode 100644 apps/ariada-console/package.json create mode 100644 apps/ariada-console/pnpm-lock.yaml create mode 100644 apps/ariada-console/src/app.d.ts create mode 100644 apps/ariada-console/src/app.html create mode 100644 apps/ariada-console/src/lib/boards/audits.ts create mode 100644 apps/ariada-console/src/lib/projects/taler.ts create mode 100644 apps/ariada-console/src/routes/+layout.svelte create mode 100644 apps/ariada-console/src/routes/+layout.ts create mode 100644 apps/ariada-console/src/routes/+page.svelte create mode 100644 apps/ariada-console/src/routes/app.css create mode 100644 apps/ariada-console/src/routes/r/[token]/+page.svelte create mode 100644 apps/ariada-console/src/routes/subject/+page.svelte create mode 100644 apps/ariada-console/svelte.config.js create mode 100644 apps/ariada-console/tsconfig.json create mode 100644 apps/ariada-console/vite.config.ts create mode 100644 packages/admin-surface/.gitignore create mode 100644 packages/admin-surface/README.md create mode 100644 packages/admin-surface/package.json create mode 100644 packages/admin-surface/src/chart.test.ts create mode 100644 packages/admin-surface/src/grid.test.ts create mode 100644 packages/admin-surface/src/index.test.ts create mode 100644 packages/admin-surface/src/index.ts create mode 100644 packages/admin-surface/templates/admin-surface.ts.template create mode 100644 packages/admin-surface/tsconfig.build.json create mode 100644 packages/admin-surface/tsconfig.json create mode 100644 packages/admin-svelte/.gitignore create mode 100644 packages/admin-svelte/README.md create mode 100644 packages/admin-svelte/package.json create mode 100644 packages/admin-svelte/src/AdminGrid.svelte create mode 100644 packages/admin-svelte/src/MetricChart.svelte create mode 100644 packages/admin-svelte/src/RowDetailDrawer.svelte create mode 100644 packages/admin-svelte/src/chart.test.ts create mode 100644 packages/admin-svelte/src/chart.ts create mode 100644 packages/admin-svelte/src/components.test.ts create mode 100644 packages/admin-svelte/src/format.test.ts create mode 100644 packages/admin-svelte/src/format.ts create mode 100644 packages/admin-svelte/src/i18n.ts create mode 100644 packages/admin-svelte/src/icons.ts create mode 100644 packages/admin-svelte/src/index.ts create mode 100644 packages/admin-svelte/src/renderers.test.ts create mode 100644 packages/admin-svelte/src/renderers.ts create mode 100644 packages/admin-svelte/src/ssr.test.ts create mode 100644 packages/admin-svelte/src/theme.ts create mode 100644 packages/admin-svelte/src/tokens.css create mode 100644 packages/admin-svelte/tsconfig.build.json create mode 100644 packages/admin-svelte/tsconfig.json create mode 100644 packages/admin-svelte/vitest.config.ts diff --git a/apps/ariada-console/.gitignore b/apps/ariada-console/.gitignore new file mode 100644 index 00000000..18c6b9ed --- /dev/null +++ b/apps/ariada-console/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +.svelte-kit/ diff --git a/apps/ariada-console/package.json b/apps/ariada-console/package.json new file mode 100644 index 00000000..6dec044f --- /dev/null +++ b/apps/ariada-console/package.json @@ -0,0 +1,27 @@ +{ + "name": "@ariada-org/ariada-console", + "private": true, + "version": "0.1.0", + "type": "module", + "license": "EUPL-1.2", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@ariada-org/admin-surface": "file:../../packages/admin-surface", + "@ariada-org/admin-svelte": "file:../../packages/admin-svelte", + "ag-grid-community": "^36.0.2" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.22.0", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/apps/ariada-console/pnpm-lock.yaml b/apps/ariada-console/pnpm-lock.yaml new file mode 100644 index 00000000..04b2edb4 --- /dev/null +++ b/apps/ariada-console/pnpm-lock.yaml @@ -0,0 +1,1084 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ariada-org/admin-surface': + specifier: file:../../packages/admin-surface + version: file:../../packages/admin-surface + '@ariada-org/admin-svelte': + specifier: file:../../packages/admin-svelte + version: file:../../packages/admin-svelte(@ariada-org/admin-surface@file:../../packages/admin-surface)(ag-grid-community@36.0.2)(svelte@5.56.8) + ag-grid-community: + specifier: ^36.0.2 + version: 36.0.2 + devDependencies: + '@sveltejs/adapter-static': + specifier: ^3.0.8 + version: 3.0.10(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.8)(vite@6.4.3))(svelte@5.56.8)(typescript@5.9.3)(vite@6.4.3)) + '@sveltejs/kit': + specifier: ^2.22.0 + version: 2.70.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.8)(vite@6.4.3))(svelte@5.56.8)(typescript@5.9.3)(vite@6.4.3) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.3 + version: 5.1.1(svelte@5.56.8)(vite@6.4.3) + svelte: + specifier: ^5.19.0 + version: 5.56.8 + svelte-check: + specifier: ^4.1.4 + version: 4.7.4(picomatch@4.0.5)(svelte@5.56.8)(typescript@5.9.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.3 + +packages: + + '@ariada-org/admin-surface@file:../../packages/admin-surface': + resolution: {directory: ../../packages/admin-surface, type: directory} + + '@ariada-org/admin-svelte@file:../../packages/admin-svelte': + resolution: {directory: ../../packages/admin-svelte, type: directory} + peerDependencies: + '@ariada-org/admin-surface': '>=0.1.0' + ag-grid-community: '>=36' + svelte: '>=5' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/rollup-android-arm-eabi@4.62.3': + resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.3': + resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.3': + resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.3': + resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.3': + resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.3': + resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.3': + resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.3': + resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.3': + resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.3': + resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.3': + resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.3': + resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.3': + resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.3': + resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.11': + resolution: {integrity: sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-static@3.0.10': + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.70.2': + resolution: {integrity: sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 || ^6.0.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/load-config@0.2.1': + resolution: {integrity: sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ag-charts-types@14.0.2: + resolution: {integrity: sha512-F7ZG0g8Y+iKhJi50AfZRwEyUM/TBsNyh2IoXB0JaDN97lnbemIK8GE5kF1eBtXtN4mcC+lPXK9oZUeVXwO9EWA==} + + ag-grid-community@36.0.2: + resolution: {integrity: sha512-TINZfuFvMY2nc3JfQHiUWT7dNIxI89ZxS5XkXIPi/rYICoNupRqpaM41KVzGPPfSkM0AwhuzTFxAiF08zEkV1Q==} + + ag-stack@36.0.2: + resolution: {integrity: sha512-YuhQExQw5YsWK0wxrksRyYBAqOU0v08lJH5uxRsKx+49ko5vkDgnJuhX4yF995BBVdLY1LKlXukLEub+olKyuA==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + devalue@5.8.2: + resolution: {integrity: sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.3.0: + resolution: {integrity: sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.62.3: + resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + svelte-check@4.7.4: + resolution: {integrity: sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte@5.56.8: + resolution: {integrity: sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@ariada-org/admin-surface@file:../../packages/admin-surface': {} + + '@ariada-org/admin-svelte@file:../../packages/admin-svelte(@ariada-org/admin-surface@file:../../packages/admin-surface)(ag-grid-community@36.0.2)(svelte@5.56.8)': + dependencies: + '@ariada-org/admin-surface': file:../../packages/admin-surface + ag-grid-community: 36.0.2 + svelte: 5.56.8 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@polka/url@1.0.0-next.29': {} + + '@rollup/rollup-android-arm-eabi@4.62.3': + optional: true + + '@rollup/rollup-android-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-x64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.3': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.11(acorn@8.18.0)': + dependencies: + acorn: 8.18.0 + + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.8)(vite@6.4.3))(svelte@5.56.8)(typescript@5.9.3)(vite@6.4.3))': + dependencies: + '@sveltejs/kit': 2.70.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.8)(vite@6.4.3))(svelte@5.56.8)(typescript@5.9.3)(vite@6.4.3) + + '@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.8)(vite@6.4.3))(svelte@5.56.8)(typescript@5.9.3)(vite@6.4.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.11(acorn@8.18.0) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.56.8)(vite@6.4.3) + '@types/cookie': 0.6.0 + acorn: 8.18.0 + cookie: 0.6.0 + devalue: 5.8.2 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.1.2 + sirv: 3.0.2 + svelte: 5.56.8 + vite: 6.4.3 + optionalDependencies: + typescript: 5.9.3 + + '@sveltejs/load-config@0.2.1': {} + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.8)(vite@6.4.3))(svelte@5.56.8)(vite@6.4.3)': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.56.8)(vite@6.4.3) + debug: 4.4.3 + svelte: 5.56.8 + vite: 6.4.3 + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.8)(vite@6.4.3)': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.8)(vite@6.4.3))(svelte@5.56.8)(vite@6.4.3) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.56.8 + vite: 6.4.3 + vitefu: 1.1.3(vite@6.4.3) + transitivePeerDependencies: + - supports-color + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.9': {} + + '@types/trusted-types@2.0.7': {} + + acorn@8.18.0: {} + + ag-charts-types@14.0.2: {} + + ag-grid-community@36.0.2: + dependencies: + ag-charts-types: 14.0.2 + ag-stack: 36.0.2 + + ag-stack@36.0.2: {} + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + cookie@0.6.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge@4.3.1: {} + + devalue@5.8.2: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esm-env@1.2.2: {} + + esrap@2.3.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + kleur@4.1.5: {} + + locate-character@3.0.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + rollup@4.62.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.3 + '@rollup/rollup-android-arm64': 4.62.3 + '@rollup/rollup-darwin-arm64': 4.62.3 + '@rollup/rollup-darwin-x64': 4.62.3 + '@rollup/rollup-freebsd-arm64': 4.62.3 + '@rollup/rollup-freebsd-x64': 4.62.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 + '@rollup/rollup-linux-arm-musleabihf': 4.62.3 + '@rollup/rollup-linux-arm64-gnu': 4.62.3 + '@rollup/rollup-linux-arm64-musl': 4.62.3 + '@rollup/rollup-linux-loong64-gnu': 4.62.3 + '@rollup/rollup-linux-loong64-musl': 4.62.3 + '@rollup/rollup-linux-ppc64-gnu': 4.62.3 + '@rollup/rollup-linux-ppc64-musl': 4.62.3 + '@rollup/rollup-linux-riscv64-gnu': 4.62.3 + '@rollup/rollup-linux-riscv64-musl': 4.62.3 + '@rollup/rollup-linux-s390x-gnu': 4.62.3 + '@rollup/rollup-linux-x64-gnu': 4.62.3 + '@rollup/rollup-linux-x64-musl': 4.62.3 + '@rollup/rollup-openbsd-x64': 4.62.3 + '@rollup/rollup-openharmony-arm64': 4.62.3 + '@rollup/rollup-win32-arm64-msvc': 4.62.3 + '@rollup/rollup-win32-ia32-msvc': 4.62.3 + '@rollup/rollup-win32-x64-gnu': 4.62.3 + '@rollup/rollup-win32-x64-msvc': 4.62.3 + fsevents: 2.3.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + set-cookie-parser@3.1.2: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + svelte-check@4.7.4(picomatch@4.0.5)(svelte@5.56.8)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.2.1 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.56.8 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte@5.56.8: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.11(acorn@8.18.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.18.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.2 + esm-env: 1.2.2 + esrap: 2.3.0 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + totalist@3.0.1: {} + + typescript@5.9.3: {} + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.25 + rollup: 4.62.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitefu@1.1.3(vite@6.4.3): + optionalDependencies: + vite: 6.4.3 + + zimmerframe@1.1.4: {} diff --git a/apps/ariada-console/src/app.d.ts b/apps/ariada-console/src/app.d.ts new file mode 100644 index 00000000..af4d7837 --- /dev/null +++ b/apps/ariada-console/src/app.d.ts @@ -0,0 +1,6 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +declare global { + namespace App {} +} + +export {}; diff --git a/apps/ariada-console/src/app.html b/apps/ariada-console/src/app.html new file mode 100644 index 00000000..42f82ece --- /dev/null +++ b/apps/ariada-console/src/app.html @@ -0,0 +1,13 @@ + + + + + + + Ariada — accessibility audit console + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/ariada-console/src/lib/boards/audits.ts b/apps/ariada-console/src/lib/boards/audits.ts new file mode 100644 index 00000000..e3a7d340 --- /dev/null +++ b/apps/ariada-console/src/lib/boards/audits.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 Agonist Development AB +// SPDX-License-Identifier: EUPL-1.2 +// +// The audit-management board — the heart of the Ariada admin, declared as DATA +// on the framework-neutral @ariada-org/admin-surface contract (rendered by +// @ariada-org/admin-svelte). One grid over a project's resources: every row is a +// scanned surface with its accessibility score, findings, Clamper gate state, +// and Reverter remediation state. Row actions drive the loop — run a scan, open +// the report, open the subject live in the Chrome plugin (before/after), or ask +// Reverter for a remediated version. No per-project code: a project just +// supplies rows; a profile re-orders columns/actions per audience. +import { + defineAdminGridSurface, + defineOperatorDashboardProfile, +} from '@ariada-org/admin-surface'; + +export const AUDITS_BOARD = defineAdminGridSurface({ + schemaVersion: 'ariada-org.admin-grid/v1', + id: 'ariada.audits', + title: 'Accessibility audits', + rowKey: 'id', + defaultSort: { key: 'score', dir: 'asc' }, // worst first + columns: [ + { key: 'label', label: 'Resource', kind: 'text', pin: 'left', width: 260, + help: { description: 'The scanned surface (a page in the project).', wikiSlug: 'audits', wikiAnchor: 'resource' } }, + { key: 'group', label: 'Group', kind: 'enum', renderer: 'tag', + help: { description: 'core / bank / partner / grantee-module.', wikiSlug: 'audits', wikiAnchor: 'group' } }, + { key: 'score', label: 'Score', kind: 'score', align: 'right', renderer: 'ramp', colorRamp: { good: 'high' }, + help: { description: '1–10 accessibility score; critical defects dominate.', formula: 'see scoring model', wikiSlug: 'audits', wikiAnchor: 'score' } }, + { key: 'critical', label: 'Critical', kind: 'count', align: 'right', renderer: 'bar', colorRamp: { good: 'low' }, + help: { description: 'Defects that block a user completely.', wikiSlug: 'audits', wikiAnchor: 'critical' } }, + { key: 'serious', label: 'Serious', kind: 'count', align: 'right', + help: { description: 'Defects that seriously degrade the experience.', wikiSlug: 'audits', wikiAnchor: 'serious' } }, + { key: 'findings', label: 'Findings', kind: 'count', align: 'right', + help: { description: 'Total findings on this resource.', wikiSlug: 'audits', wikiAnchor: 'findings' } }, + { key: 'blastRadius', label: 'Blast radius', kind: 'count', align: 'right', + help: { description: 'Pages amplified by one shared node (ACCE whole-codebase analysis).', wikiSlug: 'audits', wikiAnchor: 'blast-radius' } }, + { key: 'gate', label: 'Gate', kind: 'enum', renderer: 'status-dot', + help: { description: 'Clamper CI gate — pass / blocked (new-vs-baseline).', wikiSlug: 'audits', wikiAnchor: 'gate' } }, + { key: 'reverter', label: 'Reverter', kind: 'enum', renderer: 'tag', + help: { description: 'Remediation state — none / recommended / remediated.', wikiSlug: 'audits', wikiAnchor: 'reverter' } }, + ], + rowActions: [ + { key: 'scan', label: 'Run scan', confirm: { reasonRequired: false }, endpoint: '/api/scan' }, + { key: 'report', label: 'Open report', confirm: { reasonRequired: false }, endpoint: '/api/report' }, + { key: 'plugin', label: 'Open live in plugin', confirm: { reasonRequired: false }, endpoint: '/api/plugin/open' }, + { key: 'remediate', label: 'Reverter remediate', confirm: { title: 'Generate a remediated version?', reasonRequired: false }, endpoint: '/api/reverter' }, + ], +} as const); + +// Two profiles over the same grid — content/order only, never a skin. +export const AUDIT_PROFILES = [ + defineOperatorDashboardProfile( + { + schemaVersion: 'ariada-org.operator-dashboard-profile/v1', + id: 'triage', + label: 'Triage (worst first)', + columns: ['label', 'group', 'score', 'critical', 'serious', 'blastRadius', 'gate', 'reverter'], + actions: ['scan', 'report', 'plugin', 'remediate'], + sort: { key: 'score', dir: 'asc' }, + density: 'comfortable', + }, + AUDITS_BOARD, + ), + defineOperatorDashboardProfile( + { + schemaVersion: 'ariada-org.operator-dashboard-profile/v1', + id: 'evidence', + label: 'Evidence (procurement)', + columns: ['label', 'group', 'score', 'findings', 'gate', 'reverter'], + actions: ['report'], + sort: { key: 'label', dir: 'asc' }, + density: 'compact', + }, + AUDITS_BOARD, + ), +] as const; diff --git a/apps/ariada-console/src/lib/projects/taler.ts b/apps/ariada-console/src/lib/projects/taler.ts new file mode 100644 index 00000000..21601f22 --- /dev/null +++ b/apps/ariada-console/src/lib/projects/taler.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 Agonist Development AB +// SPDX-License-Identifier: EUPL-1.2 +// +// The first dogfood PROJECT: the GNU Taler / GNUnet ("никс") ecosystem. One +// selectable entity grouping every resource — core surfaces, the two pilot +// banks, partners, and the grant-funded module landings (see +// grants/TALER_ECOSYSTEM_CATALOG_2026-07-30.md). Rows feed AUDITS_BOARD. +// Metrics (score/findings/…) are populated from the scan API at load; the +// catalog only fixes identity + scan target + group, never fabricated numbers. + +export interface AuditRow { + id: string; + label: string; + url: string; + group: 'core' | 'bank' | 'partner' | 'grantee-module'; + owner: string; + scanStatus: 'scanned' | 'to-scan'; + score: number | null; + critical: number | null; + serious: number | null; + findings: number | null; + blastRadius: number | null; + gate: 'pass' | 'blocked' | 'unknown'; + reverter: 'none' | 'recommended' | 'remediated'; +} + +export interface Project { + id: string; + name: string; + kind: 'dogfood' | 'customer'; + resources: AuditRow[]; +} + +const pending = { score: null, critical: null, serious: null, findings: null, blastRadius: null, gate: 'unknown', reverter: 'none' } as const; + +export const TALER_PROJECT: Project = { + id: 'taler', + name: 'GNU Taler ecosystem', + kind: 'dogfood', + resources: [ + // A. core — already scanned (real counts from the ui-audit run, 2026-07-27) + { id: 'taler-site', label: 'Taler — project site', url: 'https://www.taler.net/en/index.html', group: 'core', owner: 'GNU Taler', scanStatus: 'scanned', score: 9, critical: 0, serious: 3, findings: 5, blastRadius: null, gate: 'blocked', reverter: 'recommended' }, + { id: 'taler-docs', label: 'Taler — documentation', url: 'https://docs.taler.net', group: 'core', owner: 'GNU Taler', scanStatus: 'scanned', score: 8, critical: 0, serious: 13, findings: 14, blastRadius: null, gate: 'blocked', reverter: 'recommended' }, + { id: 'taler-demo', label: 'Taler — demo showcase', url: 'https://demo.taler.net', group: 'core', owner: 'GNU Taler', scanStatus: 'scanned', score: 10, critical: 0, serious: 2, findings: 4, blastRadius: null, gate: 'blocked', reverter: 'recommended' }, + { id: 'taler-shop', label: 'Taler — demo shop', url: 'https://shop.demo.taler.net', group: 'core', owner: 'GNU Taler', scanStatus: 'scanned', score: 10, critical: 0, serious: 2, findings: 4, blastRadius: null, gate: 'blocked', reverter: 'recommended' }, + { id: 'taler-bank', label: 'Taler — demo bank', url: 'https://bank.demo.taler.net', group: 'core', owner: 'GNU Taler', scanStatus: 'scanned', score: 9, critical: 0, serious: 6, findings: 8, blastRadius: null, gate: 'blocked', reverter: 'recommended' }, + { id: 'taler-backend', label: 'Taler — merchant backend (login)', url: 'https://backend.demo.taler.net', group: 'core', owner: 'GNU Taler', scanStatus: 'scanned', score: 1, critical: 6, serious: 5, findings: 17, blastRadius: null, gate: 'blocked', reverter: 'recommended' }, + { id: 'taler-wallet', label: 'Taler wallet — full UI', url: 'https://addons.mozilla.org/firefox/addon/taler-wallet/', group: 'core', owner: 'GNU Taler', scanStatus: 'scanned', score: 9, critical: 0, serious: 4, findings: 8, blastRadius: null, gate: 'blocked', reverter: 'recommended' }, + { id: 'taler-popup', label: 'Taler wallet — confirm popup', url: 'https://addons.mozilla.org/firefox/addon/taler-wallet/', group: 'core', owner: 'GNU Taler', scanStatus: 'scanned', score: 9, critical: 0, serious: 4, findings: 8, blastRadius: null, gate: 'blocked', reverter: 'recommended' }, + // B. pilot banks — to scan (EAA-obligated adopters) + { id: 'gls-bank', label: 'GLS Bank — Taler', url: 'https://www.gls.de/privatkunden/taler', group: 'bank', owner: 'GLS Bank', scanStatus: 'to-scan', ...pending }, + { id: 'magnet-bank', label: 'MagNet Bank', url: 'https://www.magnetbank.hu', group: 'bank', owner: 'MagNet Bank', scanStatus: 'to-scan', ...pending }, + // C. partners — to scan + { id: 'taler-systems', label: 'Taler Systems SA', url: 'https://www.taler-systems.com', group: 'partner', owner: 'Taler Systems SA', scanStatus: 'to-scan', ...pending }, + { id: 'gnunet', label: 'GNUnet e.V.', url: 'https://gnunet.org/en/', group: 'partner', owner: 'GNUnet e.V.', scanStatus: 'to-scan', ...pending }, + { id: 'bfh', label: 'BFH (Bern UAS)', url: 'https://ti.bfh.ch', group: 'partner', owner: 'BFH', scanStatus: 'to-scan', ...pending }, + { id: 'tue', label: 'TU Eindhoven', url: 'https://www.tue.nl', group: 'partner', owner: 'TU/e', scanStatus: 'to-scan', ...pending }, + { id: 'codeblau', label: 'Code Blau GmbH', url: 'https://www.codeblau.de', group: 'partner', owner: 'Code Blau', scanStatus: 'to-scan', ...pending }, + { id: 'visualvest', label: 'VisualVest', url: 'https://www.visualvest.de', group: 'partner', owner: 'VisualVest', scanStatus: 'to-scan', ...pending }, + { id: 'ps-taler', label: 'petites singularités — Taler', url: 'https://ps.lesoiseaux.io/taler', group: 'partner', owner: 'petites singularités', scanStatus: 'to-scan', ...pending }, + { id: 'eseniors', label: 'E-Seniors', url: 'https://www.eseniors.eu', group: 'partner', owner: 'E-Seniors', scanStatus: 'to-scan', ...pending }, + { id: 'homodigitalis', label: 'Homo Digitalis', url: 'https://www.homodigitalis.gr', group: 'partner', owner: 'Homo Digitalis', scanStatus: 'to-scan', ...pending }, + // D. NGI TALER grant-funded module landings ("никс" grantees) — to scan + { id: 'g-lookup', label: 'Wallet ID Lookup Service', url: 'https://nlnet.nl/project/TALER-LookupService', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-oim', label: 'Road Signs for Digital Payments', url: 'https://nlnet.nl/project/TALER-OIM', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-bullion', label: 'TALER Bullion', url: 'https://nlnet.nl/project/TALER-Bullion', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-mte', label: 'MTE (MirageOS Taler Exchange)', url: 'https://nlnet.nl/project/MTE', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-odoo', label: 'Taler-Odoo module', url: 'https://nlnet.nl/project/TALER-Odoo-module', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-tryton', label: 'Tryton / GNUHealth integration', url: 'https://nlnet.nl/project/TALER-Tryton', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-interledger', label: 'Interledger interoperability study', url: 'https://nlnet.nl/project/TALER-Interledger-study', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-obg', label: 'Open Banking Gateway', url: 'https://nlnet.nl/project/TALER-OpenBankingGateway', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-flohmarkt', label: 'Flohmarkt', url: 'https://nlnet.nl/project/TALER-flohmarkt', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-nuxt', label: 'Nuxt/Vue.js payment module', url: 'https://nlnet.nl/project/TALER-integration-Nuxt', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-dolibarr', label: 'Taler-Dolibarr', url: 'https://nlnet.nl/project/Taler-Dolibarr', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-contributron', label: 'Contributron', url: 'https://nlnet.nl/project/Contributron', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-xbsd', label: 'xBSD (Taler on BSD)', url: 'https://nlnet.nl/project/Taler-on-BSD', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-php', label: 'TalerPHP', url: 'https://nlnet.nl/project/TalerPHP', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-ruby', label: 'Libre Payments in Ruby (OFN)', url: 'https://nlnet.nl/project/TALER-Ruby-OFN', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-liberapay', label: 'Taler in Liberapay', url: 'https://nlnet.nl/project/TALER-Liberapay', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + { id: 'g-openapi', label: 'Taler OpenAPI Specification', url: 'https://nlnet.nl/project/TALER-APIs', group: 'grantee-module', owner: 'NGI TALER', scanStatus: 'to-scan', ...pending }, + ], +}; + +export const PROJECTS: Project[] = [TALER_PROJECT]; diff --git a/apps/ariada-console/src/routes/+layout.svelte b/apps/ariada-console/src/routes/+layout.svelte new file mode 100644 index 00000000..0c533e14 --- /dev/null +++ b/apps/ariada-console/src/routes/+layout.svelte @@ -0,0 +1,60 @@ + + +{#if isPublicRoute} + {@render children()} +{:else if !authed} +
+
+

Ariada — accessibility audit console

+

Public demo. Sign in with demo / demo.

+ + + {#if error}{/if} + +
+
+{:else} +
+
+ Ariada + accessibility audit console +
+
{@render children()}
+
+{/if} diff --git a/apps/ariada-console/src/routes/+layout.ts b/apps/ariada-console/src/routes/+layout.ts new file mode 100644 index 00000000..fc3b37cd --- /dev/null +++ b/apps/ariada-console/src/routes/+layout.ts @@ -0,0 +1,4 @@ +// Client-rendered SPA (Cloudflare Pages static). No per-route prerender; the +// adapter emits an index.html fallback that boots the app on any path. +export const ssr = false; +export const prerender = false; diff --git a/apps/ariada-console/src/routes/+page.svelte b/apps/ariada-console/src/routes/+page.svelte new file mode 100644 index 00000000..a61bdae4 --- /dev/null +++ b/apps/ariada-console/src/routes/+page.svelte @@ -0,0 +1,52 @@ + + +
+
+ + +
+ +
+ {project.resources.length} resources + {stat((r) => r.scanStatus === 'scanned')} scanned + {stat((r) => r.scanStatus === 'to-scan')} to scan + {stat((r) => r.group === 'bank')} pilot banks + {stat((r) => r.group === 'grantee-module')} grant modules +
+ + +
diff --git a/apps/ariada-console/src/routes/app.css b/apps/ariada-console/src/routes/app.css new file mode 100644 index 00000000..e2daf73f --- /dev/null +++ b/apps/ariada-console/src/routes/app.css @@ -0,0 +1,24 @@ +:root { color-scheme: light; } +* { box-sizing: border-box; } +body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; color: #1a1a1a; } + +.gate { min-height: 100vh; display: grid; place-items: center; background: #f6f7f8; } +.gate form { background: #fff; padding: 32px 36px; border-radius: 14px; box-shadow: 0 1px 3px rgba(0,0,0,.08); width: min(92vw, 380px); display: grid; gap: 12px; } +.gate h1 { font-size: 20px; margin: 0; } +.gate label { display: grid; gap: 4px; font-size: 13px; color: #595959; } +.gate input { padding: 9px 11px; border: 1px solid rgba(0,0,0,.18); border-radius: 8px; font-size: 15px; } +.gate button { margin-top: 6px; padding: 10px; border: 0; border-radius: 8px; background: #0b5cad; color: #fff; font-weight: 600; cursor: pointer; } +.gate .err { color: #b3261e; font-size: 13px; margin: 0; } + +.shell { min-height: 100vh; } +.topbar { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 24px; border-bottom: 1px solid rgba(0,0,0,.12); } +.brand { font-weight: 700; letter-spacing: -.02em; } +.muted { color: #767676; font-size: 14px; } +.body { padding: 24px; } + +.page { display: grid; gap: 16px; } +.controls { display: flex; gap: 20px; flex-wrap: wrap; } +.controls label { display: grid; gap: 4px; font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: #767676; } +.controls select { padding: 7px 10px; border: 1px solid rgba(0,0,0,.18); border-radius: 8px; font-size: 14px; } +.stats { display: flex; gap: 20px; flex-wrap: wrap; color: #595959; font-size: 14px; } +.stats strong { color: #1a1a1a; font-size: 18px; } diff --git a/apps/ariada-console/src/routes/r/[token]/+page.svelte b/apps/ariada-console/src/routes/r/[token]/+page.svelte new file mode 100644 index 00000000..db0f7df4 --- /dev/null +++ b/apps/ariada-console/src/routes/r/[token]/+page.svelte @@ -0,0 +1,38 @@ + + + + + Ariada accessibility report + + +
+
+ Ariada + accessibility report +
+
+

Report {token}

+ +
+ 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 @@ + + +
+ {#if showQuickFilter} +
+ + + {#if quickFilterText}{shown} / {rows.length}{:else}{rows.length}{/if} + +
+ {/if} +
+
+ +{#if confirmRequest} + + +{/if} + +{#if detailDrawer} + { detailRow = null; }} + {...(i18nOverrides ? { i18n: i18nOverrides } : {})} + {...(onRowSave ? { onSave: saveRow } : {})} + {...(detail ? { detail } : {})} + /> +{/if} diff --git a/packages/admin-svelte/src/MetricChart.svelte b/packages/admin-svelte/src/MetricChart.svelte new file mode 100644 index 00000000..fc654b2c --- /dev/null +++ b/packages/admin-svelte/src/MetricChart.svelte @@ -0,0 +1,170 @@ + + +
+ {#if spec.title || valueKeys.length > 1 || spec.unit} +
+ {#if spec.title}{spec.title}{/if} + {#if valueKeys.length > 1} + + {#each valueKeys as key, index (key)} + {key} + {/each} + + {/if} + {#if spec.unit}{spec.unit}{/if} +
+ {/if} + + {#if isEmpty} +
{i18n.noData}
+ {:else} + (hover = null)} + > + + {#each valueKeys as key, index (key)} + + + + + {/each} + + + {#if isGraph && graph} + {#each graph.edges as edge, index (index)} + {edge.label} + {/each} + {#each graph.nodes as node (node.id)} + + {node.label} + + {node.label.slice(0, 14)} + + + {/each} + {:else if plot} + + {#if spec.type === 'line'} + {#each plot.lines as points, index (index)} + + {/each} + {/if} + {#each plot.categories as category (category.index)} + + + (hover = category.index)} + /> + {#if spec.type !== 'line'} + {#each category.bars as bar (bar.key)} + {bar.key} · {category.label}: {formatInteger(bar.value)} + {/each} + {/if} + {#if category.rate !== undefined && category.bars[0]} + + {category.rate}% + + {/if} + {category.label.slice(0, 8)} + + {/each} + {/if} + + + {#if !isGraph && plot && hover !== null} + {@const active = plot.categories[hover]} + {#if active} +
+ {active.label} + {#each active.bars as bar (bar.key)} + + + {bar.key}: {formatInteger(bar.value)} + + {/each} +
+ {/if} + {/if} + {/if} +
+ + diff --git a/packages/admin-svelte/src/RowDetailDrawer.svelte b/packages/admin-svelte/src/RowDetailDrawer.svelte new file mode 100644 index 00000000..2c94ebea --- /dev/null +++ b/packages/admin-svelte/src/RowDetailDrawer.svelte @@ -0,0 +1,133 @@ + + + + +{#if row} + + +{/if} diff --git a/packages/admin-svelte/src/chart.test.ts b/packages/admin-svelte/src/chart.test.ts new file mode 100644 index 00000000..5cd09a97 --- /dev/null +++ b/packages/admin-svelte/src/chart.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { defineAdminChartSpec, type AdminChartSpec } from '@ariada-org/admin-surface'; + +import { + CHART_PADDING, + chartCategories, + chartCategoryKey, + chartColor, + chartHeight, + chartMax, + chartWidth, + graphLayout, + plotLayout, +} from './chart'; + +const ROWS = [ + { name: 'alpha', accepted: 900, blocked: 100 }, + { name: 'beta', accepted: 400, blocked: 600 }, + { name: 'gamma', accepted: 200, blocked: 0 }, +]; + +const COLUMN: AdminChartSpec = defineAdminChartSpec({ + type: 'column', + title: 'Accepted vs blocked', + categoryKey: 'name', + valueKeys: ['accepted', 'blocked'], + colors: ['#059669', '#dc2626'], + height: 180, +}); + +describe('chart defaults', () => { + it('falls back to the contract defaults the React renderer also uses', () => { + expect(chartCategoryKey({ type: 'column', valueKeys: ['x'] })).toBe('name'); + expect(chartHeight({ type: 'column', valueKeys: ['x'] })).toBe(200); + expect(chartHeight(COLUMN)).toBe(180); + }); + + it('caps categories at maxCategories (default 12)', () => { + const many = Array.from({ length: 30 }, (_, i) => ({ name: `n${i}`, accepted: i })); + expect(chartCategories({ type: 'column', valueKeys: ['accepted'] }, many)).toHaveLength(12); + expect(chartCategories({ type: 'column', valueKeys: ['accepted'], maxCategories: 4 }, many)).toHaveLength(4); + expect(chartCategories({ type: 'graph', nodes: [{ id: 'a' }] }, many)).toHaveLength(0); + }); + + it('picks accent for series 0 and the palette after it, unless colours are declared', () => { + expect(chartColor(COLUMN, 0, '#0d9488')).toBe('#059669'); + expect(chartColor(COLUMN, 1, '#0d9488')).toBe('#dc2626'); + const plain: AdminChartSpec = { type: 'column', valueKeys: ['a', 'b'] }; + expect(chartColor(plain, 0, '#0d9488')).toBe('#0d9488'); + expect(chartColor(plain, 1, '#0d9488')).toBe('#059669'); + expect(chartColor(plain, 7, '#0d9488')).toBe(chartColor(plain, 1, '#0d9488')); + }); + + it('never divides by a zero scale', () => { + expect(chartMax(COLUMN, [{ accepted: 0, blocked: 0 }])).toBe(1); + expect(chartMax(COLUMN, [])).toBe(1); + expect(chartMax(COLUMN, ROWS)).toBe(900); + }); + + it('widens the plot with the category and series count', () => { + expect(chartWidth(COLUMN, 3)).toBe(280); + expect(chartWidth(COLUMN, 12)).toBe(12 * 88 + 8); + expect(chartWidth({ type: 'funnel', valueKeys: ['a'] }, 6)).toBe(6 * 90 + 8); + }); +}); + +describe('plotLayout — column', () => { + const layout = plotLayout(COLUMN, ROWS); + + it('lays out one band per category and one bar per series', () => { + expect(layout.categories).toHaveLength(3); + expect(layout.categories[0]?.bars.map((b) => b.key)).toEqual(['accepted', 'blocked']); + expect(layout.categories[0]?.label).toBe('alpha'); + }); + + it('scales bar height against the series maximum and sits on the baseline', () => { + const tallest = layout.categories[0]?.bars[0]; + expect(tallest?.height).toBeCloseTo(layout.plotHeight, 5); + expect(tallest?.y).toBeCloseTo(CHART_PADDING.top, 5); + const zero = layout.categories[2]?.bars[1]; + expect(zero?.height).toBe(0); + expect(zero?.y).toBeCloseTo(layout.baselineY, 5); + }); + + it('keeps every bar inside its own category band', () => { + for (const category of layout.categories) { + for (const bar of category.bars) { + expect(bar.x).toBeGreaterThanOrEqual(category.bandX); + expect(bar.x + bar.width).toBeLessThanOrEqual(category.bandX + category.bandWidth); + } + } + }); + + it('emits no polylines for a column chart', () => { + expect(layout.lines).toHaveLength(0); + }); + + it('survives an empty dataset', () => { + const empty = plotLayout(COLUMN, []); + expect(empty.categories).toHaveLength(0); + expect(empty.width).toBeGreaterThan(0); + }); +}); + +describe('plotLayout — line and funnel', () => { + it('emits one polyline point string per series', () => { + const layout = plotLayout({ ...COLUMN, type: 'line' }, ROWS); + expect(layout.lines).toHaveLength(2); + expect(layout.lines[0]?.split(' ')).toHaveLength(3); + }); + + it('computes funnel conversion against the first stage', () => { + const layout = plotLayout({ type: 'funnel', categoryKey: 'name', valueKeys: ['accepted'] }, ROWS); + expect(layout.categories.map((c) => c.rate)).toEqual([100, 44, 22]); + expect(layout.categories[0]?.bars).toHaveLength(1); + }); + + it('does not divide by zero when the first funnel stage is empty', () => { + const layout = plotLayout({ type: 'funnel', valueKeys: ['accepted'] }, [ + { name: 'a', accepted: 0 }, { name: 'b', accepted: 0 }, + ]); + expect(layout.categories.map((c) => c.rate)).toEqual([100, 0]); + }); +}); + +describe('graphLayout', () => { + const spec: AdminChartSpec = defineAdminChartSpec({ + type: 'graph', + height: 200, + nodes: [ + { id: 'a', label: 'Set A', group: 'set' }, + { id: 'b', group: 'item' }, + { id: 'c', group: 'item' }, + ], + edges: [{ from: 'a', to: 'b' }, { from: 'a', to: 'c', label: 'contains' }], + }); + const layout = graphLayout(spec, '#0d9488'); + + it('places every node on the circle and labels it', () => { + expect(layout.nodes).toHaveLength(3); + expect(layout.nodes[0]?.label).toBe('Set A'); + expect(layout.nodes[1]?.label).toBe('b'); + const cx = layout.width / 2; + const cy = layout.height / 2; + const radius = Math.hypot((layout.nodes[0]?.x ?? 0) - cx, (layout.nodes[0]?.y ?? 0) - cy); + for (const node of layout.nodes) { + expect(Math.hypot(node.x - cx, node.y - cy)).toBeCloseTo(radius, 5); + } + }); + + it('colours nodes by group', () => { + expect(layout.nodes[1]?.color).toBe(layout.nodes[2]?.color); + expect(layout.nodes[0]?.color).not.toBe(layout.nodes[1]?.color); + }); + + it('draws an edge between the two node positions and labels it', () => { + expect(layout.edges).toHaveLength(2); + expect(layout.edges[0]?.label).toBe('a → b'); + expect(layout.edges[1]?.label).toBe('contains'); + expect(layout.edges[0]?.x1).toBeCloseTo(layout.nodes[0]?.x ?? 0, 5); + }); + + it('drops an edge whose endpoint is not a declared node, instead of crashing', () => { + const broken = graphLayout({ type: 'graph', nodes: [{ id: 'a' }], edges: [{ from: 'a', to: 'ghost' }] }, '#0d9488'); + expect(broken.edges).toHaveLength(0); + }); + + it('survives an empty node list', () => { + const empty = graphLayout({ type: 'graph', nodes: [] }, '#0d9488'); + expect(empty.nodes).toHaveLength(0); + expect(empty.width).toBeGreaterThan(0); + }); +}); diff --git a/packages/admin-svelte/src/chart.ts b/packages/admin-svelte/src/chart.ts new file mode 100644 index 00000000..e8727892 --- /dev/null +++ b/packages/admin-svelte/src/chart.ts @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: 2026 Agonist Development AB +// SPDX-License-Identifier: EUPL-1.2 +// +// Chart geometry for . Pure functions, no DOM and no chart +// library: an AdminChartSpec plus rows in, coordinates out. Keeping the maths +// here means the layout is unit-testable and the Svelte component stays a thin +// mapping from coordinates to SVG. +import { + ADMIN_CHART_DEFAULT_CATEGORY_KEY, + ADMIN_CHART_DEFAULT_HEIGHT, + ADMIN_CHART_DEFAULT_MAX_CATEGORIES, + type AdminChartSpec, +} from '@ariada-org/admin-surface'; + +import { toNumber, type AdminGridRow } from './format'; + +/** fallback series palette when a spec does not declare colours. */ +export const CHART_PALETTE = Object.freeze([ + '#2563eb', '#059669', '#d97706', '#7c3aed', '#db2777', '#0891b2', +]); + +export const CHART_PADDING = Object.freeze({ left: 8, bottom: 26, top: 10 }); +const CATEGORY_SLOT = 44; +const FUNNEL_SLOT = 90; +const MIN_CHART_WIDTH = 280; + +/** the colour of series/group `index`: spec colours first, then the palette. */ +export function chartColor(spec: AdminChartSpec, index: number, accent: string): string { + const declared = spec.colors?.[index]; + if (declared) return declared; + if (index === 0) return accent; + const fallback = CHART_PALETTE[index % CHART_PALETTE.length]; + return fallback ?? accent; +} + +export function chartHeight(spec: AdminChartSpec): number { + return spec.height ?? ADMIN_CHART_DEFAULT_HEIGHT; +} + +export function chartCategoryKey(spec: AdminChartSpec): string { + return spec.categoryKey ?? ADMIN_CHART_DEFAULT_CATEGORY_KEY; +} + +export function chartValueKeys(spec: AdminChartSpec): readonly string[] { + return spec.valueKeys ?? []; +} + +/** the rows a chart actually plots, capped so a dense board stays readable. */ +export function chartCategories(spec: AdminChartSpec, rows: readonly AdminGridRow[]): AdminGridRow[] { + if (spec.type === 'graph') return []; + return rows.slice(0, spec.maxCategories ?? ADMIN_CHART_DEFAULT_MAX_CATEGORIES); +} + +/** the Y scale ceiling (never 0, so a flat series still renders a baseline). */ +export function chartMax(spec: AdminChartSpec, categories: readonly AdminGridRow[]): number { + const values = categories.flatMap((row) => chartValueKeys(spec).map((key) => toNumber(row[key]))); + return Math.max(1, ...values); +} + +/** intrinsic plot width; the SVG scales it to the container via viewBox. */ +export function chartWidth(spec: AdminChartSpec, categoryCount: number): number { + const slot = spec.type === 'funnel' + ? FUNNEL_SLOT + : CATEGORY_SLOT * Math.max(1, chartValueKeys(spec).length); + return Math.max(MIN_CHART_WIDTH, categoryCount * slot + CHART_PADDING.left); +} + +export interface PlotBar { + readonly seriesIndex: number; + readonly key: string; + readonly value: number; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface PlotCategory { + readonly index: number; + readonly label: string; + /** the hover band (full-height crosshair target) for this category. */ + readonly bandX: number; + readonly bandWidth: number; + readonly centerX: number; + readonly bars: readonly PlotBar[]; + /** funnel only: conversion against the first category, in percent. */ + readonly rate?: number; +} + +export interface PlotLayout { + readonly width: number; + readonly height: number; + readonly plotHeight: number; + readonly baselineY: number; + readonly max: number; + readonly categories: readonly PlotCategory[]; + /** one polyline point string per series (line charts). */ + readonly lines: readonly string[]; +} + +const label = (value: unknown): string => (value == null ? '—' : String(value)); + +/** Column / line / funnel geometry in one pass. */ +export function plotLayout(spec: AdminChartSpec, rows: readonly AdminGridRow[]): PlotLayout { + const categories = chartCategories(spec, rows); + const valueKeys = chartValueKeys(spec); + const categoryKey = chartCategoryKey(spec); + const height = chartHeight(spec); + const width = chartWidth(spec, categories.length); + const plotHeight = Math.max(1, height - CHART_PADDING.bottom - CHART_PADDING.top); + const max = chartMax(spec, categories); + const groupWidth = (width - CHART_PADDING.left) / Math.max(1, categories.length); + const isFunnel = spec.type === 'funnel'; + const firstKey = valueKeys[0] ?? ''; + const firstValue = categories.length > 0 ? toNumber(categories[0]?.[firstKey]) : 0; + const barWidth = isFunnel + ? Math.max(3, groupWidth - 12) + : Math.max(3, (groupWidth - 10) / Math.max(1, valueKeys.length)); + + const laidOut: PlotCategory[] = categories.map((row, index) => { + const bandX = CHART_PADDING.left + index * groupWidth; + const keys = isFunnel ? valueKeys.slice(0, 1) : valueKeys; + const bars: PlotBar[] = keys.map((key, seriesIndex) => { + const value = toNumber(row[key]); + const barHeight = (value / max) * plotHeight; + return { + seriesIndex, + key, + value, + x: isFunnel ? bandX + 6 : bandX + 5 + seriesIndex * barWidth, + y: CHART_PADDING.top + plotHeight - barHeight, + width: isFunnel ? barWidth : Math.max(2, barWidth - 2), + height: barHeight, + }; + }); + const category: PlotCategory = { + index, + label: label(row[categoryKey]), + bandX, + bandWidth: groupWidth, + centerX: bandX + groupWidth / 2, + bars, + ...(isFunnel + ? { rate: index === 0 ? 100 : Math.round((toNumber(row[firstKey]) / (firstValue || 1)) * 100) } + : {}), + }; + return category; + }); + + const lines = spec.type === 'line' + ? valueKeys.map((key) => laidOut + .map((category, index) => { + const value = toNumber(categories[index]?.[key]); + return `${category.centerX},${CHART_PADDING.top + plotHeight - (value / max) * plotHeight}`; + }) + .join(' ')) + : []; + + return { + width, + height, + plotHeight, + baselineY: CHART_PADDING.top + plotHeight, + max, + categories: laidOut, + lines, + }; +} + +export interface GraphNodePoint { + readonly id: string; + readonly label: string; + readonly color: string; + readonly x: number; + readonly y: number; +} + +export interface GraphEdgeLine { + readonly label: string; + readonly x1: number; + readonly y1: number; + readonly x2: number; + readonly y2: number; +} + +export interface GraphLayout { + readonly width: number; + readonly height: number; + readonly nodes: readonly GraphNodePoint[]; + readonly edges: readonly GraphEdgeLine[]; +} + +const GRAPH_RADIUS_INSET = 34; + +/** + * Relationship map: nodes on a circle, edges as chords. Zero-dependency by + * design — a richer graph engine can swap in later behind the SAME spec. + */ +export function graphLayout(spec: AdminChartSpec, accent: string): GraphLayout { + const height = chartHeight(spec); + const nodes = spec.nodes ?? []; + const width = Math.max(MIN_CHART_WIDTH, height * 1.7); + if (nodes.length === 0) return { width, height, nodes: [], edges: [] }; + + const cx = width / 2; + const cy = height / 2; + const radius = Math.max(10, Math.min(cx, cy) - GRAPH_RADIUS_INSET); + const groups = [...new Set(nodes.map((node) => node.group ?? ''))]; + const positions = new Map(); + const points: GraphNodePoint[] = nodes.map((node, index) => { + const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2; + const x = cx + radius * Math.cos(angle); + const y = cy + radius * Math.sin(angle); + positions.set(node.id, { x, y }); + return { + id: node.id, + label: node.label ?? node.id, + color: chartColor(spec, Math.max(0, groups.indexOf(node.group ?? '')), accent), + x, + y, + }; + }); + + const edges: GraphEdgeLine[] = []; + for (const edge of spec.edges ?? []) { + const from = positions.get(edge.from); + const to = positions.get(edge.to); + if (!from || !to) continue; + edges.push({ + label: edge.label ?? `${edge.from} → ${edge.to}`, + x1: from.x, y1: from.y, x2: to.x, y2: to.y, + }); + } + return { width, height, nodes: points, edges }; +} diff --git a/packages/admin-svelte/src/components.test.ts b/packages/admin-svelte/src/components.test.ts new file mode 100644 index 00000000..798794df --- /dev/null +++ b/packages/admin-svelte/src/components.test.ts @@ -0,0 +1,96 @@ +// Component-level guards. This repo has no DOM test environment installed +// (no jsdom / happy-dom / @testing-library/svelte), so these tests do NOT mount +// the components. They compile every .svelte file with the real Svelte 5 +// compiler — which catches syntax, rune and a11y defects — and assert the +// package's structural invariants: zero runtime dependencies, no Tailwind, no +// product names in the render layer. +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { compile } from 'svelte/compiler'; +import { describe, expect, it } from 'vitest'; + +const SRC = dirname(fileURLToPath(import.meta.url)); +const COMPONENTS = readdirSync(SRC).filter((name) => name.endsWith('.svelte')).sort(); +const SOURCES = Object.fromEntries( + readdirSync(SRC) + .filter((name) => name.endsWith('.svelte') || name.endsWith('.ts') || name.endsWith('.css')) + .map((name) => [name, readFileSync(join(SRC, name), 'utf8')]), +); + +describe('Svelte components', () => { + it('ships the three shared components', () => { + expect(COMPONENTS).toEqual(['AdminGrid.svelte', 'MetricChart.svelte', 'RowDetailDrawer.svelte']); + }); + + for (const name of COMPONENTS) { + it(`compiles ${name} without errors or warnings`, () => { + const result = compile(SOURCES[name] as string, { + filename: name, + generate: 'client', + dev: false, + }); + expect(result.warnings.map((w) => `${w.code}: ${w.message}`)).toEqual([]); + expect(result.js.code.length).toBeGreaterThan(0); + }); + + it(`compiles ${name} for server-side rendering`, () => { + const result = compile(SOURCES[name] as string, { + filename: name, + generate: 'server', + dev: false, + }); + expect(result.js.code.length).toBeGreaterThan(0); + }); + } +}); + +describe('package invariants', () => { + const allSources = Object.entries(SOURCES).filter(([name]) => !name.endsWith('.test.ts')); + + it('imports nothing outside the contract, AG Grid and Svelte itself', () => { + const allowed = new Set(['@ariada-org/admin-surface', 'ag-grid-community', 'svelte']); + const seen: string[] = []; + for (const [name, source] of allSources) { + // strip comments so a usage example in a doc block is not read as an import + const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + for (const match of code.matchAll(/^\s*(?:import|export)[\s\S]*?from\s+'([^']+)'/gm)) { + const specifier = match[1] as string; + if (specifier.startsWith('./') || specifier.startsWith('../')) continue; + seen.push(specifier); + expect(allowed.has(specifier), `${name} imports ${specifier}`).toBe(true); + } + } + // guard the guard: the scan must actually have found the external imports + expect(new Set(seen)).toEqual(allowed); + }); + + it('has no React or component-library dependency', () => { + for (const [name, source] of allSources) { + expect(source, name).not.toMatch(/\bfrom\s+'(react|react-dom|antd|@ant-design\/[^']+)'/); + } + }); + + it('the stylesheet is plain CSS — no Tailwind directive and no preprocessor', () => { + const css = SOURCES['tokens.css'] as string; + expect(css).not.toMatch(/@import\s+["']tailwindcss/); + expect(css).not.toMatch(/@theme\b/); + expect(css).not.toMatch(/@apply\b/); + expect(css).toMatch(/--adm-primary:/); + }); + + it('namespaces every custom property so a consumer theme cannot collide', () => { + const css = SOURCES['tokens.css'] as string; + for (const match of css.matchAll(/^\s{2}(--[a-z0-9-]+):/gm)) { + expect(match[1]).toMatch(/^--adm-/); + } + }); + + it('carries no product name — a render layer draws whatever the contract declares', () => { + for (const [name, source] of allSources) { + if (name === 'format.ts') continue; // DEFAULT_WIKI holds the shared wiki host + expect(source.toLowerCase(), name).not.toMatch(/\b(fap\.nu|fapnu|novostnik|projectology|smartcj|tradeexpert)\b/); + } + }); +}); diff --git a/packages/admin-svelte/src/format.test.ts b/packages/admin-svelte/src/format.test.ts new file mode 100644 index 00000000..fdd2e796 --- /dev/null +++ b/packages/admin-svelte/src/format.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; + +import { + ADMIN_GRID_ACTION_EFFECT, + DEFAULT_WIKI, + barContent, + escapeHtml, + formatByKind, + formatInteger, + formatRowValue, + rampColor, + rampContent, + rampVariant, + rowLabel, + statusColor, + tagColor, + toNumber, + wikiHref, +} from './format'; + +describe('numbers', () => { + it('groups integers the way the grid does', () => { + expect(formatInteger(9300)).toBe('9,300'); + expect(formatInteger(-3120.4)).toBe('-3,120'); + }); + + it('coerces unknown cell values without throwing', () => { + expect(toNumber(12)).toBe(12); + expect(toNumber('12.5')).toBe(12.5); + expect(toNumber('nope')).toBe(0); + expect(toNumber(null)).toBe(0); + expect(toNumber(Number.NaN)).toBe(0); + expect(toNumber(Number.POSITIVE_INFINITY)).toBe(0); + }); +}); + +describe('colour ramp', () => { + it('interpolates red -> green and clamps outside 0..1', () => { + expect(rampColor(0).fg).toBe('rgb(220,38,38)'); + expect(rampColor(1).fg).toBe('rgb(5,150,105)'); + expect(rampColor(-4).fg).toBe(rampColor(0).fg); + expect(rampColor(9).fg).toBe(rampColor(1).fg); + }); + + it('emits a translucent chip background alongside the foreground', () => { + expect(rampColor(1).bg).toBe('rgba(5,150,105,0.12)'); + }); + + it('resolves the ramp variant from the CONTRACT, never from a column name', () => { + expect(rampVariant({ kind: 'ratio' })).toBe('ratio'); + expect(rampVariant({ kind: 'percent', colorRamp: { good: 'low' } })).toBe('low-percent'); + expect(rampVariant({ kind: 'count' })).toBe('signed'); + expect(rampVariant({ kind: 'count', colorRamp: { good: 'high' } })).toBe('signed'); + }); + + it('renders each ramp variant on its own scale', () => { + expect(rampContent('ratio', 1.482).text).toBe('1.48'); + expect(rampContent('low-percent', 1).text).toBe('1%'); + expect(rampContent('signed', 3120).text).toBe('+3,120'); + expect(rampContent('signed', -3120).text).toBe('-3,120'); + expect(rampContent('signed', 0).text).toBe('0'); + }); +}); + +describe('bar cell', () => { + it('clamps the fill to 0..100 but keeps the true label', () => { + expect(barContent(42.6)).toMatchObject({ percent: 42.6, text: '43' }); + expect(barContent(140).percent).toBe(100); + expect(barContent(-8).percent).toBe(0); + }); +}); + +describe('status and tag palettes', () => { + it('maps known severities and falls back to neutral', () => { + expect(statusColor('active')).toBe('#059669'); + expect(statusColor('banned')).toBe('#dc2626'); + expect(statusColor('paused')).toBe('#d97706'); + expect(statusColor('who-knows')).toBe('#94a3b8'); + expect(statusColor(undefined)).toBe('#94a3b8'); + }); + + it('maps tags and falls back to slate', () => { + expect(tagColor('feeder')).toBe('#2563eb'); + expect(tagColor('mystery')).toBe('#64748b'); + }); +}); + +describe('formatByKind', () => { + it('formats each metric kind', () => { + expect(formatByKind(0.232, 'percent')).toBe('23.2%'); + expect(formatByKind(4.5, 'currency')).toBe('$4.50'); + expect(formatByKind(90, 'duration')).toBe('90s'); + expect(formatByKind(9300, 'count')).toBe('9,300'); + expect(formatByKind(1.482, 'ratio')).toBe('1.48'); + expect(formatByKind(76.4, 'score')).toBe('76'); + expect(formatByKind('x', 'text')).toBe('x'); + }); +}); + +describe('formatRowValue — renderer wins over kind', () => { + // The regression this function exists for: a column declared kind:'percent' + // that carries a 0-100 value. The grid's ramp renderer prints "1%"; a + // kind-only formatter multiplies by 100 again and prints "100.0%". + const fraudPct = { key: 'fraudPct', kind: 'percent', renderer: 'ramp', colorRamp: { good: 'low' } } as const; + + it('prints the ramp value, not the kind value', () => { + expect(formatRowValue(1, fraudPct)).toBe('1%'); + expect(formatByKind(1, 'percent')).toBe('100.0%'); + }); + + it('agrees with the grid for every renderer in the contract', () => { + expect(formatRowValue(1.482, { kind: 'ratio', renderer: 'ramp' })).toBe('1.48'); + expect(formatRowValue(3120, { kind: 'count', renderer: 'ramp' })).toBe('+3,120'); + expect(formatRowValue(76.4, { kind: 'score', renderer: 'bar' })).toBe('76'); + expect(formatRowValue(9300, { kind: 'count' })).toBe('9,300'); + expect(formatRowValue(0.232, { kind: 'percent' })).toBe('23.2%'); + }); + + it('handles empty, boolean and text values', () => { + expect(formatRowValue(null, { kind: 'count' })).toBe('—'); + expect(formatRowValue('', { kind: 'text' })).toBe('—'); + expect(formatRowValue(true, { kind: 'enum' })).toBe('yes'); + expect(formatRowValue('feeder', { kind: 'enum', renderer: 'tag' })).toBe('feeder'); + }); +}); + +describe('wiki links', () => { + it('uses the column key when the contract omits a slug', () => { + expect(wikiHref(DEFAULT_WIKI, { description: 'x' }, 'owedRatio')) + .toBe('https://wiki.klarads.com/en/metrics/owedRatio'); + }); + + it('honours slug, anchor, language and a trailing-slash base', () => { + expect(wikiHref({ base: 'https://wiki.klarads.com/', lang: 'ru' }, { description: 'x', wikiSlug: 'owed-ratio', wikiAnchor: 'formula' }, 'owedRatio')) + .toBe('https://wiki.klarads.com/ru/metrics/owed-ratio#formula'); + }); +}); + +describe('row helpers', () => { + it('labels a row from its own fields, falling back to the row key', () => { + expect(rowLabel({ title: 'T', name: 'N', id: '1' })).toBe('T'); + expect(rowLabel({ name: 'N', id: '1' })).toBe('N'); + expect(rowLabel({ id: '1' })).toBe('1'); + expect(rowLabel({ slug: 'abc' }, 'slug')).toBe('abc'); + }); + + it('publishes the optimistic action-effect table', () => { + expect(ADMIN_GRID_ACTION_EFFECT.ban).toBe('banned'); + expect(ADMIN_GRID_ACTION_EFFECT.stop_trade).toBe('paused'); + expect(Object.isFrozen(ADMIN_GRID_ACTION_EFFECT)).toBe(true); + }); + + it('escapes values interpolated into a vanilla renderer', () => { + expect(escapeHtml('')) + .toBe('<img src=x onerror="alert(1)">'); + expect(escapeHtml(null)).toBe(''); + }); +}); diff --git a/packages/admin-svelte/src/format.ts b/packages/admin-svelte/src/format.ts new file mode 100644 index 00000000..0b08de2a --- /dev/null +++ b/packages/admin-svelte/src/format.ts @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: 2026 Agonist Development AB +// SPDX-License-Identifier: EUPL-1.2 +// +// Pure, DOM-free helpers shared by every renderer in this package. They are +// deliberately separated from the Svelte components and the AG Grid cell +// renderers so the parts that carry the actual RULES — the colour ramp, the +// value formatting, the renderer-over-kind precedence — can be unit-tested +// without a browser. +import type { AdminColumnHelp, AdminMetricColumn } from '@ariada-org/admin-surface'; + +/** a row is opaque to the render layer: the contract, not the shape, drives it. */ +export type AdminGridRow = Record; + +export interface RampColor { + /** translucent chip background. */ + readonly bg: string; + /** foreground / fill colour. */ + readonly fg: string; +} + +const NUMBER_FORMAT = new Intl.NumberFormat('en-US'); + +/** integer group formatting, e.g. 9300 -> "9,300". */ +export function formatInteger(value: number): string { + return NUMBER_FORMAT.format(Math.round(value)); +} + +/** coerce an unknown cell value to a number without throwing (0 is the floor). */ +export function toNumber(value: unknown): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +const RAMP_BAD = { r: 220, g: 38, b: 38 } as const; +const RAMP_GOOD = { r: 5, g: 150, b: 105 } as const; + +/** + * Health (0 = worst, 1 = best) -> a red→green ramp. Identical arithmetic to the + * React renderer in `@ariada-org/admin-ui`, so a value is the same colour in both. + */ +export function rampColor(health: number): RampColor { + const h = Math.max(0, Math.min(1, Number.isFinite(health) ? health : 0)); + const r = Math.round(RAMP_BAD.r + (RAMP_GOOD.r - RAMP_BAD.r) * h); + const g = Math.round(RAMP_BAD.g + (RAMP_GOOD.g - RAMP_BAD.g) * h); + const b = Math.round(RAMP_BAD.b + (RAMP_GOOD.b - RAMP_BAD.b) * h); + return { bg: `rgba(${r},${g},${b},0.12)`, fg: `rgb(${r},${g},${b})` }; +} + +/** which flavour of `renderer: 'ramp'` a column resolves to. */ +export type RampVariant = 'ratio' | 'signed' | 'low-percent'; + +/** + * A ramp column has three shapes and the contract picks between them: + * `kind: 'ratio'` -> a 0..2-ish ratio, `colorRamp.good: 'low'` -> a 0..100 + * percent where low is good (fraud), anything else -> a signed count (debt). + */ +export function rampVariant(column: Pick): RampVariant { + if (column.kind === 'ratio') return 'ratio'; + if (column.colorRamp?.good === 'low') return 'low-percent'; + return 'signed'; +} + +export interface RampContent extends RampColor { + readonly text: string; +} + +/** the text + colours a `ramp` cell paints, without touching the DOM. */ +export function rampContent(variant: RampVariant, value: unknown): RampContent { + const n = toNumber(value); + if (variant === 'ratio') return { text: n.toFixed(2), ...rampColor((n - 0.5) / 1.0) }; + if (variant === 'low-percent') return { text: `${Math.round(n)}%`, ...rampColor(1 - n / 100) }; + return { text: `${n > 0 ? '+' : ''}${formatInteger(n)}`, ...rampColor(n >= 0 ? 0.85 : 0.15) }; +} + +export interface BarContent { + /** clamped 0..100 fill width. */ + readonly percent: number; + readonly text: string; + readonly color: string; +} + +/** the fill width, label and colour a `bar` cell paints. */ +export function barContent(value: unknown): BarContent { + const n = toNumber(value); + const percent = Math.max(0, Math.min(100, n)); + return { percent, text: String(Math.round(n)), color: rampColor(percent / 100).fg }; +} + +/** neutral fallback for a status/tag the palette does not know. */ +export const NEUTRAL_COLOR = '#64748b'; +const NEUTRAL_DOT = '#94a3b8'; + +/** + * Status severity -> dot colour. Generic operational vocabulary only; no + * product-specific statuses live in the render layer. + */ +const SEVERITY: Readonly> = Object.freeze({ + active: '#059669', approved: '#059669', used: '#059669', running: '#059669', + enabled: '#059669', ready: '#059669', winner: '#059669', settled: '#059669', deployed: '#059669', + hold: '#d97706', holding: '#d97706', review: '#d97706', pending: '#d97706', paused: '#d97706', + scheduled: '#d97706', idle: '#d97706', submission: '#d97706', exploring: '#d97706', 'in-progress': '#d97706', + banned: '#dc2626', rejected: '#dc2626', disabled: '#dc2626', error: '#dc2626', dispute: '#dc2626', + built: '#0891b2', spec: '#94a3b8', superseded: '#94a3b8', +}); + +/** the dot colour for a row status; unknown statuses fall back to neutral grey. */ +export function statusColor(status: unknown): string { + return SEVERITY[String(status ?? '')] ?? NEUTRAL_DOT; +} + +const TAG_COLOR: Readonly> = Object.freeze({ + feeder: '#2563eb', barter: '#4f46e5', paid: '#7c3aed', + api: '#0891b2', csv: '#2563eb', ftp: '#4f46e5', upload: '#7c3aed', scrape: '#ea580c', + block: '#2563eb', widget: '#4f46e5', 'in-article': '#0891b2', header: '#ca8a04', footer: '#65a30d', + active: '#059669', approved: '#059669', used: '#059669', running: '#059669', ready: '#059669', + enabled: '#059669', winner: '#059669', settled: '#059669', deployed: '#059669', + paused: '#d97706', hold: '#d97706', holding: '#d97706', review: '#2563eb', pending: '#2563eb', + scheduled: '#2563eb', submission: '#2563eb', 'in-progress': '#2563eb', generated: '#4f46e5', + banned: '#dc2626', rejected: '#dc2626', disabled: '#dc2626', error: '#dc2626', dispute: '#dc2626', + licensed: '#059669', imported: '#ea580c', transcoding: '#2563eb', + idle: NEUTRAL_COLOR, spec: NEUTRAL_COLOR, superseded: NEUTRAL_COLOR, loser: NEUTRAL_COLOR, +}); + +/** the chip colour for a `tag` cell; unknown values fall back to slate. */ +export function tagColor(value: unknown): string { + return TAG_COLOR[String(value ?? '')] ?? NEUTRAL_COLOR; +} + +/** format a value from its metric `kind` alone (no renderer involved). */ +export function formatByKind(value: unknown, kind: AdminMetricColumn['kind'] | undefined): string { + const n = toNumber(value); + switch (kind) { + case 'percent': return `${(n * 100).toFixed(1)}%`; + case 'currency': return `$${n.toFixed(2)}`; + case 'duration': return `${n}s`; + case 'count': return formatInteger(n); + case 'ratio': return n.toFixed(2); + case 'score': return String(Math.round(n)); + default: return String(value ?? ''); + } +} + +/** + * Format a value the way the GRID would — `renderer` wins over `kind`. + * + * This precedence is the whole point of the function and the reason the detail + * drawer must not format from `kind` alone. Real case caught by the Svelte + * spike: `fraudPct` is declared `kind: 'percent'` but carries a 0–100 value; its + * `ramp` renderer correctly prints `1%`, while a kind-only formatter multiplies + * by 100 again and prints `100.0%`. Anything that shows a row's values next to + * the grid (a drawer, a CSV export, a tooltip) has to use this function. + */ +export function formatRowValue( + value: unknown, + column: Pick, +): string { + if (value == null || value === '') return '—'; + if (typeof value === 'boolean') return value ? 'yes' : 'no'; + if (typeof value !== 'number') return String(value); + if (column.renderer === 'ramp') return rampContent(rampVariant(column), value).text; + if (column.renderer === 'bar') return barContent(value).text; + return formatByKind(value, column.kind); +} + +/** wiki config for the column-header "learn more" link (language-aware). */ +export interface AdminGridWiki { + /** base URL, e.g. "https://wiki.klarads.com". */ + readonly base: string; + /** language segment, e.g. "en" / "ru". */ + readonly lang: string; +} + +export const DEFAULT_WIKI: AdminGridWiki = Object.freeze({ base: 'https://wiki.klarads.com', lang: 'en' }); + +/** resolve a column's contextual-help wiki URL (slug defaults to the column key). */ +export function wikiHref(wiki: AdminGridWiki, help: AdminColumnHelp, columnKey: string): string { + const slug = help.wikiSlug ?? columnKey; + const anchor = help.wikiAnchor ? `#${help.wikiAnchor}` : ''; + return `${wiki.base.replace(/\/$/, '')}/${wiki.lang}/metrics/${slug}${anchor}`; +} + +/** action key -> the status a row optimistically moves to when the action fires. */ +export const ADMIN_GRID_ACTION_EFFECT: Readonly> = Object.freeze({ + stop_trade: 'paused', hold: 'hold', ban: 'banned', + approve: 'approved', reject: 'rejected', disable: 'disabled', pause_placement: 'paused', +}); + +/** a row label for confirm dialogs and drawer titles, derived from the row itself. */ +export function rowLabel(row: AdminGridRow, rowKey = 'id'): string { + for (const key of ['title', 'name', 'label', 'surface']) { + const candidate = row[key]; + if (typeof candidate === 'string' && candidate.trim() !== '') return candidate; + } + return String(row[rowKey] ?? ''); +} + +/** escape a string for safe interpolation into innerHTML in a vanilla renderer. */ +export function escapeHtml(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/admin-svelte/src/i18n.ts b/packages/admin-svelte/src/i18n.ts new file mode 100644 index 00000000..d847d562 --- /dev/null +++ b/packages/admin-svelte/src/i18n.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 Agonist Development AB +// SPDX-License-Identifier: EUPL-1.2 +// +// Chrome labels for the shared render layer. Defaults are English; a consumer +// passes its own locale strings. No product copy lives in this package. + +export interface AdminSvelteI18n { + /** quick-filter placeholder above the grid. */ + readonly searchPlaceholder?: string; + /** header help popover link. */ + readonly learnMore?: string; + /** row-action confirm popover. */ + readonly reasonRequiredPlaceholder?: string; + readonly reasonOptionalPlaceholder?: string; + readonly confirm?: string; + readonly cancel?: string; + /** row-operation drawer. */ + readonly detailTitle?: string; + readonly parameters?: string; + readonly edit?: string; + readonly save?: string; + readonly close?: string; + /** chart empty state. */ + readonly noData?: string; +} + +export type ResolvedAdminSvelteI18n = Required; + +export const DEFAULT_I18N: ResolvedAdminSvelteI18n = Object.freeze({ + searchPlaceholder: 'Search the table…', + learnMore: 'Learn more →', + reasonRequiredPlaceholder: 'Reason (required, audit-logged)', + reasonOptionalPlaceholder: 'Reason (optional)', + confirm: 'Confirm', + cancel: 'Cancel', + detailTitle: 'Operation', + parameters: 'Parameters', + edit: 'Edit', + save: 'Save', + close: 'Close', + noData: 'no data', +}); + +export function resolveI18n(overrides?: AdminSvelteI18n): ResolvedAdminSvelteI18n { + return overrides ? { ...DEFAULT_I18N, ...stripUndefined(overrides) } : DEFAULT_I18N; +} + +function stripUndefined(value: AdminSvelteI18n): Partial { + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string') out[key] = entry; + } + return out as Partial; +} diff --git a/packages/admin-svelte/src/icons.ts b/packages/admin-svelte/src/icons.ts new file mode 100644 index 00000000..3b4f7e55 --- /dev/null +++ b/packages/admin-svelte/src/icons.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 Agonist Development AB +// SPDX-License-Identifier: EUPL-1.2 +// +// Inline line icons. Row actions are icon buttons, not text buttons: the text +// form reserves ~46px per action and starves the data columns (the measured +// defect of the React build), while an icon needs 31px. Inline SVG keeps the +// package dependency-free — no icon library. + +/** action key -> the inner path markup of a 24x24 stroke icon. */ +export const ACTION_ICON_PATHS: Readonly> = Object.freeze({ + stop_trade: '', + hold: '', + pause_placement: '', + ban: '', + disable: '', + approve: '', + reject: '', + rescan: '', + retry: '', + promote: '', + create_task: '', +}); + +const FALLBACK_ICON = ''; + +/** a 24x24 stroke-icon SVG string for an action key (falls back to a dot). */ +export function actionIconSvg(key: string, size = 14): string { + const paths = ACTION_ICON_PATHS[key] ?? FALLBACK_ICON; + return ``; +} diff --git a/packages/admin-svelte/src/index.ts b/packages/admin-svelte/src/index.ts new file mode 100644 index 00000000..a4fe9f32 --- /dev/null +++ b/packages/admin-svelte/src/index.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Agonist Development AB +// SPDX-License-Identifier: EUPL-1.2 +// +// @ariada-org/admin-svelte — the Svelte render layer for @ariada-org/admin-surface. +// +// The Svelte components are imported by path (a bundler compiles them), so this +// entry point carries the framework-neutral half: the contract-driven helpers, +// the AG Grid column-def builder, the cell renderers and the chart geometry. +// +// import AdminGrid from '@ariada-org/admin-svelte/AdminGrid.svelte'; +// import MetricChart from '@ariada-org/admin-svelte/MetricChart.svelte'; +// import RowDetailDrawer from '@ariada-org/admin-svelte/RowDetailDrawer.svelte'; +// import '@ariada-org/admin-svelte/tokens.css'; +// import { formatRowValue } from '@ariada-org/admin-svelte'; + +export { + ADMIN_GRID_ACTION_EFFECT, + DEFAULT_WIKI, + barContent, + escapeHtml, + formatByKind, + formatInteger, + formatRowValue, + rampColor, + rampContent, + rampVariant, + rowLabel, + statusColor, + tagColor, + toNumber, + NEUTRAL_COLOR, + type AdminGridRow, + type AdminGridWiki, + type BarContent, + type RampColor, + type RampContent, + type RampVariant, +} from './format'; + +export { + ACTIONS_COLUMN_ID, + ADMIN_CELL_RENDERERS, + actionsColumnWidth, + buildAdminColumnDefs, + isActionDisabled, + resolveRowActions, + type BuildColumnDefsOptions, + type ConfirmRequest, +} from './renderers'; + +export { + CHART_PADDING, + CHART_PALETTE, + chartCategories, + chartCategoryKey, + chartColor, + chartHeight, + chartMax, + chartValueKeys, + chartWidth, + graphLayout, + plotLayout, + type GraphEdgeLine, + type GraphLayout, + type GraphNodePoint, + type PlotBar, + type PlotCategory, + type PlotLayout, +} from './chart'; + +export { + DEFAULT_ACCENT, + createAdminGridTheme, + type AdminColorScheme, + type AdminGridThemeOptions, +} from './theme'; + +export { + DEFAULT_I18N, + resolveI18n, + type AdminSvelteI18n, + type ResolvedAdminSvelteI18n, +} from './i18n'; + +export { ACTION_ICON_PATHS, actionIconSvg } from './icons'; diff --git a/packages/admin-svelte/src/renderers.test.ts b/packages/admin-svelte/src/renderers.test.ts new file mode 100644 index 00000000..75a2e764 --- /dev/null +++ b/packages/admin-svelte/src/renderers.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { + ADMIN_GRID_SCHEMA, + OPERATOR_DASHBOARD_PROFILE_SCHEMA, + defineAdminGridSurface, + defineOperatorDashboardProfile, + type AdminGridSurface, + type OperatorDashboardProfile, +} from '@ariada-org/admin-surface'; + +import { + ACTIONS_COLUMN_ID, + ADMIN_CELL_RENDERERS, + actionsColumnWidth, + buildAdminColumnDefs, + isActionDisabled, + resolveRowActions, +} from './renderers'; + +const SURFACE: AdminGridSurface = defineAdminGridSurface({ + schemaVersion: ADMIN_GRID_SCHEMA, + id: 'operator.traffic-board', + title: 'Source productivity', + rowKey: 'id', + columns: [ + { key: 'name', label: 'Source', kind: 'text', renderer: 'status-dot' }, + { key: 'kindTag', label: 'Kind', kind: 'enum', renderer: 'tag' }, + { key: 'productivity', label: 'Productivity', kind: 'score', renderer: 'bar', colorRamp: { good: 'high' } }, + { key: 'owedRatio', label: 'Owed ratio', kind: 'ratio', renderer: 'ramp', colorRamp: { good: 'high' }, help: { description: 'What partners owe us.', formula: 'accepted / owed', wikiSlug: 'owed-ratio' } }, + { key: 'debt', label: 'Debt', kind: 'count', renderer: 'ramp', align: 'right' }, + { key: 'fraudPct', label: 'Fraud', kind: 'percent', renderer: 'ramp', colorRamp: { good: 'low' } }, + { key: 'accepted', label: 'Accepted', kind: 'count' }, + { key: 'unused', label: 'Unused', kind: 'count' }, + ], + rowActions: [ + { key: 'stop_trade', label: 'Stop', confirm: { reasonRequired: true }, endpoint: '/api/traffic/stop' }, + { key: 'hold', label: 'Hold', confirm: { reasonRequired: true }, endpoint: '/api/traffic/hold' }, + { key: 'ban', label: 'Ban', danger: true, confirm: { reasonRequired: true }, endpoint: '/api/traffic/ban' }, + ], + defaultSort: { key: 'productivity', dir: 'desc' }, +}); + +const PROFILE: OperatorDashboardProfile = defineOperatorDashboardProfile({ + schemaVersion: OPERATOR_DASHBOARD_PROFILE_SCHEMA, + id: 'smartcj', + label: 'SmartCJ', + columns: ['name', 'kindTag', 'productivity', 'owedRatio', 'debt', 'fraudPct', 'accepted'], + actions: ['stop_trade', 'ban'], + sort: { key: 'owedRatio', dir: 'desc' }, + terminology: { name: 'Trader' }, +}, SURFACE); + +describe('buildAdminColumnDefs', () => { + const defs = buildAdminColumnDefs(SURFACE, PROFILE); + + it('renders exactly the profile columns, in profile order, plus the actions column', () => { + expect(defs.map((d) => d.field ?? d.colId)).toEqual([ + 'name', 'kindTag', 'productivity', 'owedRatio', 'debt', 'fraudPct', 'accepted', ACTIONS_COLUMN_ID, + ]); + }); + + it('never renders a column the profile did not select', () => { + expect(defs.some((d) => d.field === 'unused')).toBe(false); + }); + + it('skips a profile column the surface does not declare, instead of guessing', () => { + const ghosted = { ...PROFILE, columns: ['name', 'ghost', 'accepted'] } as OperatorDashboardProfile; + expect(buildAdminColumnDefs(SURFACE, ghosted).map((d) => d.field ?? d.colId)) + .toEqual(['name', 'accepted', ACTIONS_COLUMN_ID]); + }); + + it('applies the profile terminology override to the header', () => { + expect(defs[0]?.headerName).toBe('Trader'); + expect(defs[6]?.headerName).toBe('Accepted'); + }); + + it('maps each contract renderer to its cell renderer', () => { + expect(defs[0]?.cellRenderer).toBe(ADMIN_CELL_RENDERERS['status-dot']); + expect(defs[1]?.cellRenderer).toBe(ADMIN_CELL_RENDERERS.tag); + expect(defs[2]?.cellRenderer).toBe(ADMIN_CELL_RENDERERS.bar); + expect(defs[3]?.cellRenderer).toBe(ADMIN_CELL_RENDERERS.ramp); + expect(defs[6]?.cellRenderer).toBeUndefined(); + }); + + it('passes the ramp variant resolved from kind / colorRamp', () => { + expect(defs[3]?.cellRendererParams).toEqual({ variant: 'ratio' }); + expect(defs[4]?.cellRendererParams).toEqual({ variant: 'signed' }); + expect(defs[5]?.cellRendererParams).toEqual({ variant: 'low-percent' }); + }); + + it('formats plain columns from their kind', () => { + const formatter = defs[6]?.valueFormatter; + expect(typeof formatter).toBe('function'); + expect(typeof formatter === 'function' ? formatter({ value: 9300 } as never) : null).toBe('9,300'); + }); + + it('gives the name column a fixed width so fitGridWidth cannot starve the tail', () => { + expect(defs[0]?.width).toBe(230); + expect(defs[0]?.minWidth).toBe(150); + }); + + it('carries contextual help into the header component params', () => { + expect(defs[3]?.headerComponent).toBe(ADMIN_CELL_RENDERERS.header); + expect(defs[3]?.headerComponentParams).toMatchObject({ + columnKey: 'owedRatio', + help: { description: 'What partners owe us.', formula: 'accepted / owed', wikiSlug: 'owed-ratio' }, + }); + expect(defs[6]?.headerComponentParams).not.toHaveProperty('help'); + }); + + it('applies the profile sort, and falls back to the surface default sort', () => { + expect(defs[3]?.sort).toBe('desc'); + expect(defs[2]?.sort).toBeUndefined(); + const noSort = { ...PROFILE, sort: undefined } as OperatorDashboardProfile; + const fallback = buildAdminColumnDefs(SURFACE, noSort); + expect(fallback[2]?.sort).toBe('desc'); + expect(fallback[3]?.sort).toBeUndefined(); + }); + + it('right-aligns from the contract', () => { + expect(defs[4]?.type).toBe('rightAligned'); + expect(defs[6]?.type).toBeUndefined(); + }); + + it('pins the actions column and sizes it for icon buttons', () => { + const actions = defs[defs.length - 1]; + expect(actions?.colId).toBe(ACTIONS_COLUMN_ID); + expect(actions?.pinned).toBe('right'); + expect(actions?.sortable).toBe(false); + // 2 profile actions -> 18 + 2*31. The React build reserved 44 + n*34 for + // text buttons and starved the data columns. + expect(actions?.width).toBe(80); + expect(actions?.minWidth).toBe(80); + }); + + it('omits the actions column when the profile selects no action', () => { + const readOnly = { ...PROFILE, actions: [] } as unknown as OperatorDashboardProfile; + expect(buildAdminColumnDefs(SURFACE, readOnly).some((d) => d.colId === ACTIONS_COLUMN_ID)).toBe(false); + }); +}); + +describe('row actions', () => { + it('resolves only the actions the profile selects, in profile order', () => { + expect(resolveRowActions(SURFACE, PROFILE).map((a) => a.key)).toEqual(['stop_trade', 'ban']); + }); + + it('ignores an action key the surface never declared', () => { + const bogus = { ...PROFILE, actions: ['ban', 'launch_missiles'] } as OperatorDashboardProfile; + expect(resolveRowActions(SURFACE, bogus).map((a) => a.key)).toEqual(['ban']); + }); + + it('disables an action whose effect the row already has', () => { + const ban = SURFACE.rowActions![2]!; + expect(isActionDisabled(ban, { status: 'banned' })).toBe(true); + expect(isActionDisabled(ban, { status: 'active' })).toBe(false); + expect(isActionDisabled(ban, {})).toBe(false); + }); + + it('sizes the actions column from the action count', () => { + expect(actionsColumnWidth(0)).toBe(18); + expect(actionsColumnWidth(3)).toBe(111); + }); +}); diff --git a/packages/admin-svelte/src/renderers.ts b/packages/admin-svelte/src/renderers.ts new file mode 100644 index 00000000..4c6f5211 --- /dev/null +++ b/packages/admin-svelte/src/renderers.ts @@ -0,0 +1,415 @@ +// SPDX-FileCopyrightText: 2026 Agonist Development AB +// SPDX-License-Identifier: EUPL-1.2 +// +// AG Grid ships official wrappers for React / Angular / Vue, not Svelte. This +// package therefore drives the FRAMEWORK-NEUTRAL vanilla `createGrid` API, and +// every custom cell renderer is a plain DOM class (`init` / `getGui` / +// `refresh`) instead of a framework component. That is not a downgrade: it makes +// the render layer dependency-free — no AntD, no icon library, no Svelte +// runtime inside a cell. +// +// The contract drives everything here. A column's `renderer` / `kind` / +// `colorRamp` picks the cell; nothing keys off a column NAME, so no board can +// be special-cased. +import type { + AdminGridSurface, + AdminMetricColumn, + AdminRowAction, + AdminColumnHelp, + OperatorDashboardProfile, +} from '@ariada-org/admin-surface'; +import type { ColDef, ICellRendererParams, IHeaderParams } from 'ag-grid-community'; + +import { + ADMIN_GRID_ACTION_EFFECT, + DEFAULT_WIKI, + barContent, + escapeHtml, + formatByKind, + rampContent, + rampVariant, + statusColor, + tagColor, + wikiHref, + type AdminGridRow, + type AdminGridWiki, + type RampVariant, +} from './format'; +import { actionIconSvg } from './icons'; +import { DEFAULT_I18N, type ResolvedAdminSvelteI18n } from './i18n'; + +/** the pinned actions column id — the one column the grid adds itself. */ +export const ACTIONS_COLUMN_ID = 'actions'; +/** icon button 26px + 5px gap; 18px of column padding. */ +const ACTION_BUTTON_SLOT = 31; +const ACTION_COLUMN_PADDING = 18; + +/** width of the pinned actions column for a given number of row actions. */ +export function actionsColumnWidth(actionCount: number): number { + return ACTION_COLUMN_PADDING + Math.max(0, actionCount) * ACTION_BUTTON_SLOT; +} + +/** the row actions a profile selects, in the profile's order. */ +export function resolveRowActions( + surface: AdminGridSurface, + profile: OperatorDashboardProfile, +): AdminRowAction[] { + const byKey = new Map((surface.rowActions ?? []).map((action) => [action.key, action])); + return (profile.actions ?? []) + .map((key) => byKey.get(key)) + .filter((action): action is AdminRowAction => Boolean(action)); +} + +/** + * An action is disabled when the row already sits in the status the action would + * move it to (banning a banned row). Derived from the contract's effect table — + * never from a hard-coded board rule. + */ +export function isActionDisabled(action: AdminRowAction, row: AdminGridRow): boolean { + const effect = ADMIN_GRID_ACTION_EFFECT[action.key]; + return effect !== undefined && String(row.status ?? '') === effect; +} + +/** a row action awaiting confirmation, handed up to the Svelte layer. */ +export interface ConfirmRequest { + readonly row: AdminGridRow; + readonly action: AdminRowAction; + /** the button that triggered it, so the popover can be anchored to it. */ + readonly anchor: DOMRect; +} + +// ── vanilla cell renderers ─────────────────────────────────────────────────── + +class StatusDotCell { + private gui!: HTMLElement; + + init(params: ICellRendererParams): void { + this.gui = document.createElement('span'); + this.gui.className = 'adm-status-cell'; + const dot = document.createElement('span'); + dot.className = 'adm-status-dot'; + dot.style.background = statusColor((params.data as AdminGridRow | undefined)?.status); + const url = (params.data as AdminGridRow | undefined)?.url; + const text = String(params.value ?? ''); + if (typeof url === 'string' && /^https?:\/\//i.test(url)) { + const link = document.createElement('a'); + link.href = url; + link.target = '_blank'; + link.rel = 'noreferrer'; + link.className = 'adm-status-link'; + link.textContent = `${text} ↗`; + link.addEventListener('click', (event) => event.stopPropagation()); + this.gui.append(dot, link); + } else { + const name = document.createElement('span'); + name.className = 'adm-status-name'; + name.textContent = text; + this.gui.append(dot, name); + } + } + + getGui(): HTMLElement { return this.gui; } + refresh(): boolean { return false; } +} + +class TagCell { + private gui!: HTMLElement; + + init(params: ICellRendererParams): void { + this.gui = document.createElement('span'); + this.gui.className = 'adm-tag'; + const value = String(params.value ?? ''); + const color = tagColor(value); + this.gui.textContent = value; + this.gui.style.color = color; + this.gui.style.background = `${color}1a`; + this.gui.style.borderColor = `${color}40`; + } + + getGui(): HTMLElement { return this.gui; } + refresh(): boolean { return false; } +} + +class BarCell { + private gui!: HTMLElement; + + init(params: ICellRendererParams): void { + const content = barContent(params.value); + this.gui = document.createElement('div'); + this.gui.className = 'adm-bar'; + const track = document.createElement('div'); + track.className = 'adm-bar-track'; + const fill = document.createElement('div'); + fill.className = 'adm-bar-fill'; + fill.style.width = `${content.percent}%`; + fill.style.background = content.color; + track.append(fill); + const label = document.createElement('span'); + label.className = 'adm-bar-label'; + label.style.color = content.color; + label.textContent = content.text; + this.gui.append(track, label); + } + + getGui(): HTMLElement { return this.gui; } + refresh(): boolean { return false; } +} + +interface RampCellParams extends ICellRendererParams { + variant?: RampVariant; +} + +class RampCell { + private gui!: HTMLElement; + + init(params: RampCellParams): void { + const content = rampContent(params.variant ?? 'signed', params.value); + this.gui = document.createElement('span'); + this.gui.className = 'adm-chip'; + this.gui.textContent = content.text; + this.gui.style.background = content.bg; + this.gui.style.color = content.fg; + } + + getGui(): HTMLElement { return this.gui; } + refresh(): boolean { return false; } +} + +interface ActionsCellParams extends ICellRendererParams { + rowActions?: readonly AdminRowAction[]; + requestConfirm?: (request: ConfirmRequest) => void; +} + +class ActionsCell { + private gui!: HTMLElement; + + init(params: ActionsCellParams): void { + this.gui = document.createElement('div'); + this.gui.className = 'adm-actions'; + const row = params.data as AdminGridRow | undefined; + if (!row) return; + for (const action of params.rowActions ?? []) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = action.danger ? 'adm-icon-btn adm-danger' : 'adm-icon-btn'; + button.innerHTML = actionIconSvg(action.key); + button.title = action.label; + button.setAttribute('aria-label', action.label); + button.disabled = isActionDisabled(action, row); + button.addEventListener('click', (event) => { + event.stopPropagation(); + // Hand off to the Svelte layer so the confirm is a real anchored + // popover with a reason field, never a browser prompt. + params.requestConfirm?.({ row, action, anchor: button.getBoundingClientRect() }); + }); + this.gui.append(button); + } + } + + getGui(): HTMLElement { return this.gui; } + refresh(): boolean { return false; } +} + +interface MetricHeaderParams extends IHeaderParams { + help?: AdminColumnHelp; + columnKey?: string; + wiki?: AdminGridWiki; + learnMore?: string; +} + +/** + * Header cell with the ⓘ contextual-help popover (description + formula + wiki + * link). The help text comes from the column contract, so documentation and + * board stay in sync by construction. + */ +class MetricHeader { + private gui!: HTMLElement; + private popover: HTMLElement | null = null; + private closeTimer: ReturnType | null = null; + + init(params: MetricHeaderParams): void { + this.gui = document.createElement('div'); + this.gui.className = 'adm-header'; + const title = document.createElement('span'); + title.className = 'adm-header-title'; + title.textContent = params.displayName; + title.title = params.displayName; + if (params.enableSorting) { + title.classList.add('adm-sortable'); + title.addEventListener('click', (event) => params.progressSort(event.shiftKey)); + } + this.gui.append(title); + + const help = params.help; + if (!help) return; + const info = document.createElement('span'); + info.className = 'adm-header-info'; + info.textContent = 'ⓘ'; + info.setAttribute('role', 'note'); + info.setAttribute('aria-label', help.description); + info.addEventListener('click', (event) => event.stopPropagation()); + info.addEventListener('mouseenter', () => this.open(info, params, help)); + info.addEventListener('mouseleave', () => this.scheduleClose()); + this.gui.append(info); + } + + private open(anchor: HTMLElement, params: MetricHeaderParams, help: AdminColumnHelp): void { + if (this.closeTimer) { clearTimeout(this.closeTimer); this.closeTimer = null; } + if (this.popover) return; + const wiki = params.wiki ?? DEFAULT_WIKI; + const columnKey = params.columnKey ?? params.displayName; + const popover = document.createElement('div'); + popover.className = 'adm-popover adm-anim-pop'; + popover.innerHTML = + `
${escapeHtml(params.displayName)}
` + + `
${escapeHtml(help.description)}
` + + (help.formula ? `
${escapeHtml(help.formula)}
` : '') + + `${escapeHtml(params.learnMore ?? DEFAULT_I18N.learnMore)}`; + popover.addEventListener('mouseenter', () => { + if (this.closeTimer) { clearTimeout(this.closeTimer); this.closeTimer = null; } + }); + popover.addEventListener('mouseleave', () => this.scheduleClose()); + const rect = anchor.getBoundingClientRect(); + popover.style.left = `${Math.max(8, Math.min(rect.left - 8, window.innerWidth - 360))}px`; + popover.style.top = `${rect.bottom + 8}px`; + document.body.append(popover); + this.popover = popover; + } + + private scheduleClose(): void { + if (this.closeTimer) clearTimeout(this.closeTimer); + this.closeTimer = setTimeout(() => this.close(), 160); + } + + private close(): void { + this.popover?.remove(); + this.popover = null; + this.closeTimer = null; + } + + getGui(): HTMLElement { return this.gui; } + refresh(): boolean { return false; } + destroy(): void { + if (this.closeTimer) clearTimeout(this.closeTimer); + this.close(); + } +} + +/** the renderer classes, exported so a consumer can reuse one in its own colDef. */ +export const ADMIN_CELL_RENDERERS = Object.freeze({ + 'status-dot': StatusDotCell, + tag: TagCell, + bar: BarCell, + ramp: RampCell, + actions: ActionsCell, + header: MetricHeader, +}); + +// ── contract -> AG Grid column definitions ─────────────────────────────────── + +export interface BuildColumnDefsOptions { + readonly wiki?: AdminGridWiki; + readonly i18n?: ResolvedAdminSvelteI18n; + /** invoked when a row-action button is pressed; the Svelte layer confirms it. */ + readonly requestConfirm?: (request: ConfirmRequest) => void; +} + +/** default width of the `status-dot` name column when the contract omits one. */ +const NAME_COLUMN_WIDTH = 230; +const NAME_COLUMN_MIN_WIDTH = 150; + +function toColDef( + column: AdminMetricColumn, + headerOverride: string | undefined, + options: BuildColumnDefsOptions, +): ColDef { + const i18n = options.i18n ?? DEFAULT_I18N; + const base: ColDef = { + field: column.key, + headerName: headerOverride ?? column.label, + ...(column.width === undefined ? {} : { width: column.width }), + ...(column.pin ? { pinned: column.pin } : {}), + ...(column.align === 'right' ? { type: 'rightAligned' } : {}), + ...(column.align === 'center' ? { cellStyle: { textAlign: 'center' } } : {}), + headerComponent: MetricHeader, + headerComponentParams: { + ...(column.help ? { help: column.help } : {}), + columnKey: column.key, + wiki: options.wiki ?? DEFAULT_WIKI, + learnMore: i18n.learnMore, + }, + }; + + switch (column.renderer) { + case 'status-dot': + // A fixed-ish name column, NOT flex: a flex column makes AG Grid's + // fitGridWidth leave the others at natural width and overflow, which + // pushes the trailing columns off-screen on an 8-11 column board. + base.width = column.width ?? NAME_COLUMN_WIDTH; + base.minWidth = NAME_COLUMN_MIN_WIDTH; + base.cellRenderer = StatusDotCell; + return base; + case 'tag': + base.cellRenderer = TagCell; + return base; + case 'bar': + base.cellRenderer = BarCell; + return base; + case 'ramp': + base.cellRenderer = RampCell; + base.cellRendererParams = { variant: rampVariant(column) }; + return base; + default: + break; + } + + if (column.kind === 'percent' || column.kind === 'currency' + || column.kind === 'duration' || column.kind === 'count') { + base.valueFormatter = (params) => formatByKind(params.value, column.kind); + } + return base; +} + +/** + * Build the AG Grid column defs for a profile over a grid surface: the profile + * picks the columns, their order, their terminology and its sort; the surface + * declares what each column MEANS. Unknown profile columns are skipped rather + * than guessed at. + */ +export function buildAdminColumnDefs( + surface: AdminGridSurface, + profile: OperatorDashboardProfile, + options: BuildColumnDefsOptions = {}, +): ColDef[] { + const byKey = new Map(surface.columns.map((column) => [column.key, column])); + const defs: ColDef[] = []; + for (const key of profile.columns) { + const column = byKey.get(key); + if (!column) continue; + const def = toColDef(column, profile.terminology?.[key], options); + const sort = profile.sort ?? surface.defaultSort; + if (sort && sort.key === key) def.sort = sort.dir; + defs.push(def); + } + const rowActions = resolveRowActions(surface, profile); + if (rowActions.length > 0) { + const width = actionsColumnWidth(rowActions.length); + defs.push({ + colId: ACTIONS_COLUMN_ID, + headerName: '', + pinned: 'right', + width, + minWidth: width, + suppressSizeToFit: true, + sortable: false, + filter: false, + resizable: false, + cellRenderer: ActionsCell, + cellRendererParams: { + rowActions, + ...(options.requestConfirm ? { requestConfirm: options.requestConfirm } : {}), + }, + }); + } + return defs; +} diff --git a/packages/admin-svelte/src/ssr.test.ts b/packages/admin-svelte/src/ssr.test.ts new file mode 100644 index 00000000..80c53159 --- /dev/null +++ b/packages/admin-svelte/src/ssr.test.ts @@ -0,0 +1,178 @@ +// Server-side render tests: the components are rendered for real (through +// `svelte/server`) and asserted on their markup. No DOM environment is +// installed in this repo, so nothing is mounted and no event is dispatched — +// click paths (confirm popover, drawer editing, hover crosshair) are covered by +// the consuming app's Playwright suite. +import { + ADMIN_GRID_SCHEMA, + defineAdminChartSpec, + defineAdminGridSurface, + type AdminGridSurface, +} from '@ariada-org/admin-surface'; +import { render } from 'svelte/server'; +import { describe, expect, it } from 'vitest'; + +import AdminGrid from './AdminGrid.svelte'; +import MetricChart from './MetricChart.svelte'; +import RowDetailDrawer from './RowDetailDrawer.svelte'; + +const ROWS = [ + { id: '1', name: 'alpha', accepted: 900, blocked: 100 }, + { id: '2', name: 'beta', accepted: 400, blocked: 600 }, +]; + +describe(' server render', () => { + it('renders a column chart with a bar per series and a category label', () => { + const spec = defineAdminChartSpec({ + type: 'column', + title: 'Accepted vs blocked', + categoryKey: 'name', + valueKeys: ['accepted', 'blocked'], + colors: ['#059669', '#dc2626'], + height: 180, + }); + const { body } = render(MetricChart, { props: { spec, rows: ROWS } }); + expect(body).toContain('Accepted vs blocked'); + // 2 categories x 2 series + expect(body.match(/]*fill="url\(#/g) ?? []).toHaveLength(4); + // one hover band per category + expect(body.match(/]*role="presentation"/g) ?? []).toHaveLength(2); + expect(body).toContain('alpha'); + expect(body).toContain('>beta<'); + // legend swatches use the declared colours + expect(body).toContain('#059669'); + expect(body).toContain('#dc2626'); + // a gradient per series + expect(body.match(/ { + const spec = defineAdminChartSpec({ type: 'line', categoryKey: 'name', valueKeys: ['accepted', 'blocked'] }); + const { body } = render(MetricChart, { props: { spec, rows: ROWS } }); + expect(body.match(/]*fill="url\(#/g) ?? []).toHaveLength(0); + }); + + it('renders a funnel with conversion labels', () => { + const spec = defineAdminChartSpec({ type: 'funnel', categoryKey: 'name', valueKeys: ['accepted'] }); + const { body } = render(MetricChart, { props: { spec, rows: ROWS } }); + expect(body).toContain('100%'); + expect(body).toContain('44%'); + }); + + it('renders a graph relationship map as nodes and edges', () => { + const spec = defineAdminChartSpec({ + type: 'graph', + title: 'Relationship map', + nodes: [{ id: 'a', label: 'Set A', group: 'set' }, { id: 'b', group: 'item' }], + edges: [{ from: 'a', to: 'b', label: 'contains' }], + }); + const { body } = render(MetricChart, { props: { spec, rows: [] } }); + expect(body.match(/contains'); + expect(body).toContain('Set A'); + }); + + it('renders the empty state instead of an axis-less chart', () => { + const spec = defineAdminChartSpec({ type: 'column', categoryKey: 'name', valueKeys: ['accepted'] }); + const { body } = render(MetricChart, { props: { spec, rows: [] } }); + expect(body).toContain('no data'); + expect(body).not.toContain(' server render', () => { + // The grid itself is created in onMount, which SSR never runs; what this + // proves is that the module graph is server-safe (AG Grid's vanilla API does + // not touch the DOM at import time) and that the chrome renders. + it('renders the quick filter, the row counter and the grid viewport', () => { + const { body } = render(AdminGrid, { + props: { surface: SURFACE, profile: PROFILE as never, rows: [ROW, { ...ROW, id: '8' }], height: 400 }, + }); + expect(body).toContain('adm-grid-viewport'); + expect(body).toContain('Search the table…'); + expect(body).toContain('height:400px'); + expect(body).toContain('>2'); + }); + + it('can be rendered without the quick filter', () => { + const { body } = render(AdminGrid, { + props: { surface: SURFACE, profile: PROFILE as never, rows: [ROW], quickFilter: false }, + }); + expect(body).not.toContain('adm-grid-toolbar'); + expect(body).toContain('adm-grid-viewport'); + }); +}); + +describe(' server render', () => { + it('renders every surface column with the GRID formatting (renderer over kind)', () => { + const { body } = render(RowDetailDrawer, { props: { surface: SURFACE, row: ROW, onClose: () => {} } }); + for (const column of SURFACE.columns) expect(body).toContain(column.label); + // The defect this drawer exists to avoid: fraudPct is kind:'percent' with a + // 0-100 value, so a kind-only formatter would print "100.0%" here. + expect(body).toContain('1%'); + expect(body).not.toContain('100.0%'); + expect(body).toContain('+3,120'); + expect(body).toContain('1.48'); + expect(body).toContain('9,300'); + expect(body).toContain('23.2%'); + expect(body).toContain('alpha'); + }); + + it('renders nothing when no row is open', () => { + const { body } = render(RowDetailDrawer, { props: { surface: SURFACE, row: null, onClose: () => {} } }); + expect(body.replace(//g, '').trim()).toBe(''); + }); + + it('hides the edit affordance when the consumer passes no save handler', () => { + const { body } = render(RowDetailDrawer, { props: { surface: SURFACE, row: ROW, onClose: () => {} } }); + expect(body).not.toContain('>Edit<'); + }); + + it('shows the edit affordance when a save handler is passed', () => { + const { body } = render(RowDetailDrawer, { + props: { surface: SURFACE, row: ROW, onClose: () => {}, onSave: () => {} }, + }); + expect(body).toContain('Edit'); + }); + + it('accepts consumer locale strings instead of the English defaults', () => { + const { body } = render(RowDetailDrawer, { + props: { + surface: SURFACE, + row: ROW, + onClose: () => {}, + i18n: { detailTitle: 'Операция', parameters: 'Параметры' }, + }, + }); + expect(body).toContain('Операция'); + expect(body).toContain('Параметры'); + expect(body).not.toContain('Operation'); + }); +}); diff --git a/packages/admin-svelte/src/theme.ts b/packages/admin-svelte/src/theme.ts new file mode 100644 index 00000000..f16f9a94 --- /dev/null +++ b/packages/admin-svelte/src/theme.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Agonist Development AB +// SPDX-License-Identifier: EUPL-1.2 +// +// The AG Grid theme for every Agonist admin surface. Kept in one place so a +// board can never bring its own grid skin — a profile changes CONTENT only. +import { themeQuartz } from 'ag-grid-community'; +import type { Theme } from 'ag-grid-community'; + +export type AdminColorScheme = 'light' | 'dark'; + +export interface AdminGridThemeOptions { + /** the ONE permitted visual knob, taken from the dashboard profile. */ + readonly accent?: string; + readonly scheme?: AdminColorScheme; + readonly fontFamily?: string; +} + +export const DEFAULT_ACCENT = '#0d9488'; +const DEFAULT_FONT = 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + +const LIGHT = { + headerBackgroundColor: '#fbfcfd', + headerTextColor: '#475569', + backgroundColor: '#ffffff', + oddRowBackgroundColor: '#ffffff', + borderColor: '#eef0f4', + rowHoverColor: '#f8fafc', + foregroundColor: '#0b1220', +} as const; + +// A real cold neutral ramp, not an inversion of the light theme — the mistake +// that makes most dark modes look muddy. Matches tokens.css's dark scheme. +const DARK = { + headerBackgroundColor: '#141924', + headerTextColor: '#9aa5b6', + backgroundColor: '#11151e', + oddRowBackgroundColor: '#11151e', + borderColor: '#1e2431', + rowHoverColor: '#151b26', + foregroundColor: '#e8ecf3', +} as const; + +/** Build the shared grid theme. Only accent, scheme and font are configurable. */ +export function createAdminGridTheme(options: AdminGridThemeOptions = {}): Theme { + const palette = options.scheme === 'dark' ? DARK : LIGHT; + return themeQuartz.withParams({ + accentColor: options.accent ?? DEFAULT_ACCENT, + fontFamily: options.fontFamily ?? DEFAULT_FONT, + fontSize: 13.5, + headerFontWeight: 600, + headerFontSize: 12.5, + borderRadius: 8, + wrapperBorderRadius: 14, + cellHorizontalPadding: 10, + ...palette, + }); +} diff --git a/packages/admin-svelte/src/tokens.css b/packages/admin-svelte/src/tokens.css new file mode 100644 index 00000000..a596f438 --- /dev/null +++ b/packages/admin-svelte/src/tokens.css @@ -0,0 +1,537 @@ +/* SPDX-FileCopyrightText: 2026 Agonist Development AB + * SPDX-License-Identifier: EUPL-1.2 + * + * @ariada-org/admin-svelte — design tokens, primitives and motion. + * + * ONE stylesheet, plain CSS. No Tailwind, no preprocessor, no vendor theme: a + * consumer imports this file and gets the whole surface. Tailwind (or any other + * utility layer) may still be used by the consumer, but is never required here. + * + * import '@ariada-org/admin-svelte/tokens.css'; + * + * The philosophy is shadcn's: the system lives in CUSTOM PROPERTIES you can + * override, not inside a component library you have to fight. Every token is + * namespaced `--adm-*` so it cannot collide with the consumer's own theme. + * + * Dark scheme: set `data-adm-scheme="dark"` on (or any ancestor). + * + * Classes generated by the vanilla AG Grid cell renderers live here too — they + * are created outside Svelte's scoped-style boundary, so they must be global. + */ + +:root { + --adm-canvas: #f7f8fa; + --adm-surface: #ffffff; + --adm-surface-raised: #ffffff; + --adm-surface-sunken: #fbfcfd; + --adm-border: #e8eaee; + --adm-border-strong: #d8dce3; + --adm-muted: #6b7280; + --adm-fg: #0b1220; + --adm-fg-soft: #334155; + + --adm-primary: #0d9488; + --adm-primary-strong: #0f766e; + --adm-primary-weak: #0d94881a; + --adm-success: #059669; + --adm-warning: #d97706; + --adm-danger: #dc2626; + --adm-danger-weak: #fef2f2; + --adm-danger-border: #fecaca; + + --adm-radius-card: 14px; + --adm-radius-control: 8px; + --adm-radius-chip: 6px; + + --adm-shadow-xs: 0 1px 2px rgba(11, 18, 32, 0.04); + --adm-shadow-sm: 0 1px 3px rgba(11, 18, 32, 0.06), 0 1px 2px rgba(11, 18, 32, 0.04); + --adm-shadow-md: 0 6px 16px rgba(11, 18, 32, 0.08), 0 2px 6px rgba(11, 18, 32, 0.04); + --adm-shadow-lg: 0 24px 48px rgba(11, 18, 32, 0.14), 0 8px 16px rgba(11, 18, 32, 0.06); + + /* Motion measured from the Ant Design reference build, then extended. */ + --adm-ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --adm-ease-std: cubic-bezier(0.645, 0.045, 0.355, 1); + --adm-dur-fast: 140ms; + --adm-dur: 200ms; + --adm-dur-slow: 300ms; + + --adm-font: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --adm-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* A real cold neutral ramp, NOT an inversion of the light theme — the mistake + that makes most dark modes look muddy. */ +[data-adm-scheme="dark"] { + --adm-canvas: #0a0d14; + --adm-surface: #11151e; + --adm-surface-raised: #151a24; + --adm-surface-sunken: #141924; + --adm-border: #1e2431; + --adm-border-strong: #2a3242; + --adm-muted: #8a94a6; + --adm-fg: #e8ecf3; + --adm-fg-soft: #c7d0de; + --adm-primary: #2dd4bf; + --adm-primary-strong: #14b8a6; + --adm-primary-weak: #2dd4bf1f; + --adm-danger-weak: #2a1416; + --adm-danger-border: #7f1d1d; + --adm-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.3); + --adm-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.35); + --adm-shadow-lg: 0 28px 60px rgba(0, 0, 0, 0.62), 0 10px 20px rgba(0, 0, 0, 0.4); + color-scheme: dark; +} + +/* ── numerals ────────────────────────────────────────────────────────────── */ +/* Every figure in a dense board should be tabular. It is the single biggest + readability win on a metrics table, and no component library does it. */ +.adm-tabnum, +.adm-stat-value, +.ag-cell, +.adm-chip, +.adm-bar-label, +.adm-field-value { + font-variant-numeric: tabular-nums; +} + +/* ── primitives ──────────────────────────────────────────────────────────── */ +.adm-card { + background: var(--adm-surface); + border: 1px solid var(--adm-border); + border-radius: var(--adm-radius-card); + box-shadow: var(--adm-shadow-sm); + transition: + box-shadow var(--adm-dur) var(--adm-ease-out), + transform var(--adm-dur) var(--adm-ease-out), + border-color var(--adm-dur) var(--adm-ease-out); +} +.adm-card-hover:hover { + box-shadow: var(--adm-shadow-md); + transform: translateY(-1px); + border-color: var(--adm-border-strong); +} + +.adm-btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--adm-border); + background: var(--adm-surface); + color: var(--adm-fg-soft); + border-radius: var(--adm-radius-control); + font-family: inherit; + font-size: 13px; + line-height: 1; + padding: 6px 11px; + cursor: pointer; + transition: + background var(--adm-dur-fast) var(--adm-ease-std), + border-color var(--adm-dur-fast) var(--adm-ease-std), + color var(--adm-dur-fast) var(--adm-ease-std), + box-shadow var(--adm-dur-fast) var(--adm-ease-std), + transform var(--adm-dur-fast) var(--adm-ease-std); +} +.adm-btn:hover:not(:disabled) { + border-color: var(--adm-primary); + color: var(--adm-primary); + background: var(--adm-primary-weak); +} +.adm-btn:active:not(:disabled) { transform: translateY(0.5px); } +.adm-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--adm-primary-weak); + border-color: var(--adm-primary); +} +.adm-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.adm-btn-primary { + background: var(--adm-primary); + border-color: var(--adm-primary); + color: #fff; +} +.adm-btn-primary:hover:not(:disabled) { + background: var(--adm-primary-strong); + border-color: var(--adm-primary-strong); + color: #fff; +} +.adm-btn-danger { + background: var(--adm-danger); + border-color: var(--adm-danger); + color: #fff; +} +.adm-btn-danger:hover:not(:disabled) { + background: #b91c1c; + border-color: #b91c1c; + color: #fff; +} + +.adm-input, +.adm-textarea { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--adm-border); + border-radius: var(--adm-radius-control); + background: var(--adm-surface); + color: var(--adm-fg); + font-family: inherit; + font-size: 13px; + padding: 6px 10px; + transition: + border-color var(--adm-dur-fast) var(--adm-ease-std), + box-shadow var(--adm-dur-fast) var(--adm-ease-std); +} +.adm-textarea { resize: none; line-height: 1.45; } +.adm-input:focus, +.adm-textarea:focus { + outline: none; + border-color: var(--adm-primary); + box-shadow: 0 0 0 3px var(--adm-primary-weak); +} +.adm-input::placeholder, +.adm-textarea::placeholder { color: #9aa2af; } + +.adm-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border-radius: var(--adm-radius-chip); + border: 1px solid var(--adm-border); + background: var(--adm-surface); + color: var(--adm-muted); + cursor: pointer; + transition: + color var(--adm-dur-fast) var(--adm-ease-std), + border-color var(--adm-dur-fast) var(--adm-ease-std), + background var(--adm-dur-fast) var(--adm-ease-std); +} +.adm-icon-btn:hover:not(:disabled) { + color: var(--adm-primary); + border-color: var(--adm-primary); + background: var(--adm-primary-weak); +} +.adm-icon-btn.adm-danger:hover:not(:disabled) { + color: var(--adm-danger); + border-color: var(--adm-danger-border); + background: var(--adm-danger-weak); +} +.adm-icon-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--adm-primary-weak); + border-color: var(--adm-primary); +} +.adm-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* segmented control (profile / scheme switchers in a consumer's shell) */ +.adm-seg { + display: inline-flex; + padding: 3px; + gap: 2px; + background: var(--adm-surface-sunken); + border: 1px solid var(--adm-border); + border-radius: 10px; +} +.adm-seg button { + border: 0; + background: transparent; + color: var(--adm-muted); + cursor: pointer; + font-family: inherit; + font-size: 13px; + padding: 5px 12px; + border-radius: 7px; + transition: all var(--adm-dur-fast) var(--adm-ease-std); +} +.adm-seg button:hover { color: var(--adm-fg); } +.adm-seg button[aria-pressed="true"] { + background: var(--adm-surface); + color: var(--adm-fg); + box-shadow: var(--adm-shadow-xs); + font-weight: 600; +} + +/* ── grid chrome ─────────────────────────────────────────────────────────── */ +.adm-grid { display: flex; flex-direction: column; min-height: 0; } +.adm-grid-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} +.adm-grid-viewport { flex: 1; min-height: 0; } +.adm-search { position: relative; display: inline-flex; } +.adm-search .adm-input { padding-left: 30px; width: 288px; max-width: 100%; } +.adm-search-icon { + pointer-events: none; + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + display: inline-flex; +} +.adm-grid-count { + font-size: 12px; + color: var(--adm-muted); + font-variant-numeric: tabular-nums; +} +.adm-grid-count b { color: var(--adm-fg); } + +/* ── cells rendered by the vanilla AG Grid renderers ─────────────────────── */ +.adm-status-cell { display: inline-flex; align-items: center; gap: 8px; } +.adm-status-dot { width: 8px; height: 8px; border-radius: 4px; flex: none; } +.adm-status-name { font-weight: 600; } +.adm-status-link { font-weight: 600; color: var(--adm-primary); text-decoration: none; } +.adm-status-link:hover { text-decoration: underline; } + +.adm-tag { + display: inline-block; + border: 1px solid transparent; + padding: 1px 8px; + border-radius: var(--adm-radius-chip); + font-size: 12px; + line-height: 18px; +} + +.adm-chip { + display: inline-block; + padding: 2px 8px; + border-radius: var(--adm-radius-control); + font-weight: 600; +} + +.adm-bar { display: flex; align-items: center; gap: 8px; height: 100%; } +.adm-bar-track { + flex: 1; + height: 7px; + border-radius: 4px; + background: rgba(15, 23, 42, 0.12); + overflow: hidden; +} +[data-adm-scheme="dark"] .adm-bar-track { background: rgba(226, 232, 240, 0.16); } +.adm-bar-fill { height: 100%; transition: width var(--adm-dur) var(--adm-ease-out); } +.adm-bar-label { min-width: 26px; text-align: right; } + +.adm-actions { display: flex; gap: 5px; align-items: center; height: 100%; } + +.adm-header { display: flex; align-items: center; gap: 6px; width: 100%; } +.adm-header-title { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + user-select: none; +} +.adm-header-title.adm-sortable { cursor: pointer; } +.adm-header-info { color: #94a3b8; cursor: help; font-size: 12px; } + +/* ── popovers (header help + row-action confirm) ─────────────────────────── */ +.adm-popover { + position: fixed; + z-index: 9999; + max-width: 340px; + background: var(--adm-surface); + border: 1px solid var(--adm-border); + border-radius: 12px; + box-shadow: var(--adm-shadow-lg); + padding: 12px 14px; + font-size: 12.5px; + line-height: 1.5; + color: var(--adm-fg-soft); +} +.adm-popover-title { + font-weight: 650; + margin-bottom: 5px; + color: var(--adm-fg); + letter-spacing: -0.01em; +} +.adm-popover-body { color: var(--adm-muted); } +.adm-popover-formula { + margin-top: 8px; + font-family: var(--adm-font-mono); + font-size: 11px; + background: var(--adm-surface-sunken); + border: 1px solid var(--adm-border); + padding: 7px 9px; + border-radius: 8px; + color: var(--adm-fg-soft); +} +.adm-popover-link { + display: inline-block; + margin-top: 8px; + color: var(--adm-primary); + font-weight: 550; + text-decoration: none; +} +.adm-popover-link:hover { text-decoration: underline; } + +.adm-scrim { position: fixed; inset: 0; z-index: 40; } +.adm-confirm { + position: fixed; + z-index: 50; + width: 300px; + padding: 12px; + box-shadow: var(--adm-shadow-lg); +} +.adm-confirm-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-size: 13px; + font-weight: 600; + color: var(--adm-fg); +} +.adm-confirm-dot { width: 6px; height: 6px; border-radius: 3px; flex: none; } +.adm-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } + +/* ── row-operation drawer ────────────────────────────────────────────────── */ +.adm-drawer-mask { + position: fixed; + inset: 0; + z-index: 40; + background: rgba(11, 18, 32, 0.25); + backdrop-filter: blur(2px); + border: 0; + padding: 0; +} +.adm-drawer { + position: fixed; + right: 0; + top: 0; + z-index: 50; + height: 100%; + width: min(580px, 100vw); + overflow-y: auto; + border-left: 1px solid var(--adm-border); + background: var(--adm-surface); + box-shadow: var(--adm-shadow-lg); + color: var(--adm-fg); +} +.adm-drawer-header { + position: sticky; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--adm-border); + background: var(--adm-surface); + padding: 14px 20px; +} +.adm-drawer-title { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; } +.adm-drawer-title b { color: var(--adm-primary); } +.adm-drawer-tools { display: flex; align-items: center; gap: 8px; } +.adm-drawer-body { padding: 20px; } +.adm-drawer-slot { margin-bottom: 16px; } +.adm-section-label { + margin-bottom: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--adm-muted); +} +.adm-fields { + overflow: hidden; + border-radius: 12px; + border: 1px solid var(--adm-border); +} +.adm-field-row { + display: grid; + grid-template-columns: 168px 1fr; + align-items: center; + border-bottom: 1px solid var(--adm-border); +} +.adm-field-row:last-child { border-bottom: 0; } +.adm-field-row:nth-child(odd) { background: var(--adm-surface-sunken); } +.adm-field-label { padding: 10px 14px; font-size: 12.5px; color: var(--adm-muted); } +.adm-field-value { padding: 8px 14px; font-size: 13px; } + +/* ── chart ───────────────────────────────────────────────────────────────── */ +.adm-chart { width: 100%; } +.adm-chart-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; } +.adm-chart-title { font-size: 12px; font-weight: 600; letter-spacing: -0.01em; color: var(--adm-fg); } +.adm-chart-unit { font-size: 10px; color: var(--adm-muted); } +.adm-chart-legend { display: inline-flex; gap: 12px; flex-wrap: wrap; } +.adm-chart-legend span { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--adm-muted); } +.adm-swatch { width: 8px; height: 8px; border-radius: 4px; display: inline-block; } +.adm-chart-empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--adm-muted); + font-size: 12px; +} +.adm-chart-tooltip { + display: inline-flex; + align-items: center; + gap: 12px; + margin-top: 4px; + padding: 6px 12px; + font-size: 11.5px; + box-shadow: var(--adm-shadow-md); +} +.adm-chart-tooltip span { display: inline-flex; align-items: center; gap: 6px; color: var(--adm-muted); font-variant-numeric: tabular-nums; } +.adm-chart-tooltip b { color: var(--adm-fg); } + +/* ── motion ──────────────────────────────────────────────────────────────── */ +@keyframes adm-fade-in { from { opacity: 0 } to { opacity: 1 } } +@keyframes adm-slide-in-right { from { transform: translateX(20px); opacity: 0 } to { transform: translateX(0); opacity: 1 } } +@keyframes adm-rise-in { from { transform: translateY(8px) scale(0.985); opacity: 0 } to { transform: translateY(0) scale(1); opacity: 1 } } +@keyframes adm-pop-in { from { transform: translateY(-4px) scale(0.97); opacity: 0 } to { transform: translateY(0) scale(1); opacity: 1 } } +@keyframes adm-grow-y { from { transform: scaleY(0) } to { transform: scaleY(1) } } + +.adm-anim-fade { animation: adm-fade-in var(--adm-dur) var(--adm-ease-out) both; } +.adm-anim-drawer { animation: adm-slide-in-right var(--adm-dur-slow) var(--adm-ease-out) both; } +.adm-anim-rise { animation: adm-rise-in var(--adm-dur) var(--adm-ease-out) both; } +.adm-anim-pop { animation: adm-pop-in var(--adm-dur-fast) var(--adm-ease-out) both; } + +/* stagger a KPI/card strip on first paint */ +.adm-stagger > * { animation: adm-rise-in var(--adm-dur-slow) var(--adm-ease-out) both; } +.adm-stagger > *:nth-child(1) { animation-delay: 0ms } +.adm-stagger > *:nth-child(2) { animation-delay: 45ms } +.adm-stagger > *:nth-child(3) { animation-delay: 90ms } +.adm-stagger > *:nth-child(4) { animation-delay: 135ms } +.adm-stagger > *:nth-child(5) { animation-delay: 180ms } +.adm-stagger > *:nth-child(6) { animation-delay: 225ms } + +/* ── AG Grid surface polish ──────────────────────────────────────────────── */ +.adm-grid .ag-row { transition: background var(--adm-dur-fast) var(--adm-ease-std); position: relative; } +/* a left accent bar on hover — a cheap detail that reads expensive */ +.adm-grid .ag-row::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: var(--adm-primary); + opacity: 0; + transition: opacity var(--adm-dur-fast) var(--adm-ease-std); +} +.adm-grid .ag-row:hover::before { opacity: 1; } + +@media (prefers-reduced-motion: reduce) { + .adm-card, + .adm-btn, + .adm-icon-btn, + .adm-input, + .adm-textarea, + .adm-seg button, + .adm-bar-fill, + .adm-anim-fade, + .adm-anim-drawer, + .adm-anim-rise, + .adm-anim-pop, + .adm-stagger > *, + .adm-grid .ag-row, + .adm-grid .ag-row::before { + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/packages/admin-svelte/tsconfig.build.json b/packages/admin-svelte/tsconfig.build.json new file mode 100644 index 00000000..3e868d3a --- /dev/null +++ b/packages/admin-svelte/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts" + ] +} diff --git a/packages/admin-svelte/tsconfig.json b/packages/admin-svelte/tsconfig.json new file mode 100644 index 00000000..55f3f7eb --- /dev/null +++ b/packages/admin-svelte/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "noEmit": true, + "types": [], + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.svelte" + ], + "exclude": [ + "src/**/*.test.ts" + ] +} diff --git a/packages/admin-svelte/vitest.config.ts b/packages/admin-svelte/vitest.config.ts new file mode 100644 index 00000000..20fbe0e3 --- /dev/null +++ b/packages/admin-svelte/vitest.config.ts @@ -0,0 +1,15 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vitest/config'; + +// The Svelte plugin lets the suite import .svelte files directly and render +// them through `svelte/server`, so the component tests assert on real markup. +// No DOM environment is installed in this repo, so the tests are server-side +// renders (no mounting, no events); browser-level verification of these +// components belongs to the consuming app's Playwright suite. +export default defineConfig({ + plugins: [svelte({ compilerOptions: { dev: false } })], + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +});