@voxgig/build 4.3.0 → 4.11.0

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.
Files changed (100) hide show
  1. package/README.md +51 -2
  2. package/build.ts +20 -0
  3. package/dist/api/api_gen.d.ts +7 -0
  4. package/dist/api/api_gen.js +370 -0
  5. package/dist/api/api_gen.js.map +1 -0
  6. package/dist/build.d.ts +16 -1
  7. package/dist/build.js +17 -1
  8. package/dist/build.js.map +1 -1
  9. package/dist/doc/doc_gen.d.ts +8 -0
  10. package/dist/doc/doc_gen.js +397 -0
  11. package/dist/doc/doc_gen.js.map +1 -0
  12. package/dist/env/web/web_gen.js +159 -1
  13. package/dist/env/web/web_gen.js.map +1 -1
  14. package/dist/shape/ent.js +4 -2
  15. package/dist/shape/ent.js.map +1 -1
  16. package/env/web/web_gen.ts +174 -1
  17. package/package.json +6 -9
  18. package/tm/web/backend/env/shared/seed.ts.frag +23 -0
  19. package/tm/web/backend/env/web/api.ts.frag +157 -0
  20. package/tm/web/backend/env/web/web.ts.frag +37 -4
  21. package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
  22. package/tm/web/backend/srv/api/expose.ts.frag +33 -0
  23. package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
  24. package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
  25. package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
  26. package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
  27. package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
  28. package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
  29. package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
  30. package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
  31. package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
  32. package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
  33. package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
  34. package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
  35. package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
  36. package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
  37. package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
  38. package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
  39. package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
  40. package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
  41. package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
  42. package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
  43. package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
  44. package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
  45. package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
  46. package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
  47. package/tm/web/backend/srv/ent/access.ts.frag +179 -0
  48. package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
  49. package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
  50. package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
  51. package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
  52. package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
  53. package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
  54. package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
  55. package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
  56. package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
  57. package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
  58. package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
  59. package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
  60. package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
  61. package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
  62. package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
  63. package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
  64. package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
  65. package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
  66. package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
  67. package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
  68. package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
  69. package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
  70. package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
  71. package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
  72. package/tm/web/docs/reference/web-app.md.frag +98 -0
  73. package/tm/web/web/AGENTS.md.frag +52 -0
  74. package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
  75. package/tm/web/web/index.html.frag +3 -0
  76. package/tm/web/web/package.json.frag +4 -1
  77. package/tm/web/web/playwright.config.js.frag +6 -1
  78. package/tm/web/web/src/api.js.frag +92 -0
  79. package/tm/web/web/src/bus.js.frag +46 -23
  80. package/tm/web/web/src/cmp/admin.js.frag +292 -193
  81. package/tm/web/web/src/cmp/admin.md.frag +51 -0
  82. package/tm/web/web/src/cmp/app.js.frag +12 -15
  83. package/tm/web/web/src/cmp/app.md.frag +28 -0
  84. package/tm/web/web/src/cmp/auth.js.frag +69 -41
  85. package/tm/web/web/src/cmp/auth.md.frag +36 -0
  86. package/tm/web/web/src/cmp/public.js.frag +58 -0
  87. package/tm/web/web/src/cmp/public.md.frag +26 -0
  88. package/tm/web/web/src/cmp/settings.js.frag +178 -0
  89. package/tm/web/web/src/cmp/settings.md.frag +30 -0
  90. package/tm/web/web/src/cmp/shell.js.frag +231 -0
  91. package/tm/web/web/src/cmp/shell.md.frag +49 -0
  92. package/tm/web/web/src/cmp/view/custom-view.js.frag +47 -0
  93. package/tm/web/web/src/cmp/view/custom-view.md.frag +31 -0
  94. package/tm/web/web/src/custom.css.frag +8 -0
  95. package/tm/web/web/src/customise.js.frag +30 -0
  96. package/tm/web/web/src/hooks.js.frag +79 -0
  97. package/tm/web/web/src/main.js.frag +17 -4
  98. package/tm/web/web/src/model.js.frag +162 -0
  99. package/tm/web/web/src/style.css.frag +135 -21
  100. package/tm/web/web/src/theme.js.frag +82 -0
