@christianirack/brainllm-runtime 0.0.0-stage → 0.1.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/brain-anim.js +21 -0
  4. package/brain-ch.js +254 -0
  5. package/brain-consent.js +38 -0
  6. package/brain-data.js +547 -0
  7. package/brain-dev.js +100 -0
  8. package/brain-env.js +160 -0
  9. package/brain-err.js +242 -0
  10. package/brain-form.js +260 -0
  11. package/brain-guard.js +242 -0
  12. package/brain-hydrate.js +72 -0
  13. package/brain-i18n.js +111 -0
  14. package/brain-inspect.js +745 -0
  15. package/brain-island.js +106 -0
  16. package/brain-lazy.js +88 -0
  17. package/brain-mcp.js +58 -0
  18. package/brain-mock.js +80 -0
  19. package/brain-overlay.js +123 -0
  20. package/brain-persist.js +65 -0
  21. package/brain-router.js +353 -0
  22. package/brain-rpc.js +17 -0
  23. package/brain-schema.js +69 -0
  24. package/brain-wc.js +69 -0
  25. package/brain-why.js +198 -0
  26. package/brain-worker.js +66 -0
  27. package/brain.js +421 -0
  28. package/package.json +20 -4
  29. package/server/server.mjs +882 -0
  30. package/shim/dom.js +448 -0
  31. package/shim/probe.js +111 -0
  32. package/sw/brain-sw.js +103 -0
  33. package/themes/contrast.cssbrain +39 -0
  34. package/themes/dark.cssbrain +39 -0
  35. package/themes/glass.cssbrain +39 -0
  36. package/themes/paper.cssbrain +39 -0
  37. package/ui/button.brain +31 -0
  38. package/ui/center.brain +11 -0
  39. package/ui/checkbox.brain +13 -0
  40. package/ui/container.brain +11 -0
  41. package/ui/dialog.brain +29 -0
  42. package/ui/error-state.brain +53 -0
  43. package/ui/field.brain +34 -0
  44. package/ui/grid.brain +11 -0
  45. package/ui/input.brain +23 -0
  46. package/ui/lang-switch.brain +38 -0
  47. package/ui/menu.brain +64 -0
  48. package/ui/row.brain +12 -0
  49. package/ui/select.brain +29 -0
  50. package/ui/sidebar.brain +17 -0
  51. package/ui/spacer.brain +12 -0
  52. package/ui/stack.brain +10 -0
  53. package/ui/table.brain +73 -0
  54. package/ui/tabs.brain +33 -0
  55. package/ui/toast.brain +45 -0
