create-flowdular 0.4.0 → 0.4.3

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 (32) hide show
  1. package/agent-template/.agents/skills/module-new/SKILL.md +20 -19
  2. package/agent-template/.agents/skills/module-update/SKILL.md +4 -2
  3. package/agent-template/.agents/skills/spec-interview/SKILL.md +21 -20
  4. package/agent-template/.agents/skills/ux-design/SKILL.md +23 -1
  5. package/agent-template/.ai/platform-capabilities.md +8 -4
  6. package/agent-template/.ai/policies/capabilities.yaml +1 -0
  7. package/agent-template/.ai/references/catalog/module.json +1 -1
  8. package/agent-template/.ai/references/catalog/package.json +2 -2
  9. package/agent-template/.ai/references/catalog/spec/module.yaml +1 -1
  10. package/agent-template/.ai/references/catalog/src/client/CatalogItemForm.tsrx +4 -4
  11. package/agent-template/.ai/references/catalog/src/client/CatalogView.tsrx +28 -27
  12. package/agent-template/.ai/references/catalog.provenance.json +8 -8
  13. package/agent-template/.ai/skills/module-new/SKILL.md +20 -19
  14. package/agent-template/.ai/skills/module-update/SKILL.md +4 -2
  15. package/agent-template/.ai/skills/spec-interview/SKILL.md +21 -20
  16. package/agent-template/.ai/skills/ux-design/SKILL.md +23 -1
  17. package/agent-template/.claude/skills/module-new/SKILL.md +20 -19
  18. package/agent-template/.claude/skills/module-update/SKILL.md +4 -2
  19. package/agent-template/.claude/skills/spec-interview/SKILL.md +21 -20
  20. package/agent-template/.claude/skills/ux-design/SKILL.md +23 -1
  21. package/agent-template/docs/cli.md +3 -0
  22. package/agent-template/docs/configuration.md +17 -0
  23. package/agent-template/docs/design-system.md +8 -1
  24. package/agent-template/docs/module-web-surfaces.md +31 -3
  25. package/package.json +1 -1
  26. package/template/default/modules/example/module.json +1 -1
  27. package/template/default/modules/example/package.json +1 -1
  28. package/template/default/modules/example/spec/module.yaml +1 -1
  29. package/template/default/package.json +1 -1
  30. package/template/default/platform/octane.config.ts +26 -2
  31. package/template/default/platform/package.json +1 -1
  32. package/template/default/platform/scripts/dev.mjs +16 -1
@@ -29,25 +29,26 @@ For an edit, also read the module's current `spec/module.yaml` and write the sma
29
29
 
30
30
  One pass, in this order. For each row, write the default from the card into the spec and record it as a `decisions[]` entry with `decidedBy: default`. Ask only where the answer is a business fact that no default can supply.
31
31
 
32
- | Decision | Default to propose | Lands in |
33
- | ---------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------- |
34
- | Actors | Owner manages, member reads | `permissions`, `invariants` |
35
- | Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name | `entities[]` |
36
- | Uniqueness | The human-facing code is `unique: tenant`; everything else `none` | `entities[].fields[].unique` |
37
- | States and transitions | `active` and `archived`, every transition behind the manage permission | `entities[].states` |
38
- | Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope | `permissions`, `screens[]`, `invariants` |
39
- | What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing | `acceptanceScenarios` |
40
- | Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400 | `invariants`, `acceptanceScenarios` |
41
- | Cross-module reads | None. A read of another module goes through its public capability and a declared dependency | `dependencies`, `dataOwnership` |
42
- | Screens | One `list` screen with the entity's identifying columns | `screens[]` |
43
- | Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it | `widgets[]` |
44
- | Settings | None. A number the business may change later is `scope: tenant` with a stated default | `settings[]` |
45
- | Agent tools | None. A tool is a later phase and `risk` may only be `read` or `workspace-write` | `agentTools[]` |
46
- | Outside sources | None. A named public source is `research`, with the entity its findings attach to | `research` |
47
- | Other systems | None. A named system is one `source` adapter per record kind, run on demand | `adapters[]` |
48
- | Documents | None. A named document is one `templates[]` entry on the record it describes | `templates[]` |
49
- | Reports | None. There is no export, no PDF and no search; a report is a screen or it is out of scope | `outOfScope[]` |
50
- | Out of scope | Every item from the card's gap list the request touched, each with its business decision | `outOfScope[]`, `decisions[]` |
32
+ | Decision | Default to propose | Lands in |
33
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
34
+ | Actors | Owner manages, member reads | `permissions`, `invariants` |
35
+ | Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name | `entities[]` |
36
+ | Uniqueness | The human-facing code is `unique: tenant`; everything else `none` | `entities[].fields[].unique` |
37
+ | States and transitions | `active` and `archived`, every transition behind the manage permission | `entities[].states` |
38
+ | Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope | `permissions`, `screens[]`, `invariants` |
39
+ | What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing | `acceptanceScenarios` |
40
+ | Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400 | `invariants`, `acceptanceScenarios` |
41
+ | Cross-module reads | None. A read of another module goes through its public capability and a declared dependency | `dependencies`, `dataOwnership` |
42
+ | Screens | One `list` screen with the entity's identifying columns | `screens[]` |
43
+ | Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it | `widgets[]` |
44
+ | Settings | None. A number the business may change later is `scope: tenant` with a stated default | `settings[]` |
45
+ | Feature flags | Ask for one whenever a change alters behaviour a workspace already relies on, or is hard to undo: `kind: flag`, boolean, `scope: tenant`, a stated default, and the behaviour named in an invariant | `settings[]` |
46
+ | Agent tools | None. A tool is a later phase and `risk` may only be `read` or `workspace-write` | `agentTools[]` |
47
+ | Outside sources | None. A named public source is `research`, with the entity its findings attach to | `research` |
48
+ | Other systems | None. A named system is one `source` adapter per record kind, run on demand | `adapters[]` |
49
+ | Documents | None. A named document is one `templates[]` entry on the record it describes | `templates[]` |
50
+ | Reports | None. There is no export, no PDF and no search; a report is a screen or it is out of scope | `outOfScope[]` |
51
+ | Out of scope | Every item from the card's gap list the request touched, each with its business decision | `outOfScope[]`, `decisions[]` |
51
52
 