@@ -0,0 +1,109 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert'
3
+
4
+ import { makeSeneca, as } from './ent.setup'
5
+
6
+
7
+ // The browser proxies: aim:web,on:ent,cmd:* -> aim:ent,cmd:*
8
+ //
9
+ // A browser may only send aim:web, so these four actions ARE the entity
10
+ // surface. Two things matter: they forward faithfully, and they are not a
11
+ // way around the rules the real commands enforce.
12
+ //
13
+ // Forwarding is checked on the action functions directly - they are plain
14
+ // modules, so a fake `this` records what they post. No boot, no store, and
15
+ // nothing that depends on which entities this model happens to have.
16
+
17
+ const ALICE = { id: 'u-alice', email: 'alice@ex.com' }
18
+
19
+ const OPS = ['list', 'load', 'save', 'remove']
20
+
21
+ function proxyAction(op: string) {
22
+ return require('../../../../dist/srv/ent/web_cmd_' + op + '.js')()
23
+ }
24
+
25
+ // A `this` that records posts instead of sending them.
26
+ function recorder(result: any = { ok: true }) {
27
+ const posts: any[] = []
28
+ return {
29
+ posts,
30
+ ctx: {
31
+ post: async (pattern: string, msg: any) => {
32
+ posts.push({ pattern, msg })
33
+ return result
34
+ },
35
+ },
36
+ }
37
+ }
38
+
39
+
40
+ describe('entity browser proxies', () => {
41
+
42
+ test('each proxy forwards to its own entity command', async () => {
43
+ for (const op of OPS) {
44
+ const rec = recorder()
45
+ await proxyAction(op).call(rec.ctx, { ent: 'x/y' })
46
+ assert.strictEqual(rec.posts.length, 1, op)
47
+ assert.strictEqual(rec.posts[0].pattern, 'aim:ent,cmd:' + op, op)
48
+ }
49
+ })
50
+
51
+
52
+ test('arguments travel through unchanged', async () => {
53
+ const rec = recorder()
54
+
55
+ await proxyAction('list').call(rec.ctx, { ent: 'x/y', q: { a: 1 } })
56
+ assert.deepStrictEqual(rec.posts[0].msg.q, { a: 1 })
57
+ assert.strictEqual(rec.posts[0].msg.ent, 'x/y')
58
+
59
+ await proxyAction('load').call(rec.ctx, { ent: 'x/y', id: 'e01' })
60
+ assert.strictEqual(rec.posts[1].msg.id, 'e01')
61
+
62
+ await proxyAction('save').call(rec.ctx, { ent: 'x/y', item: { t: 'v' } })
63
+ assert.deepStrictEqual(rec.posts[2].msg.item, { t: 'v' })
64
+
65
+ await proxyAction('remove').call(rec.ctx, { ent: 'x/y', id: 'e01' })
66
+ assert.strictEqual(rec.posts[3].msg.id, 'e01')
67
+ })
68
+
69
+
70
+ test('the result is returned unchanged', async () => {
71
+ const rec = recorder({ ok: true, list: [{ id: 'e01' }] })
72
+ const out = await proxyAction('list').call(rec.ctx, { ent: 'x/y' })
73
+ assert.deepStrictEqual(out, { ok: true, list: [{ id: 'e01' }] })
74
+
75
+ const failed = recorder({ ok: false, why: 'forbidden' })
76
+ const bad = await proxyAction('load').call(failed.ctx, { ent: 'x/y', id: 'e01' })
77
+ assert.deepStrictEqual(bad, { ok: false, why: 'forbidden' })
78
+ })
79
+
80
+
81
+ test('proxies enforce the same rules, not weaker ones', async () => {
82
+ const seneca = await makeSeneca()
83
+
84
+ // Unauthenticated: refused, exactly like the real commands.
85
+ for (const cmd of OPS) {
86
+ const out = await seneca.post({
87
+ aim: 'web', on: 'ent', cmd, ent: 'sys/user', id: 'x', item: {},
88
+ })
89
+ assert.strictEqual(out.ok, false, cmd)
90
+ assert.strictEqual(out.why, 'not-authenticated', cmd)
91
+ }
92
+
93
+ // The sys zone stays unreachable through the proxy.
94
+ const sys = await as(seneca, ALICE,
95
+ { aim: 'web', on: 'ent', cmd: 'list', ent: 'sys/apikey' })
96
+ assert.strictEqual(sys.ok, false)
97
+ assert.strictEqual(sys.why, 'unknown-entity')
98
+
99
+ // sys/user remains read-only through it too.
100
+ const write = await as(seneca, ALICE, {
101
+ aim: 'web', on: 'ent', cmd: 'save', ent: 'sys/user', item: { name: 'Hacked' },
102
+ })
103
+ assert.strictEqual(write.ok, false)
104
+ assert.strictEqual(write.why, 'read-only')
105
+
106
+ await seneca.close()
107
+ })
108
+ })
109
+
@@ -0,0 +1,50 @@
1
+ # Explanation: web app architecture
2
+
3
+ *Diátaxis: explanation — why the generated app is built this way.*
4
+
5
+ ## One primitive: Seneca messages
6
+
7
+ The frontend is web components on a browser Seneca bus. Every data flow —
8
+ backend CRUD, auth, cross-component events, cache reactivity — is a
9
+ pattern-matched message. `aim:*` messages travel transparently to the
10
+ backend gateway (the same patterns the backend services answer); local
11
+ concerns stay local. One primitive means one debugging story (the
12
+ in-window devtools show every flow), one interception point (the store
13
+ cache), and no parallel event system to keep consistent.
14
+
15
+ ## Model-driven at runtime
16
+
17
+ The app fetches `/model.json` and derives its UI from it: the entity
18
+ menu, list columns, forms, relationship pickers and drill-down (from
19
+ `ref` fields), project scoping. Adding an entity to the model changes the
20
+ running UI on reload — no regeneration, no per-entity components, no
21
+ merge conflicts. This is what lets the same generated codebase serve an
22
+ entity graph of hundreds of entities.
23
+
24
+ ## Access is membership, not ownership
25
+
26
+ Signed-in users collaborate through projects: membership in a project
27
+ grants access to the project's data (any entity carrying a
28
+ `ref: 'proj/project'` field is scoped to the selected project). The
29
+ generic backend `ent` service enforces this on every message — the
30
+ frontend only *reflects* scoping (the project selector), it never
31
+ enforces it.
32
+
33
+ ## Generated once, then yours
34
+
35
+ The SPA is a starting point, not a framework artifact: after generation
36
+ the project owns it. Customisation is layered so upgrades stay cheap —
37
+ theme tokens (model) → CSS overrides (`custom.css`) → hooks
38
+ (`customise.js`) → custom entity views (model-declared) → direct edits
39
+ (last resort). Only `views.js` and `theme.css` regenerate, because they
40
+ are pure functions of the model.
41
+
42
+ ## No framework, deliberately
43
+
44
+ Vanilla custom elements + `innerHTML` rendering keep the generated code
45
+ dependency-light, readable, and long-lived (no framework version
46
+ treadmill in generated projects). The cost — manual re-render discipline —
47
+ is contained by two conventions: explicit `reload()` calls after
48
+ mutations, and a render token to discard stale async renders (see
49
+ `cmp/admin.js`).
50
+
@@ -0,0 +1,53 @@
1
+ # How to add a custom entity view
2
+
3
+ *Diátaxis: how-to guide — replace the generic admin UI for one entity
4
+ with a hand-coded component, declared in the model.*
5
+
6
+ ## 1. Declare it in the model
7
+
8
+ In `backend/model/ent.aontu`, mark the entity:
9
+
10
+ ```
11
+ shop: product: ux: { view: 'custom' }
12
+ ```
13
+
14
+ ## 2. Generate the starter
15
+
16
+ ```bash
17
+ cd backend && npm run model-build
18
+ ```
19
+
20
+ This creates `web/src/cmp/view/shop_product.js` (a working starter
21
+ component — create-once, so your edits survive every future build) and
22
+ regenerates `web/src/views.js`, the index that imports all custom views.
23
+ The shell now mounts `<vg-view-shop-product>` instead of the generic
24
+ `<vg-entity-admin>` for that entity.
25
+
26
+ ## 3. Implement the view
27
+
28
+ The component contract (same as the generic admin):
29
+
30
+ - **Properties** (set by the shell before `reload()`):
31
+ - `canon` — the entity canon, e.g. `'shop/product'`
32
+ - `projectId` — current project id when the entity is project-scoped,
33
+ else `null`
34
+ - `detailId` — an entity id to open in detail, or `null`
35
+ - `onNavigate(canon, id)` — call to navigate elsewhere in the app
36
+ - **Method**: `reload()` — re-fetch and re-render.
37
+
38
+ Use the same building blocks the generic admin uses:
39
+
40
+ ```js
41
+ import * as Model from '../../model.js' // labels, fields, refs graph
42
+ import * as Api from '../../api.js' // Api.list/load/save/remove(canon, ...)
43
+ ```
44
+
45
+ Data via `Api` goes through the generic `aim:ent` messages, so
46
+ membership scoping and validation apply unchanged.
47
+
48
+ ## Reverting
49
+
50
+ Remove the `ux` declaration and run `model-build` — `views.js` drops the
51
+ import and the entity falls back to the generic admin. Your component
52
+ file stays (delete it manually if unwanted).
53
+
@@ -0,0 +1,63 @@
1
+ # How to change the theme
2
+
3
+ *Diátaxis: how-to guide — the design theme is part of the model; modes
4
+ and tokens flow from there into generated CSS.*
5
+
6
+ ## Change a token
7
+
8
+ Edit `backend/model/theme.aontu` — each named mode (light, dark, ...) is
9
+ a set of design tokens:
10
+
11
+ ```
12
+ modes: {
13
+ light: {
14
+ primary: '#7c3aed'
15
+ # ...
16
+ }
17
+ }
18
+ ```
19
+
20
+ Then regenerate:
21
+
22
+ ```bash
23
+ cd backend && npm run model-build
24
+ ```
25
+
26
+ `web/src/theme.css` is rewritten: every token becomes a CSS variable
27
+ (`--vg-primary`, ...) scoped to its mode
28
+ (`:root[data-theme-mode="light"]`; the default mode also on `:root`).
29
+ Do not edit `theme.css` by hand — it regenerates from the model.
30
+
31
+ ## Add a mode
32
+
33
+ Add a new block under `modes:` (e.g. `sepia: { ... }`) with the same
34
+ token set, and set `mode:` if it should be the default. After
35
+ `model-build`, the shell's user menu automatically offers the new mode
36
+ (the toggle appears whenever more than one mode exists), and the user's
37
+ choice persists (localStorage `vg-theme-mode`).
38
+
39
+ ## Override per project without touching the model
40
+
41
+ `web/src/custom.css` loads after the theme:
42
+
43
+ ```css
44
+ :root { --vg-radius: 10px; } /* all modes */
45
+ :root[data-theme-mode="dark"] { --vg-bg: #000; } /* one mode */
46
+ ```
47
+
48
+ ## Add modes or intercept the mode list at runtime
49
+
50
+ ```js
51
+ // web/src/customise.js
52
+ import * as Hooks from './hooks.js'
53
+ Hooks.addFilter('theme:modes', (modes) => [...modes, 'sepia'])
54
+ ```
55
+
56
+ (Provide the CSS for a runtime-added mode yourself in `custom.css`.)
57
+
58
+ ## Standard tokens
59
+
60
+ `primary`, `primary-dark`, `bg`, `surface`, `text`, `muted`, `border`,
61
+ `topbar-bg`, `topbar-fg`, `accent-bg`, `font`, `radius`, `shadow-card`.
62
+ Any additional token you add becomes `--vg-<token>` too.
63
+
@@ -0,0 +1,68 @@
1
+ # How to customise the web app
2
+
3
+ *Diátaxis: how-to guide — change the generated app's look and behaviour
4
+ without editing generated components.*
5
+
6
+ Customisations live in two create-once files that survive regeneration:
7
+ `web/src/customise.js` (hook registrations) and `web/src/custom.css`
8
+ (style overrides).
9
+
10
+ ## Inject or change HTML
11
+
12
+ ```js
13
+ // web/src/customise.js
14
+ import * as Hooks from './hooks.js'
15
+
16
+ Hooks.addHtml('shell:topbar:right', () => '<span class="vg-badge">Beta</span>')
17
+ Hooks.addHtml('admin:row:actions', ({ canon }) =>
18
+ 'todo/item' === canon ? '<button class="vg-star" data-act="star">star</button>' : '')
19
+ ```
20
+
21
+ ## Transform data
22
+
23
+ ```js
24
+ // Sort or filter list rows before render:
25
+ Hooks.addFilter('admin:list:items', (items, { canon }) =>
26
+ 'todo/item' === canon
27
+ ? items.slice().sort((a, b) => (a.done === b.done ? 0 : a.done ? 1 : -1))
28
+ : items)
29
+
30
+ // Adjust the save payload:
31
+ Hooks.addFilter('admin:save:data', (data) => ({ ...data, source: 'web' }))
32
+ ```
33
+
34
+ ## Run behaviour at lifecycle moments
35
+
36
+ ```js
37
+ Hooks.addAction('admin:list:after', ({ root, canon, items }) => {
38
+ // wire events on injected elements, integrate analytics, etc.
39
+ })
40
+ ```
41
+
42
+ All hooks are synchronous and failure-isolated (a throwing hook is logged
43
+ and skipped). The full list of points is in the
44
+ [web app reference](../reference/web-app.md#hook-points).
45
+
46
+ ## Override styles
47
+
48
+ Generated styles use CSS variables from the theme. Override tokens or add
49
+ rules in `web/src/custom.css`:
50
+
51
+ ```css
52
+ :root { --vg-primary: #7c3aed; }
53
+ .vg-badge { background: gold; color: #333; }
54
+ ```
55
+
56
+ For theme-wide changes (all modes, or new modes), change the model
57
+ instead — see [Change the theme](change-the-theme.md).
58
+
59
+ ## When hooks aren't enough
60
+
61
+ - Replace an entity's whole view:
62
+ [Add a custom entity view](add-a-custom-entity-view.md).
63
+ - Backend behaviour: override Seneca actions with priors in the backend
64
+ services.
65
+ - As a last resort, edit the generated components directly — they are
66
+ create-once and yours; just note that framework updates then need a
67
+ manual merge.
68
+
@@ -0,0 +1,53 @@
1
+ # How to use the REST API
2
+
3
+ *Diátaxis: how-to guide — the strict-JSON REST API generated from the
4
+ model (`main.api` in `backend/model/api.aontu`).*
5
+
6
+ ## Get an API key
7
+
8
+ Sign in to the web app → user menu → **Settings & security** → **API
9
+ keys** → create a key. Copy it immediately — it is shown only once (only
10
+ a hash is stored). Revoke keys there too; revocation is immediate.
11
+
12
+ ## Call the API
13
+
14
+ Uniform semantic paths per entity — `<prefix>/<version>/<zone>/<name>`:
15
+
16
+ ```bash
17
+ KEY=vk_...
18
+ B=http://localhost:8080/api/v1
19
+
20
+ curl -H "Authorization: Bearer $KEY" $B/proj/project # list
21
+ curl -H "Authorization: Bearer $KEY" "$B/proj/project?name=X" # filtered list
22
+ curl -H "Authorization: Bearer $KEY" $B/proj/project/<id> # load
23
+ curl -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
24
+ -d '{"name":"New project"}' $B/proj/project # create -> 201
25
+ curl -X PUT -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
26
+ -d '{"note":"updated"}' $B/proj/project/<id> # partial update
27
+ curl -X DELETE -H "Authorization: Bearer $KEY" $B/proj/project/<id>
28
+ ```
29
+
30
+ Results: `{ items: [...] }` (list), `{ item: {...} }` (load/create/update),
31
+ `{ ok: true, id }` (delete). Errors carry an HTTP status and
32
+ `{ error: { code, message?, details? } }`.
33
+
34
+ The API is **strict**: request bodies are validated against the entity's
35
+ model definition (generated shapes) — unknown fields are rejected (400),
36
+ required fields enforced, server-managed fields (`id`, `owner_id`,
37
+ `t_c`, `t_m`) are read-only. Access is the same project-membership
38
+ scoping as the web app, as the key's owning user.
39
+
40
+ ## The OpenAPI spec
41
+
42
+ Generated from the model on every model-build:
43
+ `backend/gen/api/openapi.json`, also served unauthenticated at
44
+ `<prefix>/openapi.json`. Schemas come from the entity field definitions;
45
+ operation ids are uniform (`list_<zone>_<name>`, `create_...`, ...) so an
46
+ SDK can be generated from it (e.g. with sdkgen).
47
+
48
+ ## Configure
49
+
50
+ `backend/model/api.aontu` — `prefix`, `version`, `active`, and per-entity
51
+ exposure (`ent: 'zone/name': { active: false }`). The sys zone is never
52
+ exposed. Run `npm run model-build` after changes.
53
+
@@ -0,0 +1,98 @@
1
+ # Reference: the web app
2
+
3
+ *Diátaxis: reference — the generated SPA's structure, messages, hook
4
+ points, and theme contract.*
5
+
6
+ ## Files (web/src/)
7
+
8
+ | File | Role | Regenerated? |
9
+ |---|---|---|
10
+ | `main.js` | Entry: imports css, bus, components, views, customise | no |
11
+ | `bus.js` | The browser Seneca bus (transport to the backend gateway) | no |
12
+ | `model.js` | Runtime model: fetches `/model.json`; entities, refs graph, labels, project scoping, custom-view tags | no |
13
+ | `api.js` | Client for `aim:ent` CRUD + `aim:auth` | no |
14
+ | `hooks.js` | Customisation hook registry | no |
15
+ | `theme.js` | Theme mode controller (persisted; applies `data-theme-mode`) | no |
16
+ | `style.css` | App styles (consume `--vg-*` variables) | no |
17
+ | `theme.css` | CSS variables per theme mode | **yes — from the model** |
18
+ | `views.js` | Index importing custom entity views | **yes — from the model** |
19
+ | `customise.js` / `custom.css` | Project customisations | no (yours) |
20
+ | `cmp/app.js` | Router: signed-out → public, signed-in → shell | no |
21
+ | `cmp/public.js` | Public site + login mount | no |
22
+ | `cmp/auth.js` | Login / forgot-password form | no |
23
+ | `cmp/shell.js` | App shell: topbar, project selector, user menu, entity nav | no |
24
+ | `cmp/admin.js` | Generic entity admin (list/detail/form, relationship drill) | no |
25
+ | `cmp/settings.js` | Profile + change password | no |
26
+ | `cmp/view/*.js` | Custom entity views (one per `ux:{view:'custom'}` entity) | no (starters) |
27
+
28
+ ## Backend messages used
29
+
30
+ **The browser may only send `aim:web` messages.** The gateway allow-list
31
+ names that one namespace, and every message the SPA sends is declared in
32
+ the model as an `aim:web` PROXY that forwards to the real service message
33
+ (`web_*` action files). Service namespaces (`aim:auth`, `aim:ent`,
34
+ `aim:api`, ...) are internal: posting one from a browser is rejected with
35
+ `not-allowed`. API-key clients have the same shape - the REST router
36
+ posts `aim:api`, which is likewise not browser-reachable.
37
+
38
+ | Browser message | Proxies to | Purpose |
39
+ |---|---|---|
40
+ | `aim:web,on:ent,cmd:list,ent:<canon>` (+`q`) | `aim:ent,cmd:list` | List (membership/project-scoped) |
41
+ | `aim:web,on:ent,cmd:load,ent:<canon>` (+`id`) | `aim:ent,cmd:load` | Load one |
42
+ | `aim:web,on:ent,cmd:save,ent:<canon>` (+`item`) | `aim:ent,cmd:save` | Create/update |
43
+ | `aim:web,on:ent,cmd:remove,ent:<canon>` (+`id`) | `aim:ent,cmd:remove` | Delete |
44
+ | `aim:web,on:auth,load:auth` | `aim:auth,load:auth` | Current principal |
45
+ | `aim:web,on:auth,signin:user` / `signout:user` | `aim:auth,*` | Session (sets/clears the cookie) |
46
+ | `aim:web,on:auth,change:pass` / `update:user` | `aim:auth,*` | Settings & security |
47
+ | `aim:web,on:auth,remind:pass` | `aim:auth,remind:pass` | Password reminder (server stub) |
48
+ | `aim:web,on:auth,create:apikey` / `list:apikey` / `revoke:apikey` | `aim:auth,*` | API keys |
49
+
50
+ To expose a new operation to the browser, declare an `aim:web` proxy for
51
+ it in the model and implement the `web_*` action - never widen the
52
+ allow-list.
53
+
54
+ ## Hook points
55
+
56
+ Register in `customise.js`; kinds: `html` (inject markup), `filter`
57
+ (transform a value), `action` (side effect). All synchronous,
58
+ failure-isolated.
59
+
60
+ | Point | Kind | Context / value |
61
+ |---|---|---|
62
+ | `shell:topbar:right` | html | `{ user }` |
63
+ | `shell:sidebar:top` | html | `{ user }` |
64
+ | `shell:nav:items` | filter | entity list for the nav menu |
65
+ | `admin:list:toolbar` | html | list toolbar region |
66
+ | `admin:list:items` | filter | rows before render |
67
+ | `admin:list:columns` | filter | column defs |
68
+ | `admin:row:actions` | html | per-row action cell |
69
+ | `admin:list:after` | action | `{ root, canon, items }` |
70
+ | `admin:form:fields` | filter | form field defs |
71
+ | `admin:form:extra` | html | extra form markup |
72
+ | `admin:form:after` | action | after form render |
73
+ | `admin:save:data` | filter | payload before save |
74
+ | `admin:save:after` | action | after successful save |
75
+ | `public:sections` | html | extra public-site sections |
76
+ | `auth:form:footer` | html | login form footer |
77
+ | `settings:sections` | html | extra settings sections |
78
+ | `theme:modes` | filter | available theme mode list |
79
+
80
+ ## Theme contract
81
+
82
+ - Model: `main.theme` = `{ mode, modes: { <name>: { <token>: <value> } } }`
83
+ (source: `backend/model/theme.aontu`).
84
+ - Generated CSS: each token → `--vg-<token>` under
85
+ `:root[data-theme-mode="<name>"]`; default mode also on `:root`.
86
+ - Runtime: `theme.js` sets `data-theme-mode` on `<html>`; choice persists
87
+ in localStorage `vg-theme-mode`; shell user menu toggles when more than
88
+ one mode exists.
89
+ - Standard tokens: `primary`, `primary-dark`, `bg`, `surface`, `text`,
90
+ `muted`, `border`, `topbar-bg`, `topbar-fg`, `accent-bg`, `font`,
91
+ `radius`, `shadow-card`.
92
+
93
+ ## Custom entity view contract
94
+
95
+ Tag `vg-view-<zone>-<name>`; properties `canon`, `projectId`, `detailId`,
96
+ `onNavigate(canon, id)`; method `reload()`. Declared by
97
+ `ux: { view: 'custom' }` on the model entity.
98
+
@@ -0,0 +1,52 @@
1
+ # Agent guide: $$name$$ web app
2
+
3
+ The model-driven SPA for $$name$$: vanilla web components on a browser
4
+ Seneca message bus (no framework). Generated once by @voxgig/build EnvWeb;
5
+ now developer-owned. Backend guide: ../AGENTS.md. Concept docs:
6
+ ../docs/ (see reference/web-app.md and explanation/web-architecture.md).
7
+
8
+ ## Commands (run in web/)
9
+
10
+ ```bash
11
+ npm install
12
+ npm run dev # vite dev server (backend must be running: npm run web in backend/)
13
+ npm run e2e # playwright e2e (headless; starts its own backend)
14
+ ```
15
+
16
+ E2e tests run headless Chromium with workers=1 against a shared backend —
17
+ keep new specs independent of execution order but tolerant of shared seed
18
+ data.
19
+
20
+ ## Architecture in one paragraph
21
+
22
+ `main.js` imports theme/css, the bus, all components, `views.js`
23
+ (generated index of custom views), and `customise.js` (project hooks).
24
+ All data flows are Seneca messages on `bus.js`. **Only `aim:web`
25
+ messages leave the browser** - that is the sole namespace the gateway
26
+ accepts, and each one is a declared proxy to a real service message;
27
+ `cmp:*` and events stay local. The transport pin is `aim:web` too, so a
28
+ stray service-message post fails locally instead of travelling. The UI is
29
+ model-driven at runtime — `model.js` fetches `/model.json` and derives
30
+ the entity menu, forms, and relationship navigation; `api.js` wraps the
31
+ `aim:web,on:ent,cmd:*` CRUD proxies and the `aim:web,on:auth,*` proxies.
32
+
33
+ ## Editing rules
34
+
35
+ - Everything here is create-once EXCEPT `src/views.js` and
36
+ `src/theme.css` — those are REGENERATED from the model
37
+ (`npm run model-build` in backend/); never hand-edit them. Theme tokens
38
+ live in `backend/model/theme.aontu`; per-project overrides in
39
+ `src/custom.css`.
40
+ - Prefer customisation over editing generated components: hooks in
41
+ `src/customise.js` (see `src/hooks.js` for points), CSS variables in
42
+ `src/custom.css`, or a custom entity view (`ux:{view:'custom'}` in the
43
+ model). Edit components directly only when those layers can't express
44
+ the change.
45
+ - Components re-render by setting `innerHTML`; async renders must guard
46
+ against stale overwrites (see the render-token pattern in
47
+ `cmp/admin.js`: capture a token at method start, bail before writing if
48
+ superseded).
49
+ - `bus.sub` has no auto-unsubscribe: long-lived subscriptions in
50
+ components must guard callbacks with `this.isConnected`.
51
+ - Escape all user data with the local `esc()` helpers when building HTML.
52
+
@@ -1,21 +1,23 @@
1
1
  import { test, expect } from '@playwright/test'
2
2
 
3
- // Generic smoke test: the SPA boots, a seeded user can sign in, and the
4
- // model-driven entity admin renders with at least one entity. Add
5
- // project-specific CRUD flows alongside this file.
3
+ // Generic smoke test: the public site loads, a seeded user can sign in, and
4
+ // the model-driven app shell renders with an entity menu. Add project-specific
5
+ // flows (CRUD, relationship drill, settings) in your own spec files.
6
6
 
7
- test('sign in and load the entity admin', async ({ page }) => {
7
+ test('public site, sign in, and the app shell', async ({ page }) => {
8
8
  await page.goto('/')
9
9
 
10
+ // Public content site with a login form.
10
11
  await expect(page.locator('vg-auth h2')).toHaveText('Sign in')
11
12
 
12
- await page.fill('input[name=email]', '$$seedEmail$$')
13
- await page.fill('input[name=password]', '$$seedPassword$$')
13
+ await page.fill('vg-auth input[name=email]', '$$seedEmail$$')
14
+ await page.fill('vg-auth input[name=password]', '$$seedPassword$$')
14
15
  await page.click('vg-auth button[type=submit]')
15
16
 
16
- await expect(page.locator('.vg-auth-bar b')).toHaveText('$$seedEmail$$')
17
- await expect(page.locator('vg-entity-admin nav a')).not.toHaveCount(0)
18
-
19
- // The first entity's list loads (count line rendered).
20
- await expect(page.locator('#vg-count')).toBeVisible()
17
+ // The enterprise shell: top bar with the signed-in user, and a
18
+ // model-driven entity menu with at least one entity.
19
+ await expect(page.locator('.vg-shell')).toBeVisible()
20
+ await expect(page.locator('.vg-user-btn')).toContainText('$$seedEmail$$')
21
+ await expect(page.locator('.vg-navlink')).not.toHaveCount(0)
21
22
  })
23
+
@@ -5,9 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>$$Name$$</title>
7
7
  <script src="/seneca-browser.js"></script>
8
+ <script src="/seneca-browser-store.js"></script>
9
+ <script src="/seneca-browser-debug.js"></script>
8
10
  </head>
9
11
  <body>
10
12
  <vg-app></vg-app>
11
13
  <script type="module" src="/src/main.js"></script>
12
14
  </body>
13
15
  </html>
16
+
@@ -4,14 +4,17 @@
4
4
  "private": true,
5
5
  "description": "$$Name$$ SPA: web components on a Seneca service bus",
6
6
  "scripts": {
7
- "postinstall": "node -e \"require('fs').copyFileSync(require.resolve('seneca-browser'),'public/seneca-browser.js')\"",
7
+ "postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});for(const p of ['seneca-browser','@voxgig/seneca-browser-store','@voxgig/seneca-browser-debug'])fs.copyFileSync(require.resolve(p),'public/'+p.replace('@voxgig/','')+'.js')\"",
8
8
  "dev": "vite",
9
9
  "build": "vite build",
10
10
  "e2e": "playwright test"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@playwright/test": "^1.58.2",
14
+ "@voxgig/seneca-browser-debug": "^0.1.0",
15
+ "@voxgig/seneca-browser-store": "^0.1.0",
14
16
  "seneca-browser": "^8.0.0-rc4",
15
17
  "vite": "^7.3.4"
16
18
  }
17
19
  }
20
+
@@ -9,6 +9,10 @@ export default defineConfig({
9
9
  testDir: './e2e',
10
10
  timeout: 30000,
11
11
  fullyParallel: false,
12
+ // One worker: all specs share a single backend web runner with one
13
+ // in-memory store and the same seeded users, so they must run serially to
14
+ // avoid cross-spec contention on shared state.
15
+ workers: 1,
12
16
  reporter: [['list']],
13
17
  use: {
14
18
  baseURL: `http://localhost:${PORT}`,
@@ -26,7 +30,7 @@ export default defineConfig({
26
30
  },
27
31
  ],
28
32
  webServer: {
29
- command: `npm run build && cd ../backend && PORT=${PORT} node dist/env/web/web.js`,
33
+ command: `npm run build && cd ../backend && PORT=${PORT} REPL=false node dist/env/web/web.js`,
30
34
  url: `http://localhost:${PORT}/`,
31
35
  timeout: 60000,
32
36
  reuseExistingServer: false,
@@ -34,3 +38,4 @@ export default defineConfig({
34
38
  stderr: 'pipe',
35
39
  },
36
40
  })
41
+