@aeratic/pagetailor 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,75 @@
1
1
  # @aeratic/pagetailor
2
2
 
3
- Pages as data. Define your app's pages in YAML, bind them to a typed catalog of components and data sources, tailor them per role and per person with small patches, and let an AI agent compose them through a validation gate.
4
-
5
- Status: 0.0.1, name reservation. The engine (built on json-render) lands in 0.1.
3
+ Pages as data. Define your app's pages in YAML, bind them to a typed catalog of components and data sources, tailor them per role and per person with small patches, and let an AI agent compose them through a validation gate. Rendering is done by [json-render](https://github.com/vercel-labs/json-render).
6
4
 
7
5
  ```
8
6
  npm init -y
9
- npx @aeratic/pagetailor init
10
- npx @aeratic/pagetailor validate
7
+ npm install @aeratic/pagetailor react react-dom
8
+ npx pagetailor init
9
+ npx pagetailor validate
10
+ npx pagetailor dev # http://localhost:4747/?user=giulia&role=marketing
11
+ ```
12
+
13
+ ## What `init` creates
14
+
15
+ ```
16
+ pagetailor/
17
+ manifest.yaml pages, home, navigation, roles
18
+ tokens.yaml design tokens -> CSS variables --pt-*
19
+ data-catalog.yaml data sources: params, returned type, allowed roles
20
+ data/sources.mjs one async function per source (demo data)
21
+ pages/home.yaml a page: tree of components, data bindings, grid with named areas
22
+ overrides/ user-giulia.yaml, role-magazzino.yaml: patches over a page, with a `why`
23
+ components/ registry.yaml + base components (yours to edit)
24
+ ```
25
+
26
+ ## A page
27
+
28
+ ```yaml
29
+ page: home
30
+ layout:
31
+ ref: layout/card@1.0.0
32
+ props: { title: "Dashboard" }
33
+ children:
34
+ - ref: layout/grid@1.0.0
35
+ props:
36
+ areas: |
37
+ chart chart
38
+ table kpi
39
+ table todo
40
+ children:
41
+ chart: { ref: charts/line@1.0.0, data: { source: analytics.revenue, params: { period: 30d } } }
42
+ table: { ref: tables/simple@1.0.0, data: { source: sales.by_region } }
43
+ kpi: { ref: kpi/number@1.0.0, data: { source: sales.total } }
44
+ todo: { ref: todo/list@1.0.0, data: { source: tasks.mine } }
45
+ ```
46
+
47
+ ## An override
48
+
49
+ ```yaml
50
+ # overrides/user-giulia.yaml
51
+ extends: home
52
+ why: "Reports every Monday, wants the KPI on top"
53
+ patches:
54
+ - { op: set-areas, target: layout/grid, areas: "kpi kpi\nchart chart\ntable todo\n" }
55
+ ```
56
+
57
+ Ops: `set-areas`, `set-props`, `set-data`, `replace`, `remove`, `insert`, `move`. Targets are component names or child names, never element ids. Role overrides (`role-<name>.yaml`) apply first, user overrides (`user-<name>.yaml`) second.
58
+
59
+ ## The gate
60
+
61
+ Every page, override and agent proposal goes through the same validator: the component exists with that version, the data source exists, the user's role may read it, the source's returned type is accepted by the component, params are allowed, grid areas and children match. An invalid node renders as a fallback box with the reason; the rest of the page renders.
62
+
63
+ ## In your own server
64
+
65
+ ```js
66
+ import { resolve, commitOverride, listHistory, rollback } from "@aeratic/pagetailor";
67
+ const { spec, errors } = await resolve("./pagetailor", "home", { user: "giulia", role: "marketing" });
68
+ // send `spec` to the browser and render it with <Page resolved={...} registry={...}/> from "@aeratic/pagetailor/client"
11
69
  ```
70
+
71
+ ## Status
72
+
73
+ 0.1: engine, CLI (`init`, `validate`, `dev`), history with rollback. Next: proposal/approval flow for shared pages and an agent endpoint.
74
+
75
+ License: MIT.
package/bin/cli.js CHANGED
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { init } from "../src/init.js";
3
3
  import { validate } from "../src/validate.js";
4
+ import { dev } from "../src/dev.js";
4
5
 
5
6
  const [cmd, ...args] = process.argv.slice(2);
7
+ const flag = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
6
8
  const help = `pagetailor <command>
7
9
 
8
- init create the pagetailor/ folder with manifest, tokens, data catalog, a sample page and base components
9
- validate check every page and override against the catalog
10
+ init [--force] create pagetailor/ with manifest, tokens, data catalog, a sample page, overrides and base components
11
+ validate check manifest, pages and overrides against the component registry and the data catalog
12
+ dev [--port 4747] preview server with live reload; ?page=home&user=giulia&role=marketing
10
13
  `;
11
14
 
12
15
  try {
13
16
  if (cmd === "init") await init(process.cwd(), args);
14
17
  else if (cmd === "validate") process.exitCode = (await validate(process.cwd())) ? 0 : 1;
18
+ else if (cmd === "dev") await dev(process.cwd(), { port: Number(flag("--port", 4747)) });
15
19
  else { console.log(help); if (cmd && cmd !== "help") process.exitCode = 1; }
16
20
  } catch (e) { console.error("pagetailor:", e.message); process.exitCode = 1; }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aeratic/pagetailor",
3
- "version": "0.0.1",
4
- "description": "Pages as data: declarative, per-user tailored UI that an AI agent can safely compose.",
3
+ "version": "0.1.1",
4
+ "description": "Pages as data: declarative YAML pages, typed data catalog, per-user overrides, validated for AI agents. Renders with json-render.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "main": "./src/index.js",
11
11
  "exports": {
12
- ".": "./src/index.js"
12
+ ".": "./src/index.js",
13
+ "./client": "./src/client.jsx"
13
14
  },
14
15
  "files": [
15
16
  "bin",
@@ -18,7 +19,7 @@
18
19
  "README.md"
19
20
  ],
20
21
  "engines": {
21
- "node": ">=18"
22
+ "node": ">=20"
22
23
  },
23
24
  "keywords": [
24
25
  "server-driven-ui",
@@ -37,6 +38,21 @@
37
38
  "access": "public"
38
39
  },