52
53
  A default you propose is still a decision: it goes into `decisions[]` so the operator can see and overturn it, and so the next agent never re-derives it.
53
54
 
@@ -95,7 +96,7 @@ A number the case needs (a score, a premium, a price per square metre) is an `ac
95
96
  - `screens[]`: `{ id, kind: list|record|form|dashboard, entity?, title?, columns?, filters?, navigationGroup? }`. `navigationGroup` is one of the six values on the card.
96
97
  - `actions[]`: `{ id, entity?, permission, kind: create|update|delete|custom, risk, idempotent, description }`. `risk: external` is refused by the platform, so an action may not declare it.
97
98
  - `widgets[]`: `{ id, slot, entity?, description }`; `slot` is one of the four workspace slots.
98
- - `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, default?, values?, description }`.
99
+ - `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, kind?: setting|flag, default?, values?, description }`. A `flag` is boolean, `scope: tenant` and carries a default; owners switch it per workspace on the Flags tab of Administration, Modules, and every change is audited. Ask whether the new behaviour ships behind one: a change to something a workspace already does, an outward-facing action, a costly or slow path, or anything a business would want to turn off without a deployment. Say what the module does with the flag off, as an invariant.
99
100
  - `agentTools[]`: `{ id, permission, description, risk: read|workspace-write }`.
100
101
  - `outOfScope[]`: plain sentences, each naming the gap and the decision taken instead.
101
102
  - `decisions[]`: `{ id, question, answer, decidedBy: user|default }`; ids match `^[A-Z][A-Z0-9-]+$`, for example `D-UNIQUE-SKU`.
@@ -98,7 +98,28 @@ Layout `ui-view`, `ui-two-col` (+`--wide-aside`), `ui-grid-2`, `ui-kpi-grid`, `u
98
98
 
99
99
  User-facing copy lives in every declared `translations/*.json` bundle and is read with fully qualified `t()` keys. Eyebrow names the domain, title names the records, and description is one sentence. Table headers say what the value is. Buttons start with a verb. Loading text ends with `…`. Drawer footer states the constraint the user cannot see. Write natural copy in each locale, with no exclamation marks or database jargon.
100
100
 
101
- ## 7. Inspect the rendered screen
101
+ ## 7. Design the screen as a preview first
102
+
103
+ A design is a file, not a description: `modules/<dir>/design/<screen>.html`, beside the spec. It is markup only, dressed by the platform's own stylesheets, so it shows what the screen will look like before a component exists and long before the application boots. An implementation phase reads it the way it reads the spec.
104
+
105
+ ```bash
106
+ pnpm ui:preview modules/<dir>/design/<screen>.html --scaffold # the record recipe in all five states
107
+ pnpm ui:preview modules/<dir>/design/<screen>.html # renders it, prints a file:// address
108
+ pnpm ui:preview modules/<dir>/design/<screen>.html --shot .flowdular/ui-preview/<screen>.png
109
+ pnpm ui:preview modules/<dir>/design/<screen>.html --open # shows it to the person asking
110
+ ```
111
+
112
+ Rules that keep a preview honest:
113
+
114
+ - Markup only. No `<style>`, no CSS rule, no `<html>` or `<body>`; the command refuses a fragment that carries one. Every visual decision comes from `packages/ui`, which is what stops a preview from becoming a second source of truth.
115
+ - One `<section class="ui-view" data-state="...">` per state: `populated`, `loading`, `empty`, `error`, `denied`. The command labels each one, so a single page answers for all five.
116
+ - Real content. The longest realistic name, a real identifier, the copy the screen will actually carry. A preview of `Lorem ipsum` proves nothing about overflow or alignment.
117
+ - Only classes a stylesheet declares. `pnpm ui-classes:check` fails on a class nothing defines, in a preview and in a `.tsrx` alike, which is the one mistake that compiles, passes its tests and renders unstyled. A class a module needs and the design system lacks is declared in the module's own CSS, as rule 4 says.
118
+ - `--shot` writes a PNG, which is how an agent with no browser looks at its own work. `--open` opens the preview in the person's browser, which is how a design is shown for approval; a correction loop renders without it rather than throwing a window at whoever is at the keyboard.
119
+
120
+ Hand off the path, not a description. An implementation phase opens the preview, mirrors its structure with the components in section 4, and keeps the copy.
121
+
122
+ ## 8. Inspect the rendered screen
102
123
 
103
124
  A screen is not finished until it has been looked at. Typecheck and tests say nothing about overflow, alignment, a duplicate label or a column that collapses.
104
125
 
@@ -122,6 +143,7 @@ Check the drawer form in the same pass: one label per field, fields top-aligned,
122
143
 
123
144
  ## Pitfalls
124
145
 
146
+ - A preview that renders unstyled is a class nothing declares, not a broken harness; run `pnpm ui-classes:check`.
125
147
  - `Kpi value={items.length}` does not typecheck; use `String(items.length)`.
126
148
  - A `Tag` for a lifecycle state uses `success` for active and `neutral` for archived, with `dot`.
