@nan0web/ui 1.6.2 → 1.7.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 (43) hide show
  1. package/package.json +89 -85
  2. package/src/core/GeneratorRunner.js +213 -0
  3. package/src/core/Intent.js +168 -0
  4. package/src/core/IntentErrorModel.js +94 -0
  5. package/src/core/MaskHandler.js +125 -0
  6. package/src/core/index.js +7 -0
  7. package/src/domain/SandboxModel.js +193 -0
  8. package/src/domain/ShowcaseAppModel.js +88 -0
  9. package/src/domain/components/AutocompleteModel.js +58 -0
  10. package/src/domain/components/BreadcrumbModel.js +265 -0
  11. package/src/domain/components/ButtonModel.js +92 -0
  12. package/src/domain/components/ConfirmModel.js +64 -0
  13. package/src/domain/components/InputModel.js +142 -0
  14. package/src/domain/components/SelectModel.js +59 -0
  15. package/src/domain/components/SpinnerModel.js +58 -0
  16. package/src/domain/components/TableModel.js +60 -0
  17. package/src/domain/components/ToastModel.js +77 -0
  18. package/src/domain/components/TreeModel.js +53 -0
  19. package/src/domain/components/index.js +11 -0
  20. package/src/domain/index.js +16 -0
  21. package/src/format.js +21 -0
  22. package/src/index.js +6 -0
  23. package/types/core/GeneratorRunner.d.ts +51 -0
  24. package/types/core/Intent.d.ts +227 -85
  25. package/types/core/IntentErrorModel.d.ts +55 -0
  26. package/types/core/MaskHandler.d.ts +33 -0
  27. package/types/core/index.d.ts +4 -0
  28. package/types/domain/SandboxModel.d.ts +59 -0
  29. package/types/domain/ShowcaseAppModel.d.ts +62 -0
  30. package/types/domain/components/AutocompleteModel.d.ts +47 -0
  31. package/types/domain/components/BreadcrumbModel.d.ts +164 -0
  32. package/types/domain/components/ButtonModel.d.ts +81 -0
  33. package/types/domain/components/ConfirmModel.d.ts +54 -0
  34. package/types/domain/components/InputModel.d.ts +121 -0
  35. package/types/domain/components/SelectModel.d.ts +48 -0
  36. package/types/domain/components/SpinnerModel.d.ts +45 -0
  37. package/types/domain/components/TableModel.d.ts +44 -0
  38. package/types/domain/components/ToastModel.d.ts +62 -0
  39. package/types/domain/components/TreeModel.d.ts +49 -0
  40. package/types/domain/components/index.d.ts +10 -0
  41. package/types/domain/index.d.ts +3 -0
  42. package/types/format.d.ts +5 -0
  43. package/types/index.d.ts +4 -0