39
40
  "dependencies": {
40
- "yaml": "^2.6.0"
41
+ "@json-render/core": "0.21.1",
42
+ "@json-render/react": "0.21.1",
43
+ "esbuild": "^0.25.0",
44
+ "yaml": "^2.6.0",
45
+ "zod": "4.3.6"
46
+ },
47
+ "peerDependencies": {
48
+ "react": ">=19.2.3",
49
+ "react-dom": ">=19.2.3"
50
+ },
51
+ "devDependencies": {
52
+ "react": "19.2.3",
53
+ "react-dom": "19.2.3"
54
+ },
55
+ "scripts": {
56
+ "test": "node --no-deprecation --import ./test/jsx-loader.mjs --test test/"
41
57
  }
42
58
  }
package/src/client.jsx ADDED
@@ -0,0 +1,26 @@
1
+ import { Renderer, JSONUIProvider } from "@json-render/react";
2
+
3
+ // Components are written as ({ props, children, slots, emit }); json-render calls them with { element, ... }.
4
+ // element.props already has $state bindings resolved by the renderer.
5
+ export function wrapRegistry(components) {
6
+ const out = {};
7
+ for (const [name, C] of Object.entries(components))
8
+ out[name] = ({ element, children, slots, emit, on, bindings, loading }) => <C props={element.props ?? {}} children={children} slots={slots} emit={emit} on={on} bindings={bindings} loading={loading} />;
9
+ return out;
10
+ }
11
+
12
+ export function Fallback({ props }) {
13
+ return <div style={{ border: "1px dashed var(--pt-color-danger, #C0392B)", color: "var(--pt-color-danger, #C0392B)", borderRadius: 8, padding: 12, fontSize: 13 }}>{props.reason}</div>;
14
+ }
15
+
16
+ // registry: { "charts/line@1.0.0": Component, ... } resolved: output of resolve()
17
+ export function Page({ resolved, registry }) {
18
+ const reg = wrapRegistry({ "pt/fallback": Fallback, ...registry });
19
+ return (
20
+ <JSONUIProvider registry={reg} initialState={resolved.spec.state} key={JSON.stringify(resolved.spec.state)}>
21
+ <Renderer spec={resolved.spec} registry={reg} fallback={Fallback} />
22
+ </JSONUIProvider>
23
+ );
24
+ }
25
+
26
+ export { tokensToCss } from "./tokens.js";
package/src/dev.js ADDED
@@ -0,0 +1,112 @@
1
+ import http from "node:http";
2
+ import { watch } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createRequire } from "node:module";
6
+ import * as esbuild from "esbuild";
7
+ import { resolve } from "./resolver.js";
8
+ import { loadProject, loadYaml, listPages } from "./loader.js";
9
+ import { commitOverride, listHistory, rollback } from "./history.js";
10
+ import { tokensToCss } from "./tokens.js";
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+
14
+ export async function dev(cwd, { port = 4747 } = {}) {
15
+ const root = join(cwd, "pagetailor");
16
+ // Bundle one React only: the project's if installed, otherwise the one shipped with this package.
17
+ const reqProject = createRequire(join(cwd, "package.json")), reqSelf = createRequire(import.meta.url);
18
+ const alias = {};
19
+ for (const m of ["react", "react-dom", "react/jsx-runtime", "react-dom/client"]) {
20
+ try { alias[m] = reqProject.resolve(m); } catch { try { alias[m] = reqSelf.resolve(m); } catch {} }
21
+ }
22
+ const nodePaths = [join(cwd, "node_modules"), join(here, "..", "node_modules")];
23
+
24
+ // one generated entry: imports every component from registry.yaml, mounts <Page>
25
+ const entry = async () => {
26
+ const reg = await loadYaml(join(root, "components", "registry.yaml"));
27
+ const imports = [], map = [];
28
+ let i = 0;
29
+ for (const [name, def] of Object.entries(reg)) for (const [ver, v] of Object.entries(def.versions ?? {})) {
30
+ if (!v.impl) continue; const id = `C${i++}`;
31
+ imports.push(`import ${id} from ${JSON.stringify(v.impl)};`);
32
+ map.push(`${JSON.stringify(`${name}@${ver}`)}: ${id}`);
33
+ }
34
+ return `${imports.join("\n")}
35
+ import { createRoot } from "react-dom/client";
36
+ import { useEffect, useState } from "react";
37
+ import { Page } from ${JSON.stringify(join(here, "client.jsx"))};
38
+ const registry = { ${map.join(", ")} };
39
+ function App() {
40
+ const q = new URLSearchParams(location.search);
41
+ const [page, setPage] = useState(q.get("page") || "__home__");
42
+ const [user, setUser] = useState(q.get("user") || "");
43
+ const [role, setRole] = useState(q.get("role") || "");
44
+ const [state, setState] = useState(null);
45
+ useEffect(() => { fetch(\`/api/page/\${page}?user=\${user}&role=\${role}\`).then(r => r.json()).then(setState); }, [page, user, role]);
46
+ useEffect(() => { const es = new EventSource("/events"); es.onmessage = () => location.reload(); return () => es.close(); }, []);
47
+ if (!state) return null;
48
+ const pages = state.manifest.pages, roles = state.manifest.roles ?? [];
49
+ return <>
50
+ <header style={{ display: "flex", gap: 12, alignItems: "center", padding: "10px 16px", borderBottom: "1px solid var(--pt-color-border)", background: "var(--pt-color-surface)", fontSize: 13 }}>
51
+ <strong>{state.manifest.app}</strong>
52
+ <label>page <select value={state.page} onChange={e => setPage(e.target.value)}>{pages.map(p => <option key={p}>{p}</option>)}</select></label>
53
+ <label>user <input value={user} placeholder="giulia" onChange={e => setUser(e.target.value)} style={{ width: 90 }} /></label>
54
+ <label>role <select value={role} onChange={e => setRole(e.target.value)}><option value="">(none)</option>{roles.map(r => <option key={r}>{r}</option>)}</select></label>
55
+ {state.errors.length > 0 && <span style={{ color: "var(--pt-color-danger)" }}>{state.errors.length} problem(s), see console</span>}
56
+ </header>
57
+ <main style={{ padding: 16, maxWidth: 1100, margin: "0 auto" }}><Page resolved={state} registry={registry} /></main>
58
+ </>;
59
+ }
60
+ createRoot(document.getElementById("root")).render(<App />);`;
61
+ };
62
+
63
+ const ctx = await esbuild.context({
64
+ stdin: { contents: await entry(), resolveDir: join(root, "components"), loader: "jsx", sourcefile: "pagetailor-entry.jsx" },
65
+ bundle: true, write: false, format: "esm", jsx: "automatic", alias, nodePaths, outfile: "bundle.js", sourcemap: "inline", logLevel: "silent",
66
+ define: { "process.env.NODE_ENV": '"development"' },
67
+ });
68
+ let bundle = "", buildErrors = [];
69
+ const rebuild = async () => {
70
+ try { const r = await ctx.rebuild(); bundle = r.outputFiles[0].text; buildErrors = []; }
71
+ catch (e) { buildErrors = e.errors ?? [{ text: e.message }]; console.error("build:", buildErrors.map(x => x.text).join("\n")); }
72
+ };
73
+ await rebuild();
74
+
75
+ const clients = new Set();
76
+ let timer;
77
+ watch(root, { recursive: true }, (_, file) => {
78
+ if (file?.includes(".history")) return;
79
+ clearTimeout(timer);
80
+ timer = setTimeout(async () => { await rebuild(); for (const c of clients) c.write("data: reload\n\n"); console.log("reloaded:", file); }, 80);
81
+ });
82
+
83
+ const json = (res, code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
84
+ const body = req => new Promise(r => { let s = ""; req.on("data", c => s += c); req.on("end", () => r(s ? JSON.parse(s) : {})); });
85
+
86
+ const server = http.createServer(async (req, res) => {
87
+ const url = new URL(req.url, "http://x");
88
+ try {
89
+ if (url.pathname === "/") {
90
+ const { tokens, manifest } = await loadProject(root);
91
+ res.writeHead(200, { "content-type": "text/html" });
92
+ return res.end(`<!doctype html><html><head><meta charset="utf-8"><title>${manifest.app}</title><style>${tokensToCss(tokens)} body{margin:0;background:var(--pt-color-background);color:var(--pt-color-text);font-family:var(--pt-font-base);font-size:var(--pt-font-size)}</style></head><body><div id="root"></div><script type="module" src="/bundle.js"></script></body></html>`);
93
+ }
94
+ if (url.pathname === "/bundle.js") { res.writeHead(200, { "content-type": "text/javascript" }); return res.end(buildErrors.length ? `document.body.innerHTML='<pre style="color:#C0392B;padding:16px">'+${JSON.stringify(buildErrors.map(e => e.text).join("\n"))}+'</pre>'` : bundle); }
95
+ if (url.pathname === "/events") { res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); res.write("\n"); clients.add(res); req.on("close", () => clients.delete(res)); return; }
96
+ if (url.pathname.startsWith("/api/page/")) {
97
+ let page = decodeURIComponent(url.pathname.slice(10));
98
+ if (page === "__home__") page = (await loadProject(root)).manifest.home ?? (await listPages(root))[0];
99
+ const r = await resolve(root, page, { user: url.searchParams.get("user") || undefined, role: url.searchParams.get("role") || undefined });
100
+ if (r.errors.length) console.warn(`[${page}]`, r.errors.map(e => `${e.path}: ${e.message}`).join("\n "));
101
+ return json(res, 200, { page, ...r });
102
+ }
103
+ if (url.pathname.startsWith("/api/history/")) return json(res, 200, await listHistory(root, url.pathname.slice(13)));
104
+ if (url.pathname === "/api/override" && req.method === "POST") { const b = await body(req); return json(res, 200, await commitOverride(root, b.name, b.override, { author: b.author })); }
105
+ if (url.pathname === "/api/rollback" && req.method === "POST") { const b = await body(req); return json(res, 200, await rollback(root, b.name, b.version, { author: b.author })); }
106
+ json(res, 404, { error: "not found" });
107
+ } catch (e) { console.error(e); json(res, 500, { error: e.message }); }
108
+ });
109
+ await new Promise(r => server.listen(port, r));
110
+ console.log(`pagetailor dev http://localhost:${port}\n pages from ${root}\n ?page=home&user=giulia&role=marketing`);
111
+ return server;
112
+ }
package/src/history.js ADDED
@@ -0,0 +1,27 @@
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { stringify } from "yaml";
4
+
5
+ // Every write to an override is a new numbered version under .history/<name>/NNNN.json.
6
+ // The current override file is rewritten from the version being committed.
7
+ const dirOf = (root, name) => join(root, ".history", name);
8
+ const pad = n => String(n).padStart(4, "0");
9
+
10
+ export async function commitOverride(root, name, override, { author = "system", status = "applied" } = {}) {
11
+ const dir = dirOf(root, name); await mkdir(dir, { recursive: true });
12
+ const n = (await readdir(dir)).length + 1;
13
+ const entry = { version: n, at: new Date().toISOString(), author, status, override };
14
+ await writeFile(join(dir, `${pad(n)}.json`), JSON.stringify(entry, null, 2));
15
+ if (status === "applied") await writeFile(join(root, "overrides", `${name}.yaml`), stringify(override));
16
+ return entry;
17
+ }
18
+
19
+ export async function listHistory(root, name) {
20
+ try { const files = (await readdir(dirOf(root, name))).sort(); return Promise.all(files.map(async f => JSON.parse(await readFile(join(dirOf(root, name), f), "utf8")))); }
21
+ catch { return []; }
22
+ }
23
+
24
+ export async function rollback(root, name, version, { author = "system" } = {}) {
25
+ const entry = JSON.parse(await readFile(join(dirOf(root, name), `${pad(version)}.json`), "utf8"));
26
+ return commitOverride(root, name, { ...entry.override, why: `rollback to v${version}: ${entry.override.why ?? ""}`.trim() }, { author });
27
+ }
package/src/index.js CHANGED
@@ -1,2 +1,8 @@
1
+ export { resolve } from "./resolver.js";
2
+ export { validateTree, validateManifest, typeAccepts } from "./validator.js";
3
+ export { applyOverride } from "./overrides.js";
4
+ export { commitOverride, listHistory, rollback } from "./history.js";
5
+ export { loadProject, loadPage, loadOverrides, listPages, listOverrides, loadSources } from "./loader.js";
1
6
  export { init } from "./init.js";