127
149
  - An `Icon` inside `Button size="sm"` is 14, not 18.
@@ -20,25 +20,26 @@ Every `acceptanceScenarios[]` entry maps to at least one test in `tests/`. A sce
20
20
 
21
21
  Each spec element maps to files:
22
22
 
23
- | Spec element | Files it produces |
24
- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `entities[]` | `src/domain/types.ts`, `migrations/000N_<module>_<name>.{up,down}.sql`, `src/services/migration.ts`, `src/services/{repository,database-repository}.ts` |
26
- | `entities[].fields[]` | the columns and row mapping above, the validation bounds in `src/api/endpoints.ts`, the form field and table cell in `src/client/*.tsrx` |
27
- | `fields[].unique: tenant` | a `(tenant_id, <field>)` unique index in the migration plus the stable conflict code in the service |
28
- | `entities[].states` | the status column, the transition guard in the service, the `Tag` tone in the view |
29
- | `screens[]` | `src/client/<Pascal>View.tsrx`, a `views` entry and a `navigation` entry in `src/client/contribution.tsrx`, `translations/*.json` |
30
- | `screens[].columns`/`filters` | the `TableColumn[]` outside the component, and the controls inside the `Filters` dropdown |
31
- | `screens[].navigationGroup` | the navigation entry's `group` |
32
- | `actions[]` | the service method with its error code, the `defineEndpoint` route, the fetch in `src/client/api.ts` |
33
- | `widgets[]` | a widget component plus a `widgets` entry with its `slot` in `src/client/contribution.tsrx` |
34
- | `settings[]` | `src/settings.ts` (`defineModuleSettings`) and `settings:` in `src/platform.ts` |
35
- | `agentTools[]` | `src/agent/tools.ts` and the `context.agentTools.register` call, as a separate `agent-tool-design` phase |
36
- | `research` | `src/research.ts`, `research-fixtures.json`, `requires` of the capabilities, the evidence attach where the `evidenceOwner` record is written |
37
- | `adapters[]` | `src/adapters/<name>.ts`, the port, the adapter registration, `adapters/<name>.recorded.json`, as a separate `integration-adapter` phase |
38
- | `templates[]` | `templates/<name>.md`, `templates` in `package.json` `files`, `src/templates.ts` registered through `documents.templates.v1` in `src/platform.ts` |
39
- | `permissions[]` | `src/acl/permissions.ts`, the endpoint `access.permission`, the client `scope` |
40
- | `acceptanceScenarios[]` | `tests/module.test.ts` and its siblings, at least one case each |
41
- | `outOfScope[]`, `decisions[]` | no code. Read them so you do not rebuild a decision or implement a deferred feature. |
23
+ | Spec element | Files it produces |
24
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `entities[]` | `src/domain/types.ts`, `migrations/000N_<module>_<name>.{up,down}.sql`, `src/services/migration.ts`, `src/services/{repository,database-repository}.ts` |
26
+ | `entities[].fields[]` | the columns and row mapping above, the validation bounds in `src/api/endpoints.ts`, the form field and table cell in `src/client/*.tsrx` |
27
+ | `fields[].unique: tenant` | a `(tenant_id, <field>)` unique index in the migration plus the stable conflict code in the service |
28
+ | `entities[].states` | the status column, the transition guard in the service, the `Tag` tone in the view |
29
+ | `screens[]` | `src/client/<Pascal>View.tsrx`, a `views` entry and a `navigation` entry in `src/client/contribution.tsrx`, `translations/*.json` |
30
+ | `design/<screen>.html` | the approved look of that screen: mirror its structure and keep its copy. Render it with `pnpm ui:preview <path>`; absent, design it first with `ux-design` |
31
+ | `screens[].columns`/`filters` | the `TableColumn[]` outside the component, and the controls inside the `Filters` dropdown |
32
+ | `screens[].navigationGroup` | the navigation entry's `group` |
33
+ | `actions[]` | the service method with its error code, the `defineEndpoint` route, the fetch in `src/client/api.ts` |
34
+ | `widgets[]` | a widget component plus a `widgets` entry with its `slot` in `src/client/contribution.tsrx` |
35
+ | `settings[]` | `src/settings.ts` (`defineModuleSettings`) and `settings:` in `src/platform.ts`; a `kind: flag` entry keeps `kind: 'flag'` there, so owners switch it on the Flags tab of Administration, Modules |
36
+ | `agentTools[]` | `src/agent/tools.ts` and the `context.agentTools.register` call, as a separate `agent-tool-design` phase |
37
+ | `research` | `src/research.ts`, `research-fixtures.json`, `requires` of the capabilities, the evidence attach where the `evidenceOwner` record is written |
38
+ | `adapters[]` | `src/adapters/<name>.ts`, the port, the adapter registration, `adapters/<name>.recorded.json`, as a separate `integration-adapter` phase |
39
+ | `templates[]` | `templates/<name>.md`, `templates` in `package.json` `files`, `src/templates.ts` registered through `documents.templates.v1` in `src/platform.ts` |
40
+ | `permissions[]` | `src/acl/permissions.ts`, the endpoint `access.permission`, the client `scope` |
41
+ | `acceptanceScenarios[]` | `tests/module.test.ts` and its siblings, at least one case each |
42
+ | `outOfScope[]`, `decisions[]` | no code. Read them so you do not rebuild a decision or implement a deferred feature. |
42
43
 
43
44
  A v1 spec stays valid and carries none of these arrays. Then the requirements are `invariants`, `permissions` and `acceptanceScenarios`, and everything the spec leaves open is still a question rather than a guess.
44
45
 
@@ -16,7 +16,7 @@ The spec-element to file mapping is the table in `module-new`; the change classe
16
16
 