package/brain-env.js ADDED
@@ -0,0 +1,160 @@
1
+ // BRAINLLM lifecycle and environment helpers (INTERNALS §41, #169). Each one cleans up with the component that called
2
+ // it (the owner's effects are disposed on unmount), and none of them runs timers, observers or listeners while the
3
+ // server renders (server.mjs SSR or the build's prerender: globalThis.__brainSSR / __brainRoute are set), so a render
4
+ // never keeps Node alive. Values meant for markup (`media`, `visible`, `debounce`) are read-only nodes read with `.v`.
5
+ import { _rt, sig, fx, untrack } from './brain.js'
6
+
7
+ const G = globalThis, I = _rt()
8
+ const onServer = () => !!(G.__brainSSR || G.__brainRoute)
9
+ const ro = n => ({ get v() { return n.v } })
10
+ const ms = t => {
11
+ if (typeof t === 'number') return t
12
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(String(t).trim())
13
+ if (!m) throw Error(`brain-env: "${t}" is not a duration (300ms, 30s, 5m, 1h)`)
14
+ return m[1] * { ms: 1, s: 1e3, m: 6e4, h: 36e5 }[m[2]]
15
+ }
16
+ // runs `f` when the component unmounts (an fx with no reads: its cleanup is the owner's disposal)
17
+ const onDispose = f => fx(() => f)
18
+
19
+ // mounted(f): after the component's nodes are in the document. A returned function runs on unmount.
20
+ export function mounted(f) {
21
+ if (onServer()) return
22
+ let alive = true, c
23
+ queueMicrotask(() => alive && (c = untrack(f)))
24
+ onDispose(() => ((alive = false), typeof c === 'function' && c()))
25
+ }
26
+
27
+ // watch(() => src, (v, old) => …): runs the callback when the source changes, not on the first read; the callback is
28
+ // not tracked. A returned function is its cleanup (before the next run and on unmount).
29
+ export function watch(src, cb) {
30
+ let first = true, old
31
+ fx(() => {
32
+ const v = src()
33
+ if (first) return void ((first = false), (old = v))
34
+ const o = old
35
+ old = v
36
+ return untrack(() => cb(v, o))
37
+ })
38
+ }
39
+
40
+ // tick(): resolves once pending updates have reached the DOM
41
+ export const tick = () => new Promise(r => setTimeout(r, 0))
42
+
43
+ // every('30s', f): an interval, paused while the tab is hidden
44
+ export function every(t, f) {
45
+ if (onServer()) return
46
+ const n = ms(t), d = G.document
47
+ let id = 0
48
+ const sync = () => (clearInterval(id), (id = d && d.hidden ? 0 : setInterval(() => untrack(f), n)))
49
+ sync()
50
+ if (d && d.addEventListener) d.addEventListener('visibilitychange', sync)
51
+ onDispose(() => (clearInterval(id), d && d.removeEventListener && d.removeEventListener('visibilitychange', sync)))
52
+ }
53
+
54
+ // media('(max-width: 600px)') → .v true/false, following the viewport
55
+ export function media(q) {
56
+ const mq = !onServer() && G.matchMedia ? G.matchMedia(q) : null
57
+ const n = sig(!!(mq && mq.matches))
58
+ if (mq) {
59
+ const on = e => (n.v = e.matches)
60
+ mq.addEventListener('change', on)
61
+ onDispose(() => mq.removeEventListener('change', on))
62
+ }
63
+ return ro(n)
64
+ }
65
+
66
+ // visible(ref) → .v true while the element is in the viewport (IntersectionObserver); a ref may still be empty
67
+ export function visible(r, o) {
68
+ const n = sig(false)
69
+ if (onServer() || !G.IntersectionObserver) return ro(n)
70
+ fx(() => {
71
+ const el = r && r.nodeType ? r : r && r.v
72
+ if (!el) return
73
+ const io = new IntersectionObserver(es => (n.v = es[es.length - 1].isIntersecting), o)
74
+ io.observe(el)
75
+ return () => io.disconnect()
76
+ })
77
+ return ro(n)
78
+ }
79
+
80
+ // debounce(() => value, '300ms') → .v follows the value once it has been still for that long
81
+ export function debounce(f, t) {
82
+ const n = sig(untrack(f)), wait = ms(t)
83
+ let first = true
84
+ fx(() => {
85
+ const v = f()
86
+ if (first) return void (first = false)
87
+ if (onServer()) return void (n.v = v)
88
+ const id = setTimeout(() => (n.v = v), wait)
89
+ return () => clearTimeout(id)
90
+ })
91
+ return ro(n)
92
+ }
93
+
94
+ // <component is={C} …props> → dyn(parent, () => C, $p, anchor): mounts C before the anchor and remounts only when the
95
+ // component itself changes (a null C renders nothing). The mounted component belongs to the caller's owner.
96
+ export function dyn(p, c, $p, end) {
97
+ const own = I.owner()
98
+ let cur, nodes = [], d = null
99
+ const clear = () => (d && d(), (d = null), I.rm(nodes), (nodes = []))
100
+ fx(() => {
101
+ const C = c()
102
+ if (C === cur) return
103
+ cur = C
104
+ untrack(() => {
105
+ clear()
106
+ if (C) I.root(x => ((d = x), (nodes = I.nodes(C($p || {})))), own)
107
+ const par = (end && end.parentNode) || p
108
+ for (const n of nodes) par.insertBefore(n, end || null)
109
+ })
110
+ })
111
+ onDispose(clear)
112
+ }
113
+
114
+ // <Portal to="#layer"> → portal(target, () => children): renders the children into another element (a selector or an
115
+ // element; document.body by default) and removes them on unmount. Returns what to insert in place: nothing in the
116
+ // browser; while the server renders, the children themselves (they stay where they are written).
117
+ export function portal(target, f) {
118
+ if (onServer() || !G.document) return f()
119
+ const t = (typeof target === 'string' ? document.querySelector(target) : target) || document.body
120
+ let nodes = [], d = null
121
+ I.root(x => ((d = x), (nodes = I.nodes(f()))), I.owner())
122
+ for (const n of nodes) t.appendChild(n)
123
+ onDispose(() => (d && d(), I.rm(nodes)))
124
+ return []
125
+ }
126
+
127
+ // route() → { path, params, query }, read-only and reactive (each a signal read): path without the base path
128
+ // (<meta name="brain-base">), query from the URL, params from the page's <meta name="brain-params" content='{"id":"7"}'>
129
+ // (the server knows the route's pattern; the client does not). brain-router calls globalThis.__brainRouted after every
130
+ // client navigation. While the server renders, the values come from globalThis.__brainRoute (server.mjs, prerender).
131
+ let cur = null
132
+ const paramsOf = () => {
133
+ try {
134
+ const m = G.document && document.querySelector('meta[name=brain-params]')
135
+ return (m && JSON.parse(m.getAttribute('content'))) || {}
136
+ } catch {
137
+ return {}
138
+ }
139
+ }
140
+ const strip = p => {
141
+ const m = G.document && document.querySelector('meta[name=brain-base]'), b = (m && m.getAttribute('content')) || '/'
142
+ return b !== '/' && (p + '/').startsWith(b) ? '/' + p.slice(b.length) : p
143
+ }
144
+ const read = () => {
145
+ const u = new URL(location.href)
146
+ return [strip(u.pathname), paramsOf(), Object.fromEntries(u.searchParams)]
147
+ }
148
+ export function route() {
149
+ const s = G.__brainRoute
150
+ if (s) return { path: s.path, params: s.params || {}, query: s.query || {} }
151
+ if (!cur) {
152
+ const S = read().map(v => sig(v))
153
+ cur = { get path() { return S[0].v }, get params() { return S[1].v }, get query() { return S[2].v } }
154
+ G.__brainRouted = () => {
155
+ const v = read()
156
+ S.forEach((s, i) => JSON.stringify(untrack(() => s.v)) !== JSON.stringify(v[i]) && (s.v = v[i])) // only what changed
157
+ }
158
+ }
159
+ return cur
160
+ }
package/brain-err.js ADDED
@@ -0,0 +1,242 @@
1
+ // BRAINLLM native error capture (INTERNALS §18), no third parties. The core already captures fx, drv, handlers and render
2
+ // (and cuts B0902 loops); this module gives each error a fingerprint, component and parent chain, stack, breadcrumbs
3
+ // (last 30 actions), build and browser; it dedupes and sends via sendBeacon with a cap.
4
+ // Usage: prod `capture({ report: '/path', build })` (brain build --report) · in dev brain-inspect installs it with `who`.
5
+ // `fallback(C, fb)` (codegen of `<meta fallback="…">`: `dev(fallback(C, fb))`) lives here: importing the module
6
+ // installs the core hook, so an app without fallbacks or --report does not pay for this code.
7
+ // Never includes input values, cookies, query strings or signal values.
8
+ import { _err, sig } from './brain.js'
9
+
10
+ const G = globalThis
11
+ const RT = new URL('.', import.meta.url).href
12
+ // Core hook: records (capture active) or console.error, and falls back to the nearest boundary's fallback (not for
13
+ // event handlers).
14
+ const I = _err((e, x, w) => {
15
+ on ? record(e, x, w) : G.console && console.error(e)
16
+ // §42: a failing event handler never destroys its component (it is reported; brain-guard shows the toast)
17
+ if (String(w).startsWith('on:')) return
18
+ for (; x; x = x.p || x.parentNode) {
19
+ const b = BW.get(x)
20
+ if (b) return fall(b)
21
+ }
22
+ })
23
+ // Boundaries: owner N and its root elements → { fb, n: nodes, r: owner, f: already fell }.
24
+ const BW = new WeakMap()
25
+ const fbn = fb => {
26
+ if (typeof fb === 'function') return I.nodes(fb())
27
+ const p = document.createElement('p')
28
+ p.setAttribute('role', 'alert')
29
+ p.textContent = fb
30
+ return [p]
31
+ }
32
+ function fall(b) {
33
+ if (b.f) return
34
+ b.f = 1
35
+ const a = fbn(b.fb), f = b.n[0], p = f && f.parentNode
36
+ if (p) for (const x of a) p.insertBefore(x, f)
37
+ b.r.q() // stops fx and handlers of the fallen component
38
+ I.rm(b.n)
39
+ b.n = a
40
+ }
41
+ // An error in the component's render, fx or handler swaps its nodes for the fallback (text → <p role=alert>,
42
+ // function → nodes); the rest of the app keeps running.
43
+ export const fallback = (C, fb) =>
44
+ Object.assign(
45
+ $p =>
46
+ I.root(() => {
47
+ const r = I.owner(), b = { fb, n: [], r, f: 0 }
48
+ BW.set(r, b)
49
+ let a
50
+ try {
51
+ a = I.nodes(C($p))
52
+ } catch (e) {
53
+ on ? record(e, r, 'render') : G.console && console.error(e)
54
+ r.q()
55
+ ;(b.f = 1), (a = fbn(fb))
56
+ }
57
+ for (const x of (b.n = a)) BW.set(x, b)
58
+ return a
59
+ }, I.owner()),
60
+ { C, h: C.h, f: C.f, p: C.p, form: C.form }
61
+ )
62
+
63
+ const MSG = { B0902: 'fx re-ran >100 times in one flush: sig→fx→sig cycle; cut off so the tab does not hang' }
64
+ export const errors = [] // deduplicated records, most recent last (Errors view, MCP brain_errors)
65
+ export const sub = new Set() // fn(record) on each new or repeated error
66
+ const crumbs = []
67
+ let opts = {}, on = false, sent = 0, F0 = null
68
+
69
+ export function crumb(k, d) {
70
+ const l = crumbs[crumbs.length - 1]
71
+ if (l && l.k === k && l.d === d) return void l.n++ // the same repeated action is counted, not stacked
72
+ crumbs.push({ k, d, n: 1, at: Date.now() })
73
+ if (crumbs.length > 30) crumbs.shift()
74
+ }
75
+
76
+ const hash = s => {
77
+ let h = 5381
78
+ for (let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0
79
+ return h.toString(36)
80
+ }
81
+ const path = u => {
82
+ try {
83
+ return new URL(u, G.location && G.location.href).pathname // no query: it may carry sensitive data
84
+ } catch {
85
+ return String(u).split('?')[0]
86
+ }
87
+ }
88
+ // "url:line:col" of each frame; the first one outside the runtime is the app line.
89
+ function frames(e) {
90
+ const out = []
91
+ for (const l of String((e && e.stack) || '').split('\n')) {
92
+ const m = l.match(/((?:https?|file):\/\/[^\s)]+):(\d+):(\d+)/)
93
+ if (m) out.push({ url: m[1], line: +m[2], col: +m[3], rt: m[1].startsWith(RT) && !m[1].slice(RT.length).includes('/') })
94
+ }
95
+ return out
96
+ }
97
+ // Component via the DOM: roots with data-b="<hash>" (prod) → parent chain via ancestors with data-b.
98
+ function byDom(el) {
99
+ const chain = []
100
+ for (; el; el = el.parentNode) if (el.getAttribute && el.getAttribute('data-b')) chain.push({ h: el.getAttribute('data-b') })
101
+ return chain.length ? { ...chain[0], chain: chain.map(c => c.h) } : null
102
+ }
103
+ // Prod chunk /_b/<hash>.brain → component hash.
104
+ const byStack = f => {
105
+ const m = f && f.url.match(/\/_b\/([a-z0-9]+)/)
106
+ return m ? { h: m[1], chain: [m[1]] } : null
107
+ }
108
+
109
+ // .brain lines (§18, dev): each module compiled in dev ends with an inline Source Map v3 that maps every generated
110
+ // line to its .brain line. With `maps`, a recorded error is enriched asynchronously: `src` ('views/home.brain:12', the
111
+ // first app frame) and `brainStack`; subscribers (brain_errors, overlay, doctor) are notified again. The file path
112
+ // comes from the module URL (two `index.brain` in different folders never mix); prod chunks carry no map: no-op.
113
+ const MAPS = new Map()
114
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
115
+ export function decode(m) {
116
+ const lines = []
117
+ let src = 0
118
+ for (const seg of m.split(';')) {
119
+ let line = null
120
+ for (const part of seg.split(',')) {
121
+ if (!part) continue
122
+ const v = []
123
+ for (let i = 0, n = 0, sh = 0; i < part.length; i++) {
124
+ const d = B64.indexOf(part[i])
125
+ n += (d & 31) << sh
126
+ if (d & 32) sh += 5
127
+ else (v.push(n & 1 ? -(n >> 1) : n >> 1), (n = 0), (sh = 0))
128
+ }
129
+ if (v.length >= 3) (src += v[2]), line === null && (line = src + 1)
130
+ }
131
+ lines.push(line)
132
+ }
133
+ return lines
134
+ }
135
+ const mapOf = url => {
136
+ if (!MAPS.has(url))
137
+ MAPS.set(
138
+ url,
139
+ Promise.resolve(opts.load ? opts.load(url) : F0(url).then(r => r.text()))
140
+ .then(t => {
141
+ const m = /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(t)
142
+ return m ? decode(JSON.parse(atob(m[1])).mappings) : null
143
+ })
144
+ .catch(() => null)
145
+ )
146
+ return MAPS.get(url)
147
+ }
148
+ async function toBrain(r, f) {
149
+ const out = []
150
+ for (const t of f.slice(0, 12)) {
151
+ const lines = t.rt ? null : await mapOf(t.url)
152
+ const l = lines && lines[t.line - 1]
153
+ if (l) out.push(`${path(t.url).replace(/^\//, '')}:${l}`)
154
+ }
155
+ if (!out.length) return
156
+ r.src = out[0]
157
+ r.brainStack = out
158
+ for (const s of sub) s(r)
159
+ }
160
+
161
+ function record(e, x, w) {
162
+ if (e && e.kind === 'aborted') return // a cancelled request (brain-data ApiError): not an error
163
+ const f = frames(e), top = f.find(t => !t.rt) || null
164
+ const c = (opts.who && opts.who(x)) || (x && x.nodeType ? byDom(x) : null) || byStack(top)
165
+ const type = (e && e.code) || (e && e.name) || typeof e
166
+ const line = top ? `${path(top.url)}:${top.line}` : null
167
+ const fp = hash([type, c && c.h, line, w].join('|'))
168
+ let r = errors.find(r => r.fp === fp)
169
+ if (r) (r.n++, (r.last = Date.now()))
170
+ else {
171
+ r = {
172
+ fp, code: (e && e.code) || null, type, msg: MSG[e && e.code] || String((e && e.message) || e).slice(0, 500), where: w || null, comp: c, line,
173
+ stack: f.slice(0, 12).map(t => `${path(t.url)}:${t.line}:${t.col}`), crumbs: crumbs.map(c => ({ ...c })),
174
+ build: opts.build || null, ua: G.navigator ? navigator.userAgent : null, at: Date.now(), last: Date.now(), n: 1,
175
+ }
176
+ errors.push(r)
177
+ if (errors.length > 100) errors.shift()
178
+ if (opts.maps) toBrain(r, f)
179
+ if (opts.report && G.navigator && navigator.sendBeacon && sent < (opts.max || 20)) sent++, navigator.sendBeacon(opts.report, JSON.stringify(r))
180
+ }
181
+ for (const s of sub) s(r)
182
+ if (opts.log !== false && G.console) console.error(e)
183
+ return r
184
+ }
185
+
186
+ // Readable element label without values: aria-label, name or button/link text (40 chars).
187
+ const label = el => {
188
+ if (!el || !el.tagName) return '?'
189
+ const t = el.tagName.toLowerCase(), a = el.getAttribute('aria-label') || el.getAttribute('name')
190
+ const txt = /^(input|textarea|select)$/.test(t) ? '' : String(el.textContent || '').trim().slice(0, 40)
191
+ return `${t}${a ? `[${a}]` : ''}${txt ? ` "${txt}"` : ''}`
192
+ }
193
+
194
+ export function capture(o = {}) {
195
+ if (on) return (Object.assign(opts, o), errors)
196
+ on = true
197
+ opts = o
198
+ F0 = G.fetch && G.fetch.bind(G) // the unwrapped fetch: reading source maps leaves no breadcrumbs
199
+ if (G.addEventListener) {
200
+ G.addEventListener('error', ev => record(ev.error || ev.message, null, 'onerror'))
201
+ G.addEventListener('unhandledrejection', ev => record(ev.reason, null, 'promise'))
202
+ G.addEventListener('popstate', () => crumb('nav', path(location.href)))
203
+ G.addEventListener('hashchange', () => crumb('nav', path(location.href) + location.hash))
204
+ }
205
+ const d = G.document
206
+ if (d && d.addEventListener) {
207
+ for (const t of ['click', 'submit', 'change']) d.addEventListener(t, e => crumb(t, label(e.target)), true)
208
+ d.addEventListener('input', e => crumb('input', label(e.target)), true) // only which field, never the value
209
+ }
210
+ if (G.history)
211
+ for (const k of ['pushState', 'replaceState']) {
212
+ const f = history[k]
213
+ history[k] = function (s, t, u) {
214
+ const r = f.apply(this, arguments)
215
+ crumb('nav', path(u == null ? location.href : u))
216
+ return r
217
+ }
218
+ }
219
+ if (G.fetch) {
220
+ const f = G.fetch
221
+ G.fetch = function (i, init) {
222
+ const m = (init && init.method) || (i && i.method) || 'GET', u = path((i && i.url) || i)
223
+ crumb('fetch', `${m} ${u}`)
224
+ return f.apply(this, arguments).then(
225
+ r => (r.status >= 400 && crumb('fetch', `${m} ${u} → ${r.status}`), r),
226
+ e => (crumb('fetch', `${m} ${u} → failed`), Promise.reject(e))
227
+ )
228
+ }
229
+ }
230
+ // Signal writes: name (dev, opts.sigName) or 'sig'; never the value.
231
+ const P = Object.getPrototypeOf(sig(0)), v = Object.getOwnPropertyDescriptor(P, 'v')
232
+ Object.defineProperty(P, 'v', {
233
+ configurable: true,
234
+ get: v.get,
235
+ set(x) {
236
+ const b = this._
237
+ v.set.call(this, x)
238
+ if (this._ !== b) crumb('sig', (opts.sigName && opts.sigName(this)) || 'sig')
239
+ },
240
+ })
241
+ return errors
242
+ }
package/brain-form.js ADDED
@@ -0,0 +1,260 @@
1
+ // BRAINLLM forms (INTERNALS §14). `let f = form(schema, { submit: async (v) => … })` →
2
+ // bind:value={f.v.email} · {f.err.email} · f.busy · f.error (form-level) · f.ok · on:submit={f.submit}
3
+ // Schema mini-language per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists,
4
+ // value for num) + `[T]` list of T, or a nested schema object (a type from types.jsbrain).
5
+ // Validation: a field is validated when it loses focus and every field on submit; errors show only after that.
6
+ // The element of each field is found without ids: the control that has focus while its value is written (the
7
+ // `bind:` input handler), or on submit `form.elements[name]`. On it the runtime keeps `aria-invalid` and, when the
8
+ // error element exists (`<Field>` or any element with id `f.id(name)`), `aria-describedby`.
9
+ // Submit: validates, focuses the first invalid control, ignores re-entry while `busy`, converts values (num → Number),
10
+ // awaits `submit(values)`. A returned or thrown `{ errors: { field: msg } }` marks fields; any other failure → f.error
11
+ // (a cancelled request, kind 'aborted', is not an error to show).
12
+ // Async checks (§41.6): `check: { email: async (v) => (await users.exists({ email: v })) ? 'Taken' : '' }` run after
13
+ // the field's own rule passes, when it loses focus and before submit; `f.checking` while any runs; a stale answer
14
+ // (the value changed meanwhile) is ignored.
15
+ // Unsaved changes (§41.5): `f.dirty` is true while values differ from the last saved ones (init, reset() or a successful
16
+ // submit); leaving the page (client navigation or closing the tab) then asks first. No API.
17
+ import { sig, fx, batch, untrack, _rt } from './brain.js'
18
+ import { rule, check } from './brain-schema.js'
19
+
20
+ export { rule, check } // server.mjs validates posted forms with the same rules (§38)
21
+
22
+ const I = _rt()
23
+
24
+ const D = globalThis.document
25
+ let seq = 0
26
+ const GENERIC = 'Something went wrong. Try again.' // the text callForm (server.mjs) shows without JS
27
+ // navigate through the client router when it is loaded (no reload, base path applied), else a normal load
28
+ const go = u => {
29
+ const G = globalThis
30
+ if (G.__brainNavigate) return G.__brainNavigate(u)
31
+ const m = D && D.querySelector('meta[name=brain-base]'), b = (m && m.getAttribute('content')) || '/'
32
+ G.location && G.location.assign(b !== '/' && !(u + '/').startsWith(b) ? b + u.slice(1) : u)
33
+ }
34
+ // forms with unsaved changes: brain-router asks before a client navigation, the browser before unload. A form being
35
+ // submitted does not ask (#299): the user chose to save, and a submit() that navigates once the server accepted the
36
+ // data (`navigate('/products/' + p.id)`) leaves before submit() returns and marks the values saved.
37
+ const DIRTY = (globalThis.__brainDirty ||= new Set())
38
+ if (globalThis.addEventListener && !globalThis.__brainDirtyHook) {
39
+ globalThis.__brainDirtyHook = 1
40
+ globalThis.addEventListener('beforeunload', e => {
41
+ for (const f of DIRTY) if (f.dirty && !f.busy) return void (e.preventDefault(), (e.returnValue = ''))
42
+ })
43
+ }
44
+ const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
45
+ const init0 = r => (r.list ? [] : r.t === 'bool' ? false : '')
46
+ const out = (r, v) => (r.list ? v || [] : r.t === 'num' ? (empty(v) ? null : Number(v)) : r.t === 'bool' ? !!v : v)
47
+
48
+ // state left by the server: globalThis.__brainForms while it renders, or the page's #brain-forms JSON
49
+ let SEEN
50
+ function posted(id) {
51
+ if (!id) return null
52
+ const g = globalThis.__brainForms
53
+ if (g) return g[id] || null
54
+ if (SEEN === undefined)
55
+ try {
56
+ const el = D && D.getElementById('brain-forms')
57
+ SEEN = el ? JSON.parse(el.textContent) : null
58
+ } catch {
59
+ SEEN = null
60
+ }
61
+ return (SEEN && SEEN[id]) || null
62
+ }
63
+
64
+ export function form(schema, o = {}) {
65
+ const id = 'f' + ++seq, owner = I.owner() // aria effects are created later (in handlers) but live with the component
66
+ const busy = sig(false), error = sig(''), shown = sig(0) // shown: bumped when touched set changes
67
+ const checking = sig(0), saved = sig('') // async checks running · JSON of the last saved values
68
+ const F = [] // flat fields: { path, r, s (value sig), e (error sig), el, touched }
69
+ const byPath = {}
70
+
71
+ // Reactive getters/setters, nested for sub-schemas: f.v.address.city
72
+ function build(sch, base, init) {
73
+ const v = {}, err = {}
74
+ for (const k of Object.keys(sch)) {
75
+ const r = rule(sch[k]), path = base ? base + '.' + k : k
76
+ if (r.t === 'obj') {
77
+ const sub = build(r.s, path, (init && init[k]) || {})
78
+ Object.defineProperty(v, k, { enumerable: true, value: sub.v })
79
+ Object.defineProperty(err, k, { enumerable: true, value: sub.err })
80
+ continue
81
+ }
82
+ const x = { path, r, s: sig(init && k in init ? init[k] : init0(r)), e: sig(''), el: null, touched: false }
83
+ F.push(x)
84
+ byPath[path] = x
85
+ Object.defineProperty(v, k, {
86
+ enumerable: true,
87
+ get: () => x.s.v,
88
+ set: val => {
89
+ x.s.v = val
90
+ track(x, D && D.activeElement)
91
+ if (x.touched) validate(x) // already shown: keep the message live while fixing it
92
+ },
93
+ })
94
+ Object.defineProperty(err, k, { enumerable: true, get: () => (shown.v, x.touched ? x.e.v : '') })
95
+ }
96
+ return { v, err }
97
+ }
98
+
99
+ function track(x, el) {
100
+ // the first field written while a control has focus claims it: logic in that control's handler that writes
101
+ // other fields does not steal it
102
+ if (!el || x.el || el.$ff || !el.isConnected || !/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName || '')) return
103
+ x.el = el
104
+ el.$ff = x
105
+ el.addEventListener('focusout', () => touch(x))
106
+ I.root(() => fx(() => {
107
+ const e = shown.v >= 0 && x.touched && x.e.v // reads shown first: re-runs when the field gets touched
108
+ if (e) el.setAttribute('aria-invalid', 'true')
109
+ else el.removeAttribute('aria-invalid')
110
+ const d = D && D.getElementById(fid(x.path))
111
+ if (e && d) el.setAttribute('aria-describedby', fid(x.path))
112
+ else if (el.getAttribute('aria-describedby') === fid(x.path)) el.removeAttribute('aria-describedby')
113
+ }), owner)
114
+ }
115
+ const touch = x => {
116
+ validate(x)
117
+ if (!x.touched) (x.touched = true), shown.v++
118
+ remote(x)
119
+ }
120
+ // async check of one field (only when its own rule passes); resolves to its message
121
+ const remote = x => {
122
+ const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
123
+ if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
124
+ checking.v++
125
+ return Promise.resolve()
126
+ .then(() => c(v))
127
+ .then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || 'Could not check this value.'))
128
+ .then(m => {
129
+ if (untrack(() => x.s.v) === v) x.e.v = m // a stale answer (the value changed meanwhile) is ignored
130
+ return untrack(() => x.e.v)
131
+ })
132
+ .finally(() => checking.v--)
133
+ }
134
+ const validate = x => (x.e.v = untrack(() => check(x.r, x.s.v)))
135
+ const fid = p => `${id}-${p.replace(/\./g, '-')}-err`
136
+
137
+ // A form posted without JS (§38) comes back from the server with its values and messages: start from them, already
138
+ // shown, both while the server renders the page and when the page hydrates
139
+ const st = posted(o.submit && o.submit.id)
140
+ const { v, err } = build(schema, '', st ? st.values : o.init)
141
+ if (st) {
142
+ for (const p in st.errors || {}) if (byPath[p]) (byPath[p].e.v = String(st.errors[p])), (byPath[p].touched = true)
143
+ error.v = st.error || ''
144
+ }
145
+ const now = () => JSON.stringify(F.map(x => x.s.v))
146
+ saved.v = untrack(now)
147
+
148
+ function values() {
149
+ const r = {}
150
+ for (const x of F) {
151
+ const ks = x.path.split('.')
152
+ let t = r
153
+ for (const k of ks.slice(0, -1)) t = t[k] || (t[k] = {})
154
+ t[ks.at(-1)] = out(x.r, untrack(() => x.s.v))
155
+ }
156
+ return r
157
+ }
158
+ function mark(errors) {
159
+ batch(() => {
160
+ for (const p in errors) {
161
+ const x = byPath[p]
162
+ if (x) (x.e.v = String(errors[p])), (x.touched = true)
163
+ }
164
+ shown.v++
165
+ })
166
+ }
167
+
168
+ const f = {
169
+ v,
170
+ err,
171
+ get busy() {
172
+ return busy.v
173
+ },
174
+ get error() {
175
+ return error.v
176
+ },
177
+ get dirty() {
178
+ return now() !== saved.v
179
+ },
180
+ get checking() {
181
+ return checking.v > 0
182
+ },
183
+ get ok() {
184
+ for (const x of F) if (check(x.r, x.s.v)) return false
185
+ return true
186
+ },
187
+ id: path => fid(path),
188
+ values,
189
+ touch: path => byPath[path] && touch(byPath[path]),
190
+ errors: mark,
191
+ reset(init) {
192
+ batch(() => {
193
+ for (const x of F) {
194
+ let val = init
195
+ for (const k of x.path.split('.')) val = val == null ? undefined : val[k]
196
+ x.s.v = val === undefined ? init0(x.r) : val
197
+ x.e.v = ''
198
+ x.touched = false
199
+ }
200
+ error.v = ''
201
+ shown.v++
202
+ saved.v = untrack(now)
203
+ })
204
+ },
205
+ async submit(e) {
206
+ if (e && e.preventDefault) e.preventDefault()
207
+ if (untrack(() => busy.v)) return // no re-entry: a double click sends once
208
+ const fm = e && e.target && e.target.elements ? e.target : null
209
+ let first = null
210
+ batch(() => {
211
+ for (const x of F) {
212
+ if (!x.el && fm) track(x, fm.elements.namedItem && fm.elements.namedItem(x.path))
213
+ validate(x)
214
+ x.touched = true
215
+ if (!first && x.e.v) first = x
216
+ }
217
+ error.v = ''
218
+ shown.v++
219
+ })
220
+ if (o.validate && !first) {
221
+ const cross = o.validate(values()) // cross-field rules: { field: msg }
222
+ if (cross && Object.keys(cross).length) {
223
+ mark(cross)
224
+ first = byPath[Object.keys(cross)[0]] || null
225
+ if (!first) return
226
+ }
227
+ }
228
+ if (first) return void (first.el && first.el.focus && first.el.focus())
229
+ busy.v = true
230
+ if (fm) fm.setAttribute('aria-busy', 'true') // assistive tech hears the form is working
231
+ try {
232
+ if (o.check) {
233
+ await Promise.all(F.map(remote))
234
+ first = F.find(x => untrack(() => x.e.v))
235
+ if (first) return void (shown.v++, first.el && first.el.focus && first.el.focus())
236
+ }
237
+ if (!o.submit) return
238
+ const r = await o.submit(values())
239
+ if (r && r.errors) mark(r.errors)
240
+ else {
241
+ saved.v = untrack(now) // saved: no longer dirty
242
+ // `return { redirect: '/x' }` from the server function: the same page the 303 reaches without JS (§38)
243
+ if (r && typeof r.redirect === 'string' && r.redirect[0] === '/' && r.redirect[1] !== '/') go(r.redirect)
244
+ }
245
+ return r
246
+ } catch (x) {
247
+ if (x && x.errors) mark(x.errors)
248
+ // a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
249
+ // TypeError (the network dropped, or a bug) get the generic one
250
+ else if (!(x && x.kind === 'aborted')) error.v = (x && x.message && !(x.status >= 500) && x.name !== 'TypeError' && x.message) || GENERIC
251
+ } finally {
252
+ busy.v = false
253
+ if (fm) fm.removeAttribute('aria-busy')
254
+ }
255
+ },
256
+ }
257
+ DIRTY.add(f)
258
+ if (owner) I.root(() => fx(() => () => DIRTY.delete(f)), owner)
259
+ return f
260
+ }