2
7
  export { validate } from "./validate.js";
8
+ export { dev } from "./dev.js";
package/src/init.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cp, mkdir, access } from "node:fs/promises";
1
+ import { cp, mkdir, access, readFile } from "node:fs/promises";
2
2
  import { join, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
@@ -10,13 +10,21 @@ export async function init(cwd, args = []) {
10
10
  try { await access(target); if (!force) throw new Error("pagetailor/ already exists (use --force to overwrite)"); } catch (e) { if (e.code !== "ENOENT") throw e; }
11
11
  await mkdir(target, { recursive: true });
12
12
  await cp(join(here, "..", "templates"), target, { recursive: true, force });
13
+ let needs = [];
14
+ try {
15
+ const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
16
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
17
+ for (const d of ["@aeratic/pagetailor", "react", "react-dom"]) if (!deps[d]) needs.push(d);
18
+ } catch { needs = ["@aeratic/pagetailor", "react", "react-dom"]; }
13
19
  console.log(`created ${target}/
14
- manifest.yaml app shape: pages, home, navigation, roles
15
- tokens.yaml design tokens
16
- data-catalog.yaml typed data sources with roles
17
- pages/home.yaml a sample page
18
- overrides/ per-user and per-role patches
19
- components/ base components, yours to edit
20
-
21
- next: npx pagetailor validate`);
20
+ manifest.yaml app shape: pages, home, navigation, roles
21
+ tokens.yaml design tokens, exposed as CSS variables --pt-*
22
+ data-catalog.yaml typed data sources with roles
23
+ data/sources.mjs one async function per source (demo data, replace with your APIs)
24
+ pages/home.yaml a sample page: card > grid with named areas
25
+ overrides/ per-user (user-giulia) and per-role (role-magazzino) patches
26
+ components/ registry.yaml + 9 base components, yours to edit
27
+ `);
28
+ if (needs.length) console.log(`install the runtime:\n npm install ${needs.join(" ")}\n`);
29
+ console.log(`then:\n npx pagetailor validate\n npx pagetailor dev`);
22
30
  }
package/src/loader.js ADDED
@@ -0,0 +1,54 @@
1
+ import { readFile, stat, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+
5
+ const cache = new Map(); // path -> { mtime, value }
6
+
7
+ export async function loadYaml(path) {
8
+ const { mtimeMs } = await stat(path);
9
+ const hit = cache.get(path);
10
+ if (hit && hit.mtime === mtimeMs) return hit.value;
11
+ const value = parse(await readFile(path, "utf8"));
12
+ cache.set(path, { mtime: mtimeMs, value });
13
+ return value;
14
+ }
15
+
16
+ export async function exists(path) { try { await stat(path); return true; } catch { return false; } }
17
+
18
+ export async function loadProject(root) {
19
+ const [manifest, tokens, catalog, registry] = await Promise.all([
20
+ loadYaml(join(root, "manifest.yaml")),
21
+ loadYaml(join(root, "tokens.yaml")),
22
+ loadYaml(join(root, "data-catalog.yaml")),
23
+ loadYaml(join(root, "components", "registry.yaml")),
24
+ ]);
25
+ return { root, manifest, tokens, catalog, registry };
26
+ }
27
+
28
+ export async function loadPage(root, page) { return loadYaml(join(root, "pages", `${page}.yaml`)); }
29
+
30
+ export async function loadOverrides(root, page, user, role) {
31
+ // role first, then user: each one a small patch file, missing file = no patch
32
+ const out = [];
33
+ for (const name of [role && `role-${role}`, user && `user-${user}`].filter(Boolean)) {
34
+ const p = join(root, "overrides", `${name}.yaml`);
35
+ if (await exists(p)) { const ov = await loadYaml(p); if (!ov.extends || ov.extends === page) out.push({ name, ...ov }); }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ export async function listPages(root) {
41
+ return (await readdir(join(root, "pages"))).filter(f => f.endsWith(".yaml")).map(f => f.slice(0, -5));
42
+ }
43
+ export async function listOverrides(root) {
44
+ const dir = join(root, "overrides");
45
+ if (!(await exists(dir))) return [];
46
+ return (await readdir(dir)).filter(f => f.endsWith(".yaml")).map(f => f.slice(0, -5));
47
+ }
48
+
49
+ export async function loadSources(root) {
50
+ const p = join(root, "data", "sources.mjs");
51
+ if (!(await exists(p))) return {};
52
+ const mod = await import(`file://${p}?t=${Date.now()}`);
53
+ return mod.default ?? mod.sources ?? mod;
54
+ }
@@ -0,0 +1,43 @@
1
+ import { splitRef } from "./validator.js";
2
+
3
+ // Ops work on the YAML tree, by component name or by child name, never by element id.
4
+ // { op: set-areas, target: layout/grid, areas: "..." }
5
+ // { op: replace, target: chart, with: { ref, props, data } }
6
+ // { op: remove, target: kpi }
7
+ // { op: set-props, target: charts/line, props: { title: "..." } }
8
+ // { op: set-data, target: chart, data: { source, params } }
9
+ // { op: insert, target: <container>, name?: todo2, node: {...}, before?: <child name>, area?: "..." }
10
+ // { op: move, target: <child name>, to: 0 } (array children only)
11
+
12
+ export function applyOverride(tree, override) {
13
+ const t = structuredClone(tree);
14
+ const errors = [];
15
+ for (const [i, p] of (override.patches ?? []).entries()) {
16
+ const found = find(t, p.target);
17
+ if (!found) { errors.push({ path: `${override.name ?? "override"}/${i}`, message: `patch target "${p.target}" not found` }); continue; }
18
+ const { parent, key, node } = found;
19
+ switch (p.op) {
20
+ case "set-areas": (node.props ??= {}).areas = p.areas; break;
21
+ case "set-props": Object.assign(node.props ??= {}, p.props); break;
22
+ case "set-data": node.data = p.data; break;
23
+ case "replace": if (parent) parent[key] = p.with; else Object.assign(t, p.with); break;
24
+ case "remove": if (Array.isArray(parent)) parent.splice(key, 1); else delete parent[key]; break;
25
+ case "insert": {
26
+ if (Array.isArray(node.children)) { const at = p.before != null ? node.children.findIndex(c => splitRef(c.ref)[0] === p.before) : node.children.length; node.children.splice(at < 0 ? node.children.length : at, 0, p.node); }
27
+ else { (node.children ??= {})[p.name] = p.node; if (p.area) (node.props ??= {}).areas = p.area; }
28
+ break;
29
+ }
30
+ case "move": { if (!Array.isArray(parent)) { errors.push({ path: `${i}`, message: "move works on array children" }); break; } const [n] = parent.splice(key, 1); parent.splice(p.to, 0, n); break; }
31
+ default: errors.push({ path: `${i}`, message: `unknown op "${p.op}"` });
32
+ }
33
+ }
34
+ return { tree: t, errors };
35
+ }
36
+
37
+ export function find(node, target, parent = null, key = null) {
38
+ if (!node || typeof node !== "object") return null;
39
+ if (key === target || (node.ref && splitRef(node.ref)[0] === target) || node.ref === target) return { parent, key, node };
40
+ const kids = node.children; if (!kids) return null;
41
+ for (const [k, v] of Object.entries(kids)) { const r = find(v, target, kids, Array.isArray(kids) ? Number(k) : k); if (r) return r; }
42
+ return null;
43
+ }
@@ -0,0 +1,44 @@
1
+ import { loadProject, loadPage, loadOverrides, loadSources } from "./loader.js";
2
+ import { validateTree } from "./validator.js";
3
+ import { applyOverride } from "./overrides.js";
4
+
5
+ // YAML page + overrides + data -> json-render flat spec { root, elements, state }
6
+ export async function resolve(root, page, { user, role, sources } = {}) {
7
+ const project = await loadProject(root);
8
+ const fetchers = sources ?? await loadSources(root);
9
+ let tree = (await loadPage(root, page)).layout;
10
+ const errors = [];
11
+ for (const ov of await loadOverrides(root, page, user, role)) {
12
+ const r = applyOverride(tree, ov); tree = r.tree; errors.push(...r.errors);
13
+ }
14
+ errors.push(...validateTree(tree, project, { role: role ?? null }));
15
+ const byPath = new Map(); for (const e of errors) byPath.set(e.path, [...(byPath.get(e.path) ?? []), e.message]);
16
+
17
+ const elements = {}, state = { data: {}, user: { id: user ?? null, role: role ?? null } };
18
+ let n = 0;
19
+ const walk = async (node, path) => {
20
+ const id = `e${n++}`;
21
+ const bad = byPath.get(path);
22
+ if (bad) { elements[id] = { type: "pt/fallback", props: { reason: bad.join("; ") } }; return id; }
23
+ const props = { ...(node.props ?? {}) };
24
+ if (node.data?.source) {
25
+ const key = `${node.data.source}${node.data.params ? ":" + JSON.stringify(node.data.params) : ""}`.replace(/[^\w]/g, "_");
26
+ if (!(key in state.data)) {
27
+ const fn = fetchers[node.data.source];
28
+ state.data[key] = fn ? await fn(node.data.params ?? {}, { user, role }) : null;
29
+ }
30
+ props.data = { $state: `/data/${key}` };
31
+ }
32
+ const el = { type: node.ref, props };
33
+ if (node.children) {
34
+ const named = !Array.isArray(node.children);
35
+ const entries = named ? Object.entries(node.children) : node.children.map((c, i) => [i, c]);
36
+ if (named) props.names = entries.map(([k]) => k);
37
+ el.children = [];
38
+ for (const [i, [, c]] of entries.entries()) el.children.push(await walk(c, `${path}/${i}`));
39
+ }
40
+ elements[id] = el; return id;
41
+ };
42
+ const rootId = await walk(tree, "root");
43
+ return { spec: { root: rootId, elements, state }, errors, tokens: project.tokens, manifest: project.manifest };
44
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,12 @@
1
+ // Flatten tokens.yaml into CSS variables: color.primary -> --pt-color-primary, spacing.md -> 16px
2
+ export function tokensToCss(tokens, prefix = "--pt") {
3
+ const out = [];
4
+ const walk = (o, path) => {
5
+ for (const [k, v] of Object.entries(o ?? {})) {
6
+ if (v && typeof v === "object") walk(v, `${path}-${k}`);
7
+ else out.push(`${path}-${k}: ${typeof v === "number" ? v + "px" : v};`);
8
+ }
9
+ };
10
+ walk(tokens, prefix);
11
+ return `:root { ${out.join(" ")} }`;
12
+ }
package/src/validate.js CHANGED
@@ -1,43 +1,29 @@
1
- import { readFile, readdir } from "node:fs/promises";
2
1
  import { join } from "node:path";
3
- import { parse } from "yaml";
4
-
5
- const load = async (p) => parse(await readFile(p, "utf8"));
2
+ import { loadProject, loadPage, listPages, listOverrides, loadYaml } from "./loader.js";
3
+ import { validateTree, validateManifest } from "./validator.js";
4
+ import { applyOverride } from "./overrides.js";
6
5
 
7
6
  export async function validate(cwd) {
8
7
  const root = join(cwd, "pagetailor");
9
- const manifest = await load(join(root, "manifest.yaml"));
10
- const data = await load(join(root, "data-catalog.yaml"));
11
- const registry = await load(join(root, "components", "registry.yaml"));
12
- const errors = [];
13
-
14
- const walk = (node, page, path = "root") => {
15
- if (!node || typeof node !== "object") return;
16
- if (node.ref) {
17
- const [name, version] = String(node.ref).split("@");
18
- const comp = registry[name];
19
- if (!comp) errors.push(`${page}: ${path}: unknown component "${name}"`);
20
- else if (version && !comp.versions?.[version]) errors.push(`${page}: ${path}: "${name}" has no version ${version}`);
21
- if (node.data?.source) {
22
- const src = data[node.data.source];
23
- if (!src) errors.push(`${page}: ${path}: unknown data source "${node.data.source}"`);
24
- else if (comp?.accepts && src.returns && !compatible(comp.accepts, src.returns))
25
- errors.push(`${page}: ${path}: "${name}" accepts ${comp.accepts} but "${node.data.source}" returns ${src.returns}`);
26
- }
8
+ const project = await loadProject(root);
9
+ const pages = await listPages(root);
10
+ const problems = validateManifest(project.manifest, pages).map(e => `manifest.yaml: ${e.message}`);
11
+ const trees = {};
12
+ for (const p of pages) {
13
+ trees[p] = (await loadPage(root, p)).layout;
14
+ for (const e of validateTree(trees[p], project)) problems.push(`pages/${p}.yaml ${e.path}: ${e.message}`);
15
+ }
16
+ for (const name of await listOverrides(root)) {
17
+ const ov = await loadYaml(join(root, "overrides", `${name}.yaml`));
18
+ const targets = ov.extends ? [ov.extends] : pages;
19
+ const role = name.startsWith("role-") ? name.slice(5) : undefined;
20
+ for (const p of targets) {
21
+ if (!trees[p]) { problems.push(`overrides/${name}.yaml: extends unknown page "${p}"`); continue; }
22
+ const r = applyOverride(trees[p], { name, ...ov });
23
+ for (const e of r.errors) problems.push(`overrides/${name}.yaml ${e.path}: ${e.message}`);
24
+ for (const e of validateTree(r.tree, project, { role })) problems.push(`overrides/${name}.yaml on ${p} ${e.path}: ${e.message}`);
27
25
  }
28
- (node.children ?? []).forEach((c, i) => walk(c, page, `${path}/${i}`));
29
- };
30
-
31
- for (const file of await readdir(join(root, "pages"))) {
32
- if (!file.endsWith(".yaml")) continue;
33
- const page = await load(join(root, "pages", file));
34
- if (!manifest.pages?.includes(page.page)) errors.push(`${file}: page "${page.page}" is not listed in manifest.yaml`);
35
- walk(page.layout, file);
36
26
  }
37
-
38
- if (errors.length) { console.error(errors.join("\n")); console.error(`\n${errors.length} problem(s)`); return false; }
39
- console.log("all pages valid"); return true;
27
+ if (problems.length) { console.error(problems.join("\n")); console.error(`\n${problems.length} problem(s)`); return false; }
28
+ console.log(`all pages valid (${pages.length} page(s))`); return true;
40
29
  }
41
-
42
- // v0: exact match or "any"; a real type lattice comes later.
43
- const compatible = (accepts, returns) => accepts === "any" || accepts === returns;
@@ -0,0 +1,56 @@
1
+ // One validator used everywhere: CLI, resolver at runtime, agent gate.
2
+ // A node is { ref: "name@version", props?, data?: { source, params? }, children?: [] | {name: node} }
3
+
4
+ export const childrenOf = (node) => !node?.children ? [] : Array.isArray(node.children) ? node.children : Object.values(node.children);
5
+ export const splitRef = (ref) => { const i = String(ref).lastIndexOf("@"); return i < 0 ? [ref, null] : [ref.slice(0, i), ref.slice(i + 1)]; };
6
+
7
+ // v0 type lattice: exact match, "any" accepts everything, "list<any>" / "table<any>" accept any list / table
8
+ export function typeAccepts(accepts, returns) {
9
+ if (!accepts || accepts === "any") return true;
10
+ if (accepts === returns) return true;
11
+ const m = /^(\w+)<any>$/.exec(accepts);
12
+ return !!m && returns.startsWith(m[1] + "<");
13
+ }
14
+
15
+ export function validateTree(tree, project, { role } = {}) {
16
+ const errors = [];
17
+ const walk = (node, path) => {
18
+ if (!node || typeof node !== "object" || !node.ref) { errors.push({ path, message: "node has no ref" }); return; }
19
+ const [name, version] = splitRef(node.ref);
20
+ const comp = project.registry[name];
21
+ if (!comp) errors.push({ path, message: `unknown component "${name}"` });
22
+ else if (version && !comp.versions?.[version]) errors.push({ path, message: `"${name}" has no version ${version}` });
23
+ if (node.data?.source) {
24
+ const src = project.catalog[node.data.source];
25
+ if (!src) errors.push({ path, message: `unknown data source "${node.data.source}"` });
26
+ else {
27
+ if (role !== undefined && src.roles && !src.roles.includes("*") && !src.roles.includes(role))
28
+ errors.push({ path, message: `role "${role ?? "none"}" cannot read "${node.data.source}"` });
29
+ if (comp?.accepts && src.returns && !typeAccepts(comp.accepts, src.returns))
30
+ errors.push({ path, message: `"${name}" accepts ${comp.accepts} but "${node.data.source}" returns ${src.returns}` });
31
+ for (const [k, v] of Object.entries(node.data.params ?? {})) {
32
+ const allowed = src.params?.[k];
33
+ if (allowed === undefined) errors.push({ path, message: `"${node.data.source}" has no param "${k}"` });
34
+ else if (Array.isArray(allowed) && !allowed.includes(v)) errors.push({ path, message: `param ${k}=${v} not in [${allowed.join(", ")}]` });
35
+ }
36
+ }
37
+ }
38
+ if (node.children && !Array.isArray(node.children) && node.props?.areas) {
39
+ const names = Object.keys(node.children);
40
+ const inAreas = new Set(String(node.props.areas).split(/\s+/).filter(Boolean).filter(a => a !== "."));
41
+ for (const a of inAreas) if (!names.includes(a)) errors.push({ path, message: `area "${a}" has no child` });
42
+ for (const n of names) if (!inAreas.has(n)) errors.push({ path, message: `child "${n}" is not placed in areas` });
43
+ }
44
+ childrenOf(node).forEach((c, i) => walk(c, `${path}/${i}`));
45
+ };
46
+ walk(tree, "root");
47
+ return errors;
48
+ }
49
+
50
+ export function validateManifest(manifest, pages) {
51
+ const errors = [];
52
+ for (const p of manifest.pages ?? []) if (!pages.includes(p)) errors.push({ path: "manifest", message: `page "${p}" listed but pages/${p}.yaml is missing` });
53
+ for (const p of pages) if (!(manifest.pages ?? []).includes(p)) errors.push({ path: "manifest", message: `pages/${p}.yaml exists but is not listed in manifest.pages` });
54
+ if (manifest.home && !pages.includes(manifest.home)) errors.push({ path: "manifest", message: `home "${manifest.home}" is not a page` });
55
+ return errors;
56
+ }
@@ -0,0 +1,8 @@
1
+ export default function Card({ props, children }) {
2
+ return (
3
+ <section style={{ background: "var(--pt-color-surface)", border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-lg)" }}>
4
+ {props.title && <h2 style={{ margin: "0 0 var(--pt-spacing-md)", fontSize: 18 }}>{props.title}</h2>}
5
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--pt-spacing-md)" }}>{children}</div>
6
+ </section>
7
+ );
8
+ }
@@ -0,0 +1,13 @@
1
+ import { Children } from "react";
2
+
3
+ // props.areas is a multiline string like CSS grid-template-areas; props.names lists the children in order.
4
+ export default function Grid({ props, children }) {
5
+ const rows = String(props.areas).trim().split("\n").map(l => l.trim().split(/\s+/));
6
+ const cols = Math.max(...rows.map(r => r.length));
7
+ const kids = Children.toArray(children);
8
+ return (
9
+ <div style={{ display: "grid", gap: "var(--pt-spacing-md)", gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gridTemplateAreas: rows.map(r => `"${r.join(" ")}"`).join(" ") }}>
10
+ {kids.map((k, i) => <div key={props.names?.[i] ?? i} style={{ gridArea: props.names?.[i], minWidth: 0 }}>{k}</div>)}
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,9 @@
1
+ const fmt = new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
2
+ export default function Kpi({ props }) {
3
+ return (
4
+ <div style={{ padding: "var(--pt-spacing-md)", border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", height: "100%", boxSizing: "border-box" }}>
5
+ <div style={{ color: "var(--pt-color-muted)", fontSize: 13 }}>{props.label}</div>
6
+ <div style={{ fontSize: 32, fontWeight: 600, color: "var(--pt-color-primary)" }}>{typeof props.data === "number" ? fmt.format(props.data) : "–"}</div>
7
+ </div>
8
+ );
9
+ }
@@ -1,3 +1,20 @@
1
- export default function LineChart({ props, data, children }) {
2
- return <div data-pagetailor="LineChart">{props?.title}{children}</div>;
1
+ // A dependency-free SVG line chart for timeseries<money>: [{ date, value }]
2
+ export default function LineChart({ props }) {
3
+ const pts = Array.isArray(props.data) ? props.data : [];
4
+ const W = 600, H = 180, pad = 24;
5
+ const vals = pts.map(p => p.value), lo = Math.min(...vals, 0), hi = Math.max(...vals, 1);
6
+ const x = i => pad + (i / Math.max(pts.length - 1, 1)) * (W - 2 * pad);
7
+ const y = v => H - pad - ((v - lo) / (hi - lo)) * (H - 2 * pad);
8
+ const d = pts.map((p, i) => `${i ? "L" : "M"}${x(i).toFixed(1)} ${y(p.value).toFixed(1)}`).join(" ");
9
+ return (
10
+ <div style={{ border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-md)" }}>
11
+ {props.title && <div style={{ fontWeight: 600, marginBottom: 8 }}>{props.title}</div>}
12
+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", display: "block" }} role="img" aria-label={props.title}>
13
+ <line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="var(--pt-color-border)" />
14
+ <path d={d} fill="none" stroke="var(--pt-color-primary)" strokeWidth="2" />
15
+ {pts.length > 0 && <text x={pad} y={14} fontSize="11" fill="var(--pt-color-muted)">{pts[0].date}</text>}
16
+ {pts.length > 1 && <text x={W - pad} y={14} fontSize="11" textAnchor="end" fill="var(--pt-color-muted)">{pts.at(-1).date}</text>}
17
+ </svg>
18
+ </div>
19
+ );
3
20
  }
@@ -0,0 +1,3 @@
1
+ export default function Row({ props, children }) {
2
+ return <div style={{ display: "flex", flexDirection: "row", gap: props.gap ?? "var(--pt-spacing-md)", alignItems: "stretch" }}>{children}</div>;
3
+ }
@@ -1,3 +1,3 @@
1
- export default function Stack({ props, data, children }) {
2
- return <div data-pagetailor="Stack">{props?.title}{children}</div>;
1
+ export default function Stack({ props, children }) {
2
+ return <div style={{ display: "flex", flexDirection: "column", gap: props.gap ?? "var(--pt-spacing-md)" }}>{children}</div>;
3
3
  }
@@ -1,3 +1,13 @@
1
- export default function Table({ props, data, children }) {
2
- return <div data-pagetailor="Table">{props?.title}{children}</div>;
1
+ export default function Table({ props }) {
2
+ const rows = Array.isArray(props.data) ? props.data : [];
3
+ const cols = rows.length ? Object.keys(rows[0]) : [];
4
+ return (
5
+ <div style={{ border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-md)", height: "100%", boxSizing: "border-box" }}>
6
+ {props.title && <div style={{ fontWeight: 600, marginBottom: 8 }}>{props.title}</div>}
7
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
8
+ <thead><tr>{cols.map(c => <th key={c} style={{ textAlign: "left", padding: "4px 6px", color: "var(--pt-color-muted)", fontWeight: 500, borderBottom: "1px solid var(--pt-color-border)" }}>{c}</th>)}</tr></thead>
9
+ <tbody>{rows.map((r, i) => <tr key={i}>{cols.map(c => <td key={c} style={{ padding: "4px 6px" }}>{String(r[c])}</td>)}</tr>)}</tbody>
10
+ </table>
11
+ </div>
12
+ );
3
13
  }
@@ -1,3 +1,3 @@
1
- export default function Text({ props, data, children }) {
2
- return <div data-pagetailor="Text">{props?.title}{children}</div>;
1
+ export default function Text({ props }) {
2
+ return <p style={{ margin: 0, color: props.muted ? "var(--pt-color-muted)" : "inherit" }}>{props.content}</p>;
3
3
  }
@@ -1,3 +1,11 @@
1
- export default function TodoList({ props, data, children }) {
2
- return <div data-pagetailor="TodoList">{props?.title}{children}</div>;
1
+ export default function TodoList({ props }) {
2
+ const items = Array.isArray(props.data) ? props.data : [];
3
+ return (
4
+ <div style={{ border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-md)", height: "100%", boxSizing: "border-box" }}>
5
+ {props.title && <div style={{ fontWeight: 600, marginBottom: 8 }}>{props.title}</div>}
6
+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 6 }}>
7
+ {items.map(t => <li key={t.id ?? t.title} style={{ textDecoration: t.done ? "line-through" : "none", color: t.done ? "var(--pt-color-muted)" : "inherit" }}>{t.done ? "☑" : "☐"} {t.title}</li>)}
8
+ </ul>
9
+ </div>
10
+ );
3
11
  }
@@ -1,14 +1,24 @@
1
- # name -> versions, and the data type each component accepts
1
+ # name -> accepted data type (optional) and versions -> implementation file.
2
+ # Containers accept no data. Add a new version when props change in an incompatible way.
2
3
  layout/stack:
3
4
  versions: { "1.0.0": { impl: ./Stack.jsx } }
5
+ layout/row:
6
+ versions: { "1.0.0": { impl: ./Row.jsx } }
7
+ layout/card:
8
+ versions: { "1.0.0": { impl: ./Card.jsx } }
9
+ layout/grid:
10
+ versions: { "1.0.0": { impl: ./Grid.jsx } }
11
+ text/block:
12
+ versions: { "1.0.0": { impl: ./Text.jsx } }
13
+ kpi/number:
14
+ accepts: money
15
+ versions: { "1.0.0": { impl: ./Kpi.jsx } }
4
16
  charts/line:
5
17
  accepts: timeseries<money>
6
18
  versions: { "1.0.0": { impl: ./LineChart.jsx } }
7
19
  tables/simple:
8
- accepts: any
20
+ accepts: table<any>
9
21
  versions: { "1.0.0": { impl: ./Table.jsx } }
10
22
  todo/list:
11
23
  accepts: list<task>
12
24
  versions: { "1.0.0": { impl: ./TodoList.jsx } }
13
- text/block:
14
- versions: { "1.0.0": { impl: ./Text.jsx } }
@@ -0,0 +1,19 @@
1
+ // One async function per data source, same names as data-catalog.yaml.
2
+ // Receives the node's params and { user, role }. Replace the demo data with your API calls.
3
+ const days = (n, base) => Array.from({ length: n }, (_, i) => ({ date: `2026-10-${String(i + 1).padStart(2, "0")}`, value: Math.round(base + Math.sin(i / 2) * base * 0.2 + i * 15) }));
4
+
5
+ export default {
6
+ "analytics.revenue": async ({ period = "30d" }) => days({ "7d": 7, "30d": 30, "90d": 90 }[period], 1200),
7
+ "sales.total": async () => 48250,
8
+ "sales.by_region": async () => [
9
+ { region: "Nord", total: 21000 }, { region: "Centro", total: 15250 }, { region: "Sud", total: 12000 },
10
+ ],
11
+ "tasks.mine": async (_, { user }) => [
12
+ { id: 1, title: `Rivedere il report settimanale (${user ?? "anon"})`, done: false },
13
+ { id: 2, title: "Chiamare il fornitore", done: true },
14
+ { id: 3, title: "Preparare la riunione di lunedì", done: false },
15
+ ],
16
+ "stock.levels": async () => [
17
+ { sku: "A-100", name: "Vite M6", qty: 1200 }, { sku: "B-220", name: "Staffa", qty: 85 }, { sku: "C-310", name: "Guarnizione", qty: 0 },
18
+ ],
19
+ };
@@ -1,10 +1,23 @@
1
- # Typed data sources. Components can only bind to sources whose `returns` they accept.
1
+ # Typed data sources. A component binds to a source only if it accepts the type the source returns
2
+ # and the user's role is listed. Implementations live in data/sources.mjs, keyed by the same names.
2
3
  analytics.revenue:
3
4
  description: "Revenue over time"
4
5
  params: { period: [7d, 30d, 90d] }
5
6
  returns: timeseries<money>
6
- roles: [admin]
7
+ roles: [admin, marketing]
8
+ sales.total:
9
+ description: "Total sales for the current month"
10
+ returns: money
11
+ roles: [admin, marketing]
12
+ sales.by_region:
13
+ description: "Sales grouped by region"
14
+ returns: table<region>
15
+ roles: [admin, marketing]
7
16
  tasks.mine:
8
17
  description: "Tasks assigned to the current user"
9
18
  returns: list<task>
10
- roles: [admin, user]
19
+ roles: ["*"]
20
+ stock.levels:
21
+ description: "Warehouse stock levels"
22
+ returns: table<stock>
23
+ roles: [admin, magazzino]
@@ -3,4 +3,4 @@ home: home
3
3
  nav:
4
4
  position: left
5
5
  pages: [home]
6
- roles: [admin, user]
6
+ roles: [admin, marketing, magazzino]
@@ -0,0 +1,15 @@
1
+ extends: home
2
+ why: "Il magazzino non vede le vendite: al posto del grafico le giacenze"
3
+ patches:
4
+ - op: replace
5
+ target: chart
6
+ with: { ref: tables/simple@1.0.0, props: { title: "Giacenze" }, data: { source: stock.levels } }
7
+ - op: remove
8
+ target: table
9
+ - op: remove
10
+ target: kpi
11
+ - op: set-areas
12
+ target: layout/grid
13
+ areas: |
14
+ chart chart
15
+ todo todo
@@ -0,0 +1,9 @@
1
+ extends: home
2
+ why: "Giulia fa il report ogni lunedì: vuole il KPI grande in alto e il grafico sotto"
3
+ patches:
4
+ - op: set-areas
5
+ target: layout/grid
6
+ areas: |
7
+ kpi kpi
8
+ chart chart
9
+ table todo
@@ -1,10 +1,16 @@
1
1
  page: home
2
2
  layout:
3
- ref: layout/stack@1.0.0
3
+ ref: layout/card@1.0.0
4
+ props: { title: "Dashboard" }
4
5
  children:
5
- - ref: charts/line@1.0.0
6
- props: { title: "Revenue, last 30 days" }
7
- data: { source: analytics.revenue, params: { period: 30d } }
8
- - ref: todo/list@1.0.0
9
- props: { filter: mine }
10
- data: { source: tasks.mine }
6
+ - ref: layout/grid@1.0.0
7
+ props:
8
+ areas: |
9
+ chart chart
10
+ table kpi
11
+ table todo
12
+ children:
13
+ chart: { ref: charts/line@1.0.0, props: { title: "Ricavi, ultimi 30 giorni" }, data: { source: analytics.revenue, params: { period: 30d } } }
14
+ table: { ref: tables/simple@1.0.0, props: { title: "Vendite per zona" }, data: { source: sales.by_region } }
15
+ kpi: { ref: kpi/number@1.0.0, props: { label: "Vendite del mese" }, data: { source: sales.total } }
16
+ todo: { ref: todo/list@1.0.0, props: { title: "Le mie attività" }, data: { source: tasks.mine } }
@@ -1,6 +1,11 @@
1
1
  color:
2
2
  primary: "#1D9E75"
3
3
  surface: "#FFFFFF"
4
+ background: "#F5F6F8"
4
5
  text: "#1A1A1A"
6
+ muted: "#6B7280"
7
+ border: "#E5E7EB"
8
+ danger: "#C0392B"
5
9
  spacing: { sm: 8, md: 16, lg: 24 }
6
- font: { base: "Inter", size: 15 }
10
+ radius: 8
11
+ font: { base: "Inter, system-ui, sans-serif", size: 15 }
File without changes
@@ -1,6 +0,0 @@
1
- One file per user or role, e.g. `user-giulia.yaml`:
2
-
3
- extends: home
4
- why: "Reports every Monday, wants revenue first"
5
- patches:
6
- - { op: move, ref: charts/line, to: 0 }