17
17
  ## 1. Read first
18
18
 
19
- Read the whole module before changing it: `spec/module.yaml`, `src/index.ts`, `src/acl/permissions.ts`, `src/api/endpoints.ts`, `src/services/*`, `src/client/*`, `tests/`. Keep every exported name in `src/index.ts`, `src/server/index.ts` and `src/client/index.ts` stable: other modules import them (`modules/users` uses `AuthRuntime` from `@flowdular/sdk/modules/auth/server`), and the generated composition imports `createServerComposition` and `createClientContribution`.
19
+ Read the whole module before changing it: `spec/module.yaml`, `design/*.html` when the change touches a screen, `src/index.ts`, `src/acl/permissions.ts`, `src/api/endpoints.ts`, `src/services/*`, `src/client/*`, `tests/`. Keep every exported name in `src/index.ts`, `src/server/index.ts` and `src/client/index.ts` stable: other modules import them (`modules/users` uses `AuthRuntime` from `@flowdular/sdk/modules/auth/server`), and the generated composition imports `createServerComposition` and `createClientContribution`.
20
20
 
21
21
  Sandbox facts for an edit session (`packages/sandbox/src/server/sessions.ts`): the module is copied to `workspace/modules/<dir>` and a pristine copy to `base/modules/<dir>`; the diff shown to the operator and the eject plan compare the two. The workspace is a pnpm workspace of its own (declared dependencies install for real; a `package.json` change triggers a reinstall that counts as the `dependencies` gate). The business manager updates the spec before implementation. The operator approves the exact spec hash for every affected module; editing that spec, requesting changes, or adding another module reopens its approval gate. A sandbox specialist never writes `status: approved`; a host agent may invoke approval only after an explicit current user request through `spec-approval`. Delivery checks the recorded hash again.
22
22
 
@@ -55,9 +55,11 @@ New screen or widget:
55
55
  3. `src/client/index.ts`: re-export the view.
56
56
  4. Add user-facing copy to every declared `translations/*.json` bundle and resolve it with fully qualified `t()` keys. Navigation copy uses getters because contributions exist before bundles are registered.
57
57
 
58
+ New feature flag: a setting with `kind: 'flag'`, `type: 'boolean'`, `scope: 'tenant'` and a `defaultValue`. It reads like any other setting, `context.settings.get<boolean>(tenantId, '<module>.core', 'key')`, and appears on the Flags tab of Administration, Modules beside the module's other flags; a change is audited as `settings.flag.changed`. Declare it in the spec as `kind: flag` too, and state in an invariant what the module does while it is off. Before changing behaviour a workspace already relies on, removing a step, or adding an outward-facing or costly path, propose a flag and let the operator decide: ship the new path behind it, default off, and keep the old path working while it is off. A flag is on or off for one workspace; there is no percentage rollout and no targeting.
59
+
58
60
  New setting:
59
61
 
60
- 1. `src/settings.ts`: `export const X_MODULE_SETTINGS = defineModuleSettings({ moduleId: '<module>.core', settings: { key: { type: 'string' | 'number' | 'boolean', defaultValue, visibility: 'private' | 'shared', client: boolean, scope: 'tenant' | 'platform', labelKey, label, descriptionKey, description, min?, max?, enum?, secret? } } })` from `@flowdular/sdk/kernel` (`packages/kernel/src/module-settings.ts`; setting keys match `^[a-z][a-zA-Z0-9]*$`). `labelKey` and `descriptionKey` are fully qualified module translation keys present in every locale. Keep the English literals as compatibility fallbacks; values, ids and secrets are never translated.
62
+ 1. `src/settings.ts`: `export const X_MODULE_SETTINGS = defineModuleSettings({ moduleId: '<module>.core', settings: { key: { type: 'string' | 'number' | 'boolean', kind?: 'flag', defaultValue, visibility: 'private' | 'shared', client: boolean, scope: 'tenant' | 'platform', labelKey, label, descriptionKey, description, min?, max?, enum?, secret? } } })` from `@flowdular/sdk/kernel` (`packages/kernel/src/module-settings.ts`; setting keys match `^[a-z][a-zA-Z0-9]*$`). `labelKey` and `descriptionKey` are fully qualified module translation keys present in every locale. Keep the English literals as compatibility fallbacks; values, ids and secrets are never translated.
61
63
  2. `src/platform.ts`: return `settings: X_MODULE_SETTINGS` next to `routes`; the platform declares it at boot and Administration, Modules renders it in the module's drawer (`modules/system/src/client/ModuleSettingsSection.tsrx`, behind `system.settings.read` and `system.settings.manage`; the API is `GET /api/settings` and `POST /api/settings/update` in `modules/system/src/server/endpoints.ts`).
62
64
  3. Read it live where it is used: `context.settings.get<number>(tenantId, '<module>.core', 'key')` at request time, never cached at boot; pass `context.settings` into the runtime or service that needs it (`modules/agents/src/settings.ts`, `agentSettings`, shows the pattern with an environment fallback).
63
65
  4. `spec/module.yaml`: an invariant or scenario naming the setting and its bounds; `specVersion` bump. Cross-module reads of a setting need `visibility: 'shared'` and a declared dependency.
@@ -23,25 +23,26 @@ For an edit, also read the module's current `spec/module.yaml` and write the sma
23
23
 
24
24
  One pass, in this order. For each row, write the default from the card into the spec and record it as a `decisions[]` entry with `decidedBy: default`. Ask only where the answer is a business fact that no default can supply.
25
25
 