package/package.json CHANGED
@@ -1,86 +1,90 @@
1
1
  {
2
- "name": "@nan0web/ui",
3
- "version": "1.6.2",
4
- "description": "NaN•Web UI. One application logic (algorithm) and many UI.",
5
- "main": "src/index.js",
6
- "types": "types/index.d.ts",
7
- "type": "module",
8
- "files": [
9
- "src/**/*.js",
10
- "!src/**/*.test.js",
11
- "types/**/*.d.ts"
12
- ],
13
- "scripts": {
14
- "build": "tsc",
15
- "play": "node play/main.js",
16
- "test": "node --test --test-timeout=3333 \"src/**/*.test.js\"",
17
- "test:nan0test": "node --test --test-timeout=3333 \"src/**/*.test.js\" | nan0test parse --fail",
18
- "test:coverage": "node --experimental-test-coverage --test-coverage-include=\"src/**/*.js\" --test-coverage-exclude=\"src/**/*.test.js\" --test \"src/**/*.test.js\"",
19
- "test:coverage:collect": "nan0test coverage",
20
- "test:docs": "node --test src/README.md.js",
21
- "release:spec": "node --test \"releases/**/*.spec.js\"",
22
- "test:release": "node --test \"releases/**/*.test.js\"",
23
- "test:status": "nan0test status --hide-name",
24
- "test:play": "node --test --test-timeout=3333 \"play/**/*.test.js\"",
25
- "test:e2e": "playwright test --ignore-snapshots e2e/components.spec.js e2e/debug-label.spec.js",
26
- "test:e2e:slow": "E2E_SLOW=1 playwright test e2e/visual.spec.js",
27
- "test:all": "npm run test && npm run test:docs && npm run test:play && npm run test:e2e && npm run build && npm run knip",
28
- "knip": "knip --production",
29
- "precommit": "npm test",
30
- "prepush": "npm test",
31
- "prepare": "husky",
32
- "release": "nan0release publish",
33
- "clean": "rm -rf .cache/ && rm -rf dist/",
34
- "clean:modules": "rm -rf node_modules",
35
- "docs:dev": "node docs/site/scripts/generate-pages.js && vite docs/site",
36
- "docs:build": "node docs/site/scripts/generate-pages.js && vite build docs/site"
37
- },
38
- "exports": {
39
- ".": {
40
- "import": "./src/index.js",
41
- "types": "./types/index.d.ts"
42
- },
43
- "./components": {
44
- "import": "./src/Component/index.js",
45
- "types": "./types/Component/index.d.ts"
46
- },
47
- "./core": {
48
- "import": "./src/core/index.js",
49
- "types": "./types/core/index.d.ts"
50
- },
51
- "./cli-app": "./apps/cli/src/index.js",
52
- "./mobile-app": "./apps/mobile/src/index.js",
53
- "./web-app": "./apps/web/src/App.jsx"
54
- },
55
- "keywords": [
56
- "ui",
57
- "nanoweb"
58
- ],
59
- "author": "YaRaSLove (ЯRаСлав) <support@yaro.page>",
60
- "license": "ISC",
61
- "packageManager": "pnpm@10.11.0",
62
- "devDependencies": {
63
- "@nan0web/event": "*",
64
- "@nan0web/i18n": "*",
65
- "@nan0web/release": "*",
66
- "@nan0web/test": "*",
67
- "@nan0web/ui-cli": "*",
68
- "@nan0web/ui-lit": "workspace:*",
69
- "@playwright/test": "^1.58.2",
70
- "@rollup/plugin-yaml": "^4.1.2",
71
- "@vitest/coverage-v8": "^3.2.4",
72
- "husky": "^9.1.7",
73
- "js-yaml": "^4.1.1",
74
- "knip": "^5.83.1",
75
- "lit": "^3.3.2",
76
- "vite": "^6.0.0",
77
- "vitest": "^3.2.4"
78
- },
79
- "dependencies": {
80
- "@nan0web/co": "*",
81
- "@nan0web/event": "*",
82
- "@nan0web/log": "*",
83
- "@nan0web/types": "*",
84
- "string-width": "^7.2.0"
85
- }
86
- }
2
+ "name": "@nan0web/ui",
3
+ "version": "1.7.0",
4
+ "description": "NaN•Web UI. One application logic (algorithm) and many UI.",
5
+ "main": "src/index.js",
6
+ "types": "types/index.d.ts",
7
+ "type": "module",
8
+ "files": [
9
+ "src/**/*.js",
10
+ "!src/**/*.test.js",
11
+ "types/**/*.d.ts"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "import": "./src/index.js",
16
+ "types": "./types/index.d.ts"
17
+ },
18
+ "./components": {
19
+ "import": "./src/Component/index.js",
20
+ "types": "./types/Component/index.d.ts"
21
+ },
22
+ "./core": {
23
+ "import": "./src/core/index.js",
24
+ "types": "./types/core/index.d.ts"
25
+ },
26
+ "./domain": {
27
+ "import": "./src/domain/index.js"
28
+ },
29
+ "./cli-app": "./apps/cli/src/index.js",
30
+ "./mobile-app": "./apps/mobile/src/index.js",
31
+ "./web-app": "./apps/web/src/App.jsx"
32
+ },
33
+ "keywords": [
34
+ "ui",
35
+ "nanoweb"
36
+ ],
37
+ "author": "YaRaSLove (ЯRаСлав) <support@yaro.page>",
38
+ "license": "ISC",
39
+ "devDependencies": {
40
+ "@nan0web/event": "*",
41
+ "@nan0web/i18n": "^1.1.0",
42
+ "@nan0web/release": "^1.0.2",
43
+ "@nan0web/test": "^1.1.3",
44
+ "@nan0web/ui-cli": "^2.3.0",
45
+ "@playwright/test": "^1.58.2",
46
+ "@rollup/plugin-yaml": "^4.1.2",
47
+ "@vitest/coverage-v8": "^3.2.4",
48
+ "husky": "^9.1.7",
49
+ "js-yaml": "^4.1.1",
50
+ "knip": "^5.86.0",
51
+ "lit": "^3.3.2",
52
+ "vite": "^6.4.1",
53
+ "vitest": "^3.2.4",
54
+ "@nan0web/ui-lit": "1.1.0"
55
+ },
56
+ "dependencies": {
57
+ "@nan0web/co": "^2.0.0",
58
+ "@nan0web/event": "^1.0.1",
59
+ "@nan0web/log": "^1.1.1",
60
+ "@nan0web/types": "^1.4.0",
61
+ "string-width": "^7.2.0"
62
+ },
63
+ "scripts": {
64
+ "build": "tsc",
65
+ "play": "node play/main.js",
66
+ "test:unit": "node --test --test-timeout=3333 \"src/**/*.test.js\"",
67
+ "test": "npm run test:unit && npm run test:ssg && npm run build",
68
+ "test:nan0test": "node --test --test-timeout=3333 \"src/**/*.test.js\" | nan0test parse --fail",
69
+ "test:coverage": "node --experimental-test-coverage --test-coverage-include=\"src/**/*.js\" --test-coverage-exclude=\"src/**/*.test.js\" --test \"src/**/*.test.js\"",
70
+ "test:coverage:collect": "nan0test coverage",
71
+ "test:docs": "node --test src/README.md.js",
72
+ "release:spec": "node --test \"releases/**/*.spec.js\"",
73
+ "test:release": "node --test \"releases/**/*.test.js\"",
74
+ "test:status": "nan0test status --hide-name",
75
+ "test:play": "node --test --test-timeout=3333 \"play/**/*.test.js\"",
76
+ "test:ssg": "node --test --test-timeout=5000 \"ssg/ssg.test.js\"",
77
+ "test:e2e": "playwright test --ignore-snapshots e2e/components.spec.js e2e/debug-label.spec.js",
78
+ "test:e2e:slow": "E2E_SLOW=1 playwright test e2e/visual.spec.js",
79
+ "test:all": "npm run test && npm run test:docs && npm run test:play && npm run test:e2e && npm run knip",
80
+ "knip": "knip --production",
81
+ "precommit": "npm test",
82
+ "prepush": "npm test",
83
+ "release": "nan0release publish",
84
+ "clean": "rm -rf .cache/ && rm -rf dist/",
85
+ "clean:modules": "rm -rf node_modules",
86
+ "docs:build-data": "node docs/site/scripts/generate-data.js",
87
+ "docs:dev": "node docs/site/scripts/generate-data.js && node docs/site/scripts/generate-pages.js && vite docs/site",
88
+ "docs:build": "node docs/site/scripts/generate-data.js && node docs/site/scripts/generate-pages.js && vite build docs/site"
89
+ }
90
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @file GeneratorRunner — Universal Adapter Loop with Timeout, Abort, and Contract Enforcement.
3
+ *
4
+ * This is the core engine that runs any OLMUI async generator (Model.run())
5
+ * through a set of adapter handlers. It enforces:
6
+ *
7
+ * 1. Intent validation (the "Judge" — Ярослав Мудрий).
8
+ * 2. Timeout / Abort support (Іван Сірко).
9
+ * 3. Type-safe handler dispatch (Борис Патон).
10
+ *
11
+ * Every UI Adapter (CLI, Lit, Chat, Test) uses this runner
12
+ * instead of writing its own while(true) loop.
13
+ */
14
+
15
+ import { validateIntent } from './Intent.js'
16
+ import { IntentErrorModel } from './IntentErrorModel.js'
17
+
18
+ /**
19
+ * @typedef {Object} AdapterHandlers
20
+ * @property {(intent: import('./Intent.js').AskIntent) => Promise<import('./Intent.js').AskResponse>} ask
21
+ * Handler for 'ask' intents. Must return { value: ... }.
22
+ * @property {(intent: import('./Intent.js').ProgressIntent) => void | Promise<void>} [progress]
23
+ * Handler for 'progress' intents. Optional (defaults to no-op).
24
+ * @property {(intent: import('./Intent.js').LogIntent) => void | Promise<void>} [log]
25
+ * Handler for 'log' intents. Optional (defaults to no-op).
26
+ * @property {(intent: import('./Intent.js').ResultIntent) => void | Promise<void>} [result]
27
+ * Handler for the final 'result'. Optional (defaults to no-op).
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} RunnerOptions
32
+ * @property {number} [timeoutMs=0]
33
+ * Maximum milliseconds to wait for an adapter handler to respond.
34
+ * Default is 0 (disabled) — web forms may wait indefinitely.
35
+ * Set to a positive value for CLI/Chat adapters where hanging is unacceptable.
36
+ * @property {AbortSignal} [signal]
37
+ * External AbortSignal for cancellation from outside.
38
+ */
39
+
40
+ /**
41
+ * Wraps a promise with a timeout. Rejects if the promise doesn't resolve in time.
42
+ * If ms <= 0, timeout is disabled (promise runs without time limit).
43
+ *
44
+ * @template T
45
+ * @param {Promise<T>} promise
46
+ * @param {number} ms
47
+ * @param {string} label - Description for the timeout error.
48
+ * @returns {Promise<T>}
49
+ */
50
+ function withTimeout(promise, ms, label) {
51
+ if (!ms || ms <= 0) return promise
52
+
53
+ return new Promise((resolve, reject) => {
54
+ const timer = setTimeout(() => {
55
+ const error = IntentErrorModel.error('timeout', { label, ms })
56
+ reject(error)
57
+ }, ms)
58
+
59
+ promise.then(
60
+ (val) => {
61
+ clearTimeout(timer)
62
+ resolve(val)
63
+ },
64
+ (err) => {
65
+ clearTimeout(timer)
66
+ reject(err)
67
+ },
68
+ )
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Runs an OLMUI async generator through the provided adapter handlers.
74
+ *
75
+ * This function is the SINGLE point of execution for all adapters.
76
+ * It guarantees:
77
+ * - Every yielded intent is validated (contract enforcement).
78
+ * - Every 'ask' intent gets a response or times out (if timeoutMs > 0).
79
+ * - External abort signals are respected.
80
+ * - The final result is returned.
81
+ *
82
+ * @template T
83
+ * @param {AsyncGenerator<import('./Intent.js').Intent, import('./Intent.js').ResultIntent, import('./Intent.js').IntentResponse>} generator
84
+ * The model's async generator (from Model.run()).
85
+ * @param {AdapterHandlers} handlers
86
+ * Platform-specific handlers for each intent type.
87
+ * @param {RunnerOptions} [options={}]
88
+ * Runner configuration (timeout, abort signal).
89
+ * @returns {Promise<T>}
90
+ * The final result data from the generator.
91
+ */
92
+ export async function runGenerator(generator, handlers, options = {}) {
93
+ const { timeoutMs = 0, signal } = options
94
+
95
+ // Validate that at least 'ask' handler is provided (Ярослав Мудрий's law)
96
+ if (!handlers || typeof handlers.ask !== 'function') {
97
+ throw IntentErrorModel.error('adapter_missing_ask')
98
+ }
99
+
100
+ /** @type {import('./Intent.js').IntentResponse | Error | undefined} */
101
+ let nextVal = undefined
102
+
103
+ while (true) {
104
+ // ─── Check external abort signal ───
105
+ if (signal?.aborted) {
106
+ await generator.return({ type: 'result', data: null })
107
+ const error = IntentErrorModel.error('aborted')
108
+ error.name = 'AbortError'
109
+ throw error
110
+ }
111
+
112
+ let nextEvent
113
+ try {
114
+ if (nextVal instanceof Error) {
115
+ nextEvent = await generator.throw(nextVal)
116
+ } else {
117
+ nextEvent = await generator.next(nextVal)
118
+ }
119
+ } catch (e) {
120
+ // If the generator didn't catch the CancelError, we gracefully abort the runner.
121
+ const err = /** @type {Error} */ (e)
122
+ if (err.name === 'CancelError') {
123
+ return /** @type {T} */ (null)
124
+ }
125
+ throw e // Bubble up unexpected runtime errors
126
+ }
127
+
128
+ const { value: intent, done } = nextEvent
129
+
130
+ // ─── Generator completed (return statement) ───
131
+ if (done) {
132
+ if (handlers.result) {
133
+ await handlers.result(intent)
134
+ }
135
+ return intent?.data
136
+ }
137
+
138
+ // ─── Validate intent structure (the Judge) ───
139
+ validateIntent(intent)
140
+
141
+ // ─── Dispatch to adapter handler ───
142
+ switch (intent.type) {
143
+ case 'ask': {
144
+ const response = await withTimeout(
145
+ handlers.ask(intent),
146
+ timeoutMs,
147
+ `ask("${intent.field}")`,
148
+ )
149
+
150
+ // Validate response shape (Борис Патон's weld check)
151
+ if (!response || typeof response !== 'object' || !('value' in response)) {
152
+ throw IntentErrorModel.error('ask_wrong_response', {
153
+ field: intent.field,
154
+ actual: JSON.stringify(response),
155
+ })
156
+ }
157
+
158
+ // ─── Handle cancellation (ESC / back navigation) ───
159
+ if (response.cancelled) {
160
+ // Prepare CancelError to be thrown INTO the generator on the next loop iteration.
161
+ // This allows the Model to elegantly intercept ESC using try...catch!
162
+ const err = new Error('User cancelled interaction')
163
+ err.name = 'CancelError'
164
+ nextVal = err
165
+ break
166
+ }
167
+
168
+ // Run field validation if schema has a validator (the Judge again)
169
+ if (!intent.model) {
170
+ /** @type {import('./Intent.js').FieldSchema} */
171
+ const fieldSchema = /** @type {import('./Intent.js').FieldSchema} */ (intent.schema)
172
+ if (fieldSchema.validate) {
173
+ const validationResult = fieldSchema.validate(response.value)
174
+ if (validationResult !== true) {
175
+ throw IntentErrorModel.error('validation_failed', {
176
+ field: intent.field,
177
+ reason: validationResult,
178
+ })
179
+ }
180
+ }
181
+ }
182
+
183
+ // Instantiate Model-as-Schema class with collected data
184
+ if (intent.model && typeof intent.schema === 'function') {
185
+ const ModelClass = /** @type {new (data: any) => any} */ (intent.schema)
186
+ response.value = new ModelClass(response.value)
187
+ }
188
+
189
+ nextVal = response
190
+ break
191
+ }
192
+
193
+ case 'progress': {
194
+ if (handlers.progress) {
195
+ await handlers.progress(intent)
196
+ }
197
+ nextVal = undefined
198
+ break
199
+ }
200
+
201
+ case 'log': {
202
+ if (handlers.log) {
203
+ await handlers.log(intent)
204
+ }
205
+ nextVal = undefined
206
+ break
207
+ }
208
+
209
+ default:
210
+ throw IntentErrorModel.error('unhandled_intent', { type: /** @type {any} */ (intent).type })
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @file Intent — Yield Contract Types for OLMUI Generators.
3
+ *
4
+ * Defines the strict set of intent types that a Model generator can yield,
5
+ * and the response shapes that Adapters must return.
6
+ *
7
+ * These types serve as the "welding contract" between Domain Models
8
+ * and UI Adapters (CLI, Lit, React, Chat, Test).
9
+ */
10
+
11
+ import { IntentErrorModel } from './IntentErrorModel.js'
12
+
13
+ // ─── Intent Types (Model → Adapter) ───
14
+
15
+ /**
16
+ * @typedef {Object} FieldSchema
17
+ * @property {string} help - Human-readable label / i18n key.
18
+ * @property {*} default - Default value for the field.
19
+ * @property {string} [type] - Field type hint ('text', 'number', 'text/markdown').
20
+ * @property {Array<{value: *, label: string}>} [options] - Enum options for select.
21
+ * @property {(val: *) => true | string} [validate] - Validator: true = ok, string = error key from Model.
22
+ * @property {boolean} [hidden] - If true, field is excluded from UI forms.
23
+ */
24
+
25
+ /**
26
+ * Model needs data from the environment (user input, LLM extraction, test fixture).
27
+ * Adapter MUST return an `AskResponse`.
28
+ * @typedef {Object} AskIntent
29
+ * @property {'ask'} type
30
+ * @property {string} field - Property name on the model.
31
+ * @property {FieldSchema | Function} schema - Field metadata or Model-as-Schema class constructor.
32
+ * @property {true} [model] - When true, schema is a Model-as-Schema class (full form).
33
+ */
34
+
35
+ /**
36
+ * Model informs about a long-running operation. No response expected.
37
+ * Message MUST come from the Model (i18n static field value).
38
+ * @typedef {Object} ProgressIntent
39
+ * @property {'progress'} type
40
+ * @property {number} [value] - Progress value (0-1).
41
+ * @property {string} [id] - Progress ID for tracking multiple parallel operations.
42
+ * @property {string} message - Status message from Model (i18n static field value).
43
+ */
44
+
45
+ /**
46
+ * Model emits a log message. No response expected.
47
+ * Message MUST come from the Model (i18n static field value).
48
+ * @typedef {Object} LogIntent
49
+ * @property {'log'} type
50
+ * @property {'info' | 'warn' | 'error' | 'success'} level
51
+ * @property {string} message - Log message from Model (i18n static field value).
52
+ */
53
+
54
+ /**
55
+ * Final return value from the generator.
56
+ * @typedef {Object} ResultIntent
57
+ * @property {'result'} type
58
+ * @property {*} data - The raw result data (JSON-serializable).
59
+ */
60
+
61
+ /**
62
+ * Union of all possible yielded intents.
63
+ * @typedef {AskIntent | ProgressIntent | LogIntent} Intent
64
+ */
65
+
66
+ // ─── Response Types (Adapter → Model) ───
67
+
68
+ /**
69
+ * Response to an AskIntent. Adapter provides the collected value.
70
+ * The value MUST conform to the type described in the requested FieldSchema.
71
+ * @typedef {Object} AskResponse
72
+ * @property {*} value - The value matching schema.type (collected from user / LLM / test fixture).
73
+ * @property {boolean} [cancelled] - Whether the user cancelled this interaction (e.g. pressed ESC).
74
+ */
75
+
76
+ // ─── Abort Support ───
77
+
78
+ /**
79
+ * Special response that Adapters can send to abort the generator.
80
+ *
81
+ * The `reason` is a KEY from the Model's static `abort` dictionary,
82
+ * not a freeform message. This enables proper i18n translation:
83
+ *
84
+ * Model defines: static abort = { user_cancelled: 'Скасовано', timeout: 'Час вичерпано' }
85
+ * Adapter sends: { abort: true, reason: 'user_cancelled' }
86
+ * UI translates: t(Model.abort[reason])
87
+ *
88
+ * @typedef {Object} AbortResponse
89
+ * @property {true} abort - Signal to the model that execution was cancelled.
90
+ * @property {string} [reason] - Key from Model's static abort dictionary (not a freeform message).
91
+ */
92
+
93
+ /**
94
+ * Union of all possible responses an Adapter can send back via iterator.next().
95
+ * @typedef {AskResponse | AbortResponse | undefined} IntentResponse
96
+ */
97
+
98
+ export const INTENT_TYPES = /** @type {const} */ (['ask', 'progress', 'log'])
99
+
100
+ /**
101
+ * Detects if a value is a Model-as-Schema class (has static fields with `help`).
102
+ * @param {*} schema
103
+ * @returns {boolean}
104
+ */
105
+ export function isModelSchema(schema) {
106
+ if (typeof schema !== 'function') return false
107
+ return Object.keys(schema).some((key) => {
108
+ const meta = schema[key]
109
+ return meta && typeof meta === 'object' && 'help' in meta
110
+ })
111
+ }
112
+
113
+ /**
114
+ * Validates that an object is a well-formed Intent.
115
+ * Throws ModelError if the intent is malformed (the "Judge").
116
+ *
117
+ * @param {*} intent
118
+ * @returns {intent is Intent}
119
+ */
120
+ export function validateIntent(intent) {
121
+ if (!intent || typeof intent !== 'object') {
122
+ throw IntentErrorModel.error('intent_not_object', { actual: typeof intent })
123
+ }
124
+ if (!INTENT_TYPES.includes(intent.type) && intent.type !== 'result') {
125
+ throw IntentErrorModel.error('intent_unknown_type', {
126
+ type: intent.type,
127
+ allowed: INTENT_TYPES.join(', ') + ', result',
128
+ })
129
+ }
130
+ if (intent.type === 'ask') {
131
+ if (typeof intent.field !== 'string' || !intent.field) {
132
+ throw IntentErrorModel.error('ask_missing_field')
133
+ }
134
+ // Accept both: plain schema {help: '...'} and Model-as-Schema class
135
+ const isModel = intent.model === true
136
+ if (!isModel && (!intent.schema || typeof intent.schema !== 'object' || !('help' in intent.schema))) {
137
+ throw IntentErrorModel.error('ask_missing_schema_help')
138
+ }
139
+ }
140
+ if (intent.type === 'progress' || intent.type === 'log') {
141
+ if (typeof intent.message !== 'string') {
142
+ throw IntentErrorModel.error('intent_missing_message', { type: intent.type })
143
+ }
144
+ }
145
+ return true
146
+ }
147
+
148
+ /**
149
+ * Create an ask intent.
150
+ *
151
+ * Two modes:
152
+ * ask('amount', { help: 'Enter amount', type: 'number' }) → single field
153
+ * ask('transfer', TransferMoneyModel) → full Model form
154
+ *
155
+ * @param {string} field - Field name or form name.
156
+ * @param {object | Function} schema - Field descriptor or Model-as-Schema class.
157
+ * @returns {AskIntent}
158
+ */
159
+ export const ask = (field, schema) => {
160
+ if (isModelSchema(schema)) {
161
+ return { type: 'ask', field, schema, model: true }
162
+ }
163
+ return { type: 'ask', field, schema }
164
+ }
165
+
166
+ export const progress = (message) => ({ type: 'progress', message })
167
+ export const log = (level, message, data = {}) => ({ type: 'log', level, message, ...data })
168
+ export const result = (data) => ({ type: 'result', data })
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @file IntentErrorModel — Model-as-Schema for all OLMUI contract errors.
3
+ *
4
+ * All error messages in the Generator/Intent system are defined here
5
+ * as static fields with i18n-ready keys. No hardcoded strings in
6
+ * validators or runners.
7
+ *
8
+ * Pattern:
9
+ * static field_name = {
10
+ * help: 'Developer description (docs/IDE)',
11
+ * error: 'User-facing message template with {params}',
12
+ * }
13
+ */
14
+
15
+ import { ModelError } from '@nan0web/types'
16
+
17
+ export class IntentErrorModel {
18
+ // ─── Intent Validation Errors ───
19
+
20
+ static intent_not_object = {
21
+ help: 'Intent must be a valid object',
22
+ error: "Intent must be an object, got: '{actual}'",
23
+ }
24
+
25
+ static intent_unknown_type = {
26
+ help: 'Only ask, progress, log, result types are allowed',
27
+ error: "Unknown intent type: '{type}'. Allowed: {allowed}",
28
+ }
29
+
30
+ static ask_missing_field = {
31
+ help: 'AskIntent requires a field property name',
32
+ error: 'AskIntent requires a non-empty "field" string',
33
+ }
34
+
35
+ static ask_missing_schema_help = {
36
+ help: 'AskIntent schema must contain help for i18n',
37
+ error: 'AskIntent.schema must have at least a "help" property',
38
+ }
39
+
40
+ static intent_missing_message = {
41
+ help: 'Progress and Log intents require a message',
42
+ error: '\'{type}\' intent requires a "message" string',
43
+ }
44
+
45
+ // ─── Runner Contract Errors ───
46
+
47
+ static adapter_missing_ask = {
48
+ help: 'Every adapter must handle ask intents',
49
+ error:
50
+ 'Adapter MUST provide at least an "ask" handler. This is the minimum contract for any UI adapter.',
51
+ }
52
+
53
+ static ask_wrong_response = {
54
+ help: 'Ask handler must return { value: ... }',
55
+ error: "ask('{field}') handler must return { value: ... }, got: {actual}",
56
+ }
57
+
58
+ static validation_failed = {
59
+ help: 'Value returned by adapter failed schema validation',
60
+ error: "Field '{field}' failed validation: {reason}",
61
+ }
62
+
63
+ static unhandled_intent = {
64
+ help: 'Intent type not handled by the runner dispatch',
65
+ error: "Unhandled intent type: '{type}'",
66
+ }
67
+
68
+ // ─── Timeout / Abort ───
69
+
70
+ static timeout = {
71
+ help: 'Adapter exceeded the allowed response time',
72
+ error: '{label} — adapter did not respond within {ms}ms',
73
+ }
74
+
75
+ static aborted = {
76
+ help: 'External signal cancelled the generator',
77
+ error: 'Generator aborted by external signal',
78
+ }
79
+
80
+ /**
81
+ * Build a ModelError for a specific error field.
82
+ *
83
+ * @param {string} field - Static field name on IntentErrorModel.
84
+ * @param {Record<string, *>} [params] - Template parameters to substitute {key} placeholders.
85
+ * @returns {ModelError}
86
+ */
87
+ static error(field, params = {}) {
88
+ let message = IntentErrorModel[field]?.error || field
89
+ for (const [key, val] of Object.entries(params)) {
90
+ message = message.replaceAll(`{${key}}`, String(val))
91
+ }
92
+ return new ModelError({ [field]: message })
93
+ }
94
+ }