26
- | Decision | Default to propose | Lands in |
27
- | ---------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------- |
28
- | Actors | Owner manages, member reads | `permissions`, `invariants` |
29
- | Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name | `entities[]` |
30
- | Uniqueness | The human-facing code is `unique: tenant`; everything else `none` | `entities[].fields[].unique` |
31
- | States and transitions | `active` and `archived`, every transition behind the manage permission | `entities[].states` |
32
- | Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope | `permissions`, `screens[]`, `invariants` |
33
- | What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing | `acceptanceScenarios` |
34
- | Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400 | `invariants`, `acceptanceScenarios` |
35
- | Cross-module reads | None. A read of another module goes through its public capability and a declared dependency | `dependencies`, `dataOwnership` |
36
- | Screens | One `list` screen with the entity's identifying columns | `screens[]` |
37
- | Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it | `widgets[]` |
38
- | Settings | None. A number the business may change later is `scope: tenant` with a stated default | `settings[]` |
39
- | Agent tools | None. A tool is a later phase and `risk` may only be `read` or `workspace-write` | `agentTools[]` |
40
- | Outside sources | None. A named public source is `research`, with the entity its findings attach to | `research` |
41
- | Other systems | None. A named system is one `source` adapter per record kind, run on demand | `adapters[]` |
42
- | Documents | None. A named document is one `templates[]` entry on the record it describes | `templates[]` |
43
- | Reports | None. There is no export, no PDF and no search; a report is a screen or it is out of scope | `outOfScope[]` |
44
- | Out of scope | Every item from the card's gap list the request touched, each with its business decision | `outOfScope[]`, `decisions[]` |
26
+ | Decision | Default to propose | Lands in |
27
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
28
+ | Actors | Owner manages, member reads | `permissions`, `invariants` |
29
+ | Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name | `entities[]` |
30
+ | Uniqueness | The human-facing code is `unique: tenant`; everything else `none` | `entities[].fields[].unique` |
31
+ | States and transitions | `active` and `archived`, every transition behind the manage permission | `entities[].states` |
32
+ | Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope | `permissions`, `screens[]`, `invariants` |
33
+ | What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing | `acceptanceScenarios` |
34
+ | Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400 | `invariants`, `acceptanceScenarios` |
35
+ | Cross-module reads | None. A read of another module goes through its public capability and a declared dependency | `dependencies`, `dataOwnership` |
36
+ | Screens | One `list` screen with the entity's identifying columns | `screens[]` |
37
+ | Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it | `widgets[]` |
38
+ | Settings | None. A number the business may change later is `scope: tenant` with a stated default | `settings[]` |
39
+ | Feature flags | Ask for one whenever a change alters behaviour a workspace already relies on, or is hard to undo: `kind: flag`, boolean, `scope: tenant`, a stated default, and the behaviour named in an invariant | `settings[]` |
40
+ | Agent tools | None. A tool is a later phase and `risk` may only be `read` or `workspace-write` | `agentTools[]` |
41
+ | Outside sources | None. A named public source is `research`, with the entity its findings attach to | `research` |
42
+ | Other systems | None. A named system is one `source` adapter per record kind, run on demand | `adapters[]` |
43
+ | Documents | None. A named document is one `templates[]` entry on the record it describes | `templates[]` |
44
+ | Reports | None. There is no export, no PDF and no search; a report is a screen or it is out of scope | `outOfScope[]` |
45
+ | Out of scope | Every item from the card's gap list the request touched, each with its business decision | `outOfScope[]`, `decisions[]` |
45
46
 
46
47
  A default you propose is still a decision: it goes into `decisions[]` so the operator can see and overturn it, and so the next agent never re-derives it.
47
48
 
@@ -89,7 +90,7 @@ A number the case needs (a score, a premium, a price per square metre) is an `ac
89
90
  - `screens[]`: `{ id, kind: list|record|form|dashboard, entity?, title?, columns?, filters?, navigationGroup? }`. `navigationGroup` is one of the six values on the card.
90
91
  - `actions[]`: `{ id, entity?, permission, kind: create|update|delete|custom, risk, idempotent, description }`. `risk: external` is refused by the platform, so an action may not declare it.
91
92
  - `widgets[]`: `{ id, slot, entity?, description }`; `slot` is one of the four workspace slots.
92
- - `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, default?, values?, description }`.
93
+ - `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, kind?: setting|flag, default?, values?, description }`. A `flag` is boolean, `scope: tenant` and carries a default; owners switch it per workspace on the Flags tab of Administration, Modules, and every change is audited. Ask whether the new behaviour ships behind one: a change to something a workspace already does, an outward-facing action, a costly or slow path, or anything a business would want to turn off without a deployment. Say what the module does with the flag off, as an invariant.
93
94
  - `agentTools[]`: `{ id, permission, description, risk: read|workspace-write }`.
94
95
  - `outOfScope[]`: plain sentences, each naming the gap and the decision taken instead.
95
96
  - `decisions[]`: `{ id, question, answer, decidedBy: user|default }`; ids match `^[A-Z][A-Z0-9-]+$`, for example `D-UNIQUE-SKU`.
@@ -93,7 +93,28 @@ Layout `ui-view`, `ui-two-col` (+`--wide-aside`), `ui-grid-2`, `ui-kpi-grid`, `u
93
93
 
94
94
  User-facing copy lives in every declared `translations/*.json` bundle and is read with fully qualified `t()` keys. Eyebrow names the domain, title names the records, and description is one sentence. Table headers say what the value is. Buttons start with a verb. Loading text ends with `…`. Drawer footer states the constraint the user cannot see. Write natural copy in each locale, with no exclamation marks or database jargon.
95
95
 
96
- ## 7. Inspect the rendered screen
96
+ ## 7. Design the screen as a preview first
97
+
98
+ A design is a file, not a description: `modules/<dir>/design/<screen>.html`, beside the spec. It is markup only, dressed by the platform's own stylesheets, so it shows what the screen will look like before a component exists and long before the application boots. An implementation phase reads it the way it reads the spec.
99
+
100
+ ```bash
101
+ pnpm ui:preview modules/<dir>/design/<screen>.html --scaffold # the record recipe in all five states
102
+ pnpm ui:preview modules/<dir>/design/<screen>.html # renders it, prints a file:// address
103
+ pnpm ui:preview modules/<dir>/design/<screen>.html --shot .flowdular/ui-preview/<screen>.png
104
+ pnpm ui:preview modules/<dir>/design/<screen>.html --open # shows it to the person asking
105
+ ```
106
+
107
+ Rules that keep a preview honest:
108
+
109
+ - Markup only. No `<style>`, no CSS rule, no `<html>` or `<body>`; the command refuses a fragment that carries one. Every visual decision comes from `packages/ui`, which is what stops a preview from becoming a second source of truth.
110
+ - One `<section class="ui-view" data-state="...">` per state: `populated`, `loading`, `empty`, `error`, `denied`. The command labels each one, so a single page answers for all five.
111
+ - Real content. The longest realistic name, a real identifier, the copy the screen will actually carry. A preview of `Lorem ipsum` proves nothing about overflow or alignment.
112
+ - Only classes a stylesheet declares. `pnpm ui-classes:check` fails on a class nothing defines, in a preview and in a `.tsrx` alike, which is the one mistake that compiles, passes its tests and renders unstyled. A class a module needs and the design system lacks is declared in the module's own CSS, as rule 4 says.
113
+ - `--shot` writes a PNG, which is how an agent with no browser looks at its own work. `--open` opens the preview in the person's browser, which is how a design is shown for approval; a correction loop renders without it rather than throwing a window at whoever is at the keyboard.
114
+
115
+ Hand off the path, not a description. An implementation phase opens the preview, mirrors its structure with the components in section 4, and keeps the copy.
116
+
117
+ ## 8. Inspect the rendered screen
97
118
 
98
119
  A screen is not finished until it has been looked at. Typecheck and tests say nothing about overflow, alignment, a duplicate label or a column that collapses.
99
120
 
@@ -117,6 +138,7 @@ Check the drawer form in the same pass: one label per field, fields top-aligned,
117
138
 
118
139
  ## Pitfalls
119
140
 
141
+ - A preview that renders unstyled is a class nothing declares, not a broken harness; run `pnpm ui-classes:check`.
120
142
  - `Kpi value={items.length}` does not typecheck; use `String(items.length)`.
121
143
  - A `Tag` for a lifecycle state uses `success` for active and `neutral` for archived, with `dot`.
122
144
  - An `Icon` inside `Button size="sm"` is 14, not 18.
@@ -33,6 +33,9 @@ flowdular module version <id> # version, platformApi range,
33
33
  flowdular module version bump <id> <level> [--apply] # patch|minor|major across module.json, package.json, specVersion and dependent ranges
34
34
  flowdular module new <id> --spec <path> [--apply] # scaffold from an approved spec
35
35
  flowdular module enable|disable <id> [--apply] # composition and scope grants
36
+ flowdular web list # the addresses this workspace serves module pages at
37
+ flowdular web mount <module id> <surface id> --path <path> --tenant <id> [--id <mount id>] [--apply]
38
+ flowdular web unmount <mount id> [--apply] # stop serving that site
36
39
  flowdular migration status [--module <id>] # migration ledger
37
40
  flowdular migration apply --module <id> [--apply]
38
41
  flowdular migration verify # checksum drift, row security, file and constant parity
@@ -583,6 +583,23 @@ deployment on the old names keeps working and the server logs one
583
583
  replacement. The platform name wins when both are set, and every refusal names
584
584
  the variable the deployment actually set.
585
585
 
586
+ The relay is also five platform-scoped `auth.core` settings, edited under
587
+ Administration, Modules: `mailTransport` (`environment`, `none` or `smtp`),
588
+ `mailSmtpUrl` (secret, write only), `mailFrom`, `mailRequireTls` and
589
+ `mailRejectUnauthorized`. `mailTransport` decides which source wins. It is
590
+ `environment` by default, and while it stays there the `FD_MAIL_*`
591
+ configuration above is in effect exactly as described, deprecation warnings
592
+ included; storing `none` or `smtp` overrides the environment for every sender of
593
+ the installation. `development` is reachable only through the environment and is
594
+ never settable from the UI. A stored relay is resolved when a message is sent,
595
+ so a change carries the next message without a restart, and the built transport
596
+ is cached by a digest of the effective configuration. Storing `smtp` without a
597
+ relay URL or without a sender is refused, as is a URL that is not `smtp://` or
598
+ `smtps://` and a sender the sender rule rejects; the refusal names the setting
599
+ and never carries its value. Administration, Settings states which source and
600
+ which transport are in effect and sends a test message to the signed-in
601
+ address.
602
+
586
603
  `none` refuses every message with `MAIL_NOT_CONFIGURED` and sends nothing.
587
604
  `development` keeps the last 100 messages in memory for a local run and a test
588
605
  and is refused at boot in production, where it would be silent data loss.
@@ -17,6 +17,12 @@ shared primitives, tokens, and the rules for using them.
17
17
  - Modules: compose screens from `@flowdular/sdk/ui`. Module CSS may only add
18
18
  module-specific composites built on the tokens (example:
19
19
  `modules/agents/src/client/agents.css`).
20
+ - `modules/<dir>/design/*.html`: a screen as markup, rendered with the
21
+ stylesheets above by `pnpm ui:preview <path>` (`--scaffold` writes the record
22
+ recipe in all five states, `--shot <file.png>` captures it). It is where a
23
+ screen is designed and agreed before it is a component, and it carries no CSS
24
+ of its own, so it cannot drift from the platform. `pnpm ui-classes:check`
25
+ refuses a class no stylesheet declares, there and in every `.tsrx`.
20
26
  - `platform/public`: `favicon.svg`, `og.png` (1200x630 Open Graph image).
21
27
  - Brand mark geometry is generated: `node packages/ui/scripts/gen-mark.mjs`
22
28
  rewrites `packages/ui/src/brand/mark.ts` from the weave parameters.
@@ -407,7 +413,8 @@ Icon names (`packages/ui/src/icons/Icon.tsrx`): `dashboard`, `parties`, `catalog
407
413
  - Drawer: `ui-drawer__form` (scrolling body plus pinned footer),
408
414
  `ui-drawer__body`, `ui-drawer__foot`
409
415
  - Settings rows inside a `ui-card` or a drawer `ui-form__section`, rendered
410
- by `SettingRow`: `ui-setting`
416
+ by `SettingRow`: `ui-setting`, grouped in a card by `ui-setting-group` with
417
+ `ui-setting-group__head` (the group's name on the rows' own inset)
411
418
  (+`__text` for title, scope tag, and help, `__control` for the one-line
412
419
  control cluster, `__status` (+`--error`) for the inline result)
413
420
  - Buttons: `ui-btn` with `--primary`, `--secondary`, `--ghost`, `--danger`,
@@ -63,7 +63,27 @@ runtime and the module's existing bundles when localization is needed.
63
63
 
64
64
  ## Operator configuration
65
65
 
66
- Add the optional `web` section in the installation's `flowdular.json`:
66
+ One command writes the mount and regenerates the composition:
67
+
68
+ ```bash
69
+ pnpm flowdular web mount example.core public --path /blog --tenant <tenant id> # preview
70
+ pnpm flowdular web mount example.core public --path /blog --tenant <tenant id> --apply
71
+ pnpm flowdular web list
72
+ pnpm flowdular web unmount acme-public --apply
73
+ ```
74
+
75
+ It refuses before it writes what the platform would refuse at boot: a module
76
+ this workspace has not enabled, a reserved address, the configured backoffice
77
+ path, an address overlapping a site already mounted, and a mount naming no
78
+ workspace. The mount id defaults to the module's first segment; `--id` names it
79
+ when one module serves several addresses, and mounting the same id again moves
80
+ that site rather than adding a second one.
81
+
82
+ The tenant is the workspace whose pages are served, so it exists before the
83
+ mount does: run `flowdular setup` first and use the id it reports.
84
+
85
+ The same section can be written by hand in the installation's
86
+ `flowdular.json`:
67
87
 
68
88
  ```json
69
89
  {
@@ -82,7 +102,8 @@ Add the optional `web` section in the installation's `flowdular.json`:
82
102
  }
83
103
  ```
84
104
 
85
- Run `pnpm flowdular module sync --apply`, then rebuild/redeploy production or
105
+ A hand-written section needs `pnpm flowdular module sync --apply` afterwards;
106
+ the `web` commands run it themselves. Then rebuild or redeploy production, or
86
107
  restart development. Configuration is generated into the server composition;
87
108
  changing it requires regeneration. It is not read from a process-local settings
88
109
  cache or an anonymous query parameter. Verify the tenant ID before publishing.
@@ -91,7 +112,14 @@ paths. `/records/:slug` above becomes `/blog/records/:slug`.
91
112
 
92
113
  The root `/` can host a public storefront, alongside more specific mounts such as
93
114
  `/blog`. Reserved platform prefixes such as `/app`, `/auth`, `/api`, `/setup` and
94
- the configured backoffice path cannot be claimed by modules. Other overlapping
115
+ the configured backoffice path cannot be claimed by modules; the list is
116
+ `RESERVED_WEB_SEGMENTS` in `@flowdular/sdk/contracts`, which the composition and the
117
+ CLI both read. A segment may carry dots inside it, so a page answers at
118
+ `/rss.xml`, `/sitemap.xml` or `/robots.txt` as a reader or a crawler expects;
119
+ each dot separates two non-empty groups, which keeps `..`, a leading dot and a
120
+ trailing dot out of every address. A mount naming a surface its module does not declare stops the
121
+ composition with that sentence, rather than answering 404 at the address for the
122
+ life of the deployment. Other overlapping
95
123
  mounts and equivalent route patterns are rejected. Custom paths such as `/blog`, `/portal` and `/forms/contact` work without a
96
124
  tenant ID in the URL. `/sites/` is an optional convention for multiple sites;
97
125
  unknown sites under that prefix return 404. A disabled binding
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-flowdular",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "description": "Scaffold a Flowdular application: the platform, one example module and the secrets a fresh install needs.",
6
6
  "license": "MIT",
@@ -14,7 +14,7 @@
14
14
  "dependencies": [
15
15
  {
16
16
  "id": "auth.core",
17
- "range": "^0.13.0"
17
+ "range": "^0.14.0"
18
18
  }
19
19
  ],
20
20
  "tenancy": "required",
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "octane": "0.1.51",
18
18
  "segment-state": "0.2.1",
19
- "@flowdular/sdk": "0.4.0"
19
+ "@flowdular/sdk": "0.4.3"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@tsrx/typescript-plugin": "0.3.120",
@@ -12,7 +12,7 @@ capabilities:
12
12
  - translations
13
13
  dependencies:
14
14
  - id: auth.core
15
- range: ^0.13.0
15
+ range: ^0.14.0
16
16
  tenancy: required
17
17
  locales:
18
18
  - en
@@ -25,7 +25,7 @@
25
25
  "devDependencies": {
26
26
  "@tsrx/prettier-plugin": "0.3.120",
27
27
  "prettier": "3.6.2",
28
- "flowdular": "0.4.0",
28
+ "flowdular": "0.4.3",
29
29
  "rulesync": "16.21.0"
30
30
  }
31
31
  }
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  assertRouteConflicts,
3
+ createCorsMiddleware,
3
4
  createModuleMetrics,
4
5
  createModuleWebRoutes,
5
6
  createApplicationRoutes,
7
+ createOpenApiRoutes,
6
8
  serverTracer,
7
9
  validateApplicationPath,
8
10
  createMailPort,
@@ -17,6 +19,7 @@ import { defineConfig, RenderRoute } from '@octanejs/vite-plugin';
17
19
  import {
18
20
  authRuntimeOptionsFromEnvironment,
19
21
  createAuthRoutes,
22
+ endpointIdentityFromContext,
20
23
  isTokenPrincipal,
21
24
  mfaEnrolmentSatisfied,
22
25
  principalFromContext,
@@ -123,7 +126,10 @@ const moduleCompositions = composeModuleServer({
123
126
  dataClasses,
124
127
  databases,
125
128
  storage,
126
- mail,
129
+ /* The relay auth.core resolves per message from its stored settings, falling
130
+ back to the environment port above, so every module of the installation
131
+ sends through the same one. */
132
+ mail: authRuntime.mail,
127
133
  /* Rebound to the composing module by the generated composition; this binding
128
134
  is what a series the platform itself records would carry. */
129
135
  metrics: createModuleMetrics('platform'),
@@ -172,7 +178,15 @@ if (building) {
172
178
  }
173
179
 
174
180
  export default defineConfig({
175
- middlewares: [authRuntime.middleware],
181
+ middlewares: [
182
+ /* Ahead of the authentication: a cross-origin preflight carries no
183
+ credential and has to be answered before anything asks for one. The
184
+ origins come from the API tokens this workspace issued. */
185
+ createCorsMiddleware({
186
+ allowOrigin: (origin) => authRuntime.apiOriginAllowed(origin),
187
+ }),
188
+ authRuntime.middleware,
189
+ ],
176
190
  router: {
177
191
  routes: checkedRoutes([
178
192
  ...createApplicationRoutes({
@@ -183,6 +197,12 @@ export default defineConfig({
183
197
  healthEndpoint.serverRoute,
184
198
  createReadinessEndpoint(databases).serverRoute,
185
199
  ...createMetricsRoutes({ environment: process.env }),
200
+ /* Describes every operation the presented credential may call, built
201
+ from the endpoints this application composed. */
202
+ ...createOpenApiRoutes({
203
+ resolveIdentity: endpointIdentityFromContext,
204
+ publicBaseUrl: authRuntime.publicBaseUrl,
205
+ }),
186
206
  ...createStorageRoutes({
187
207
  storage,
188
208
  keyring: storageKeyring,
@@ -194,6 +214,10 @@ export default defineConfig({
194
214
  modules: moduleCompositions,
195
215
  mounts: moduleWebMounts,
196
216
  applicationPath: configuredApplicationPath,
217
+ /* A build composes with NODE_ENV forced to development, so the flag
218
+ asks for both: only a development server serves a page whose
219
+ stylesheets arrive after its markup. */
220
+ development: !building && process.env.NODE_ENV !== 'production',
197
221
  resolveIdentity: async (context) => {
198
222
  const principal = principalFromContext(context);
199
223
  if (!principal) return null;
@@ -15,7 +15,7 @@
15
15
  "@octanejs/vite-plugin": "0.1.51",
16
16
  "octane": "0.1.51",
17
17
  "pg": "8.23.0",
18
- "@flowdular/sdk": "0.4.0"
18
+ "@flowdular/sdk": "0.4.3"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@octanejs/app-core": "0.0.47",
@@ -10,6 +10,14 @@ import {
10
10
  } from '@flowdular/sdk/dev-console';
11
11
 
12
12
  const appRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
13
+ /* `pnpm dev -- --port 4396 --host 0.0.0.0` overrides vite.config.ts, so a
14
+ second application runs beside the first one. */
15
+ const flag = (name) => {
16
+ const index = process.argv.indexOf(name);
17
+ return index === -1 ? undefined : process.argv[index + 1];
18
+ };
19
+ const port = Number(flag('--port'));
20
+ const host = flag('--host');
13
21
  const verbose =
14
22
  process.argv.includes('--verbose') ||
15
23
  process.argv.includes('-v') ||
@@ -24,6 +32,11 @@ try {
24
32
  configFile: resolve(appRoot, 'vite.config.ts'),
25
33
  customLogger: createOctaneLogger(verbose, color),
26
34
  clearScreen: false,
35
+ ...(Number.isInteger(port) && port > 0
36
+ ? { server: { port, strictPort: true, ...(host ? { host } : {}) } }
37
+ : host
38
+ ? { server: { host } }
39
+ : {}),
27
40
  });
28
41
  await server.listen();
29
42
  } catch (error) {
@@ -37,7 +50,9 @@ printReady({
37
50
  lines: [
38
51
  [
39
52
  'local',
40
- server.resolvedUrls?.local?.[0] ?? 'http://localhost:4310/',
53
+ server.resolvedUrls?.local?.[0] ??
54
+ server.resolvedUrls?.network?.[0] ??
55
+ 'the address vite.config.ts sets',
41
56
  'info',
42
57
  ],
43
58
  ['diagnostics', verbose ? 'verbose' : 'quiet · use --verbose', 'muted'],