@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
@@ -0,0 +1,353 @@
1
+ // BRAINLLM client router (INTERNALS §19). Views are prerendered pages, so navigating = fetch the next page, swap its
2
+ // region (`main` by default), title and SEO meta, inside a View Transition when available. Islands keep working: their
3
+ // resume listeners are delegated on document; islands swapped out are disposed (`$d`, set by the core's resume).
4
+ // Navigation API (Baseline 2026) intercepts link navigations and back/forward and restores scroll and
5
+ // focus; without it, a click listener + History API do the same. Any failure falls back to a full page load.
6
+ // Page contract (dev server and build): the region holds the view and the script that mounts it, which stores the
7
+ // dispose on the region (`e.$d = mount(V, e, p)`); the router disposes it before swapping and runs the new one.
8
+ // Usage (app entry, emitted by the toolchain): `router()` · options: { region, skip(url) → bool, parse(html) → doc,
9
+ // guards }.
10
+ // Guards (INTERNALS §22): `guards: { '/admin': ctx => … }` or `guards: { '/admin': '/_b/<h>.js' }`, one per folder with
11
+ // `_guard.jsbrain` (the build emits the chunk URL; the router imports it the first time it is needed). Before navigating, every guard whose folder contains
12
+ // the target runs, outer to inner, with ctx = { url, path, query }. It allows by returning nothing/true, cancels with
13
+ // `false` and redirects with `redirect('/login')` (thrown or returned) or a string. The server runs the same guard,
14
+ // so a redirect it answers (fetch ends on another URL) is reflected in the address bar. A guard that throws anything
15
+ // else → full page load: the server decides.
16
+ const G = globalThis
17
+ // Base path (<meta name="brain-base" content="/app/">, §23 #148): links in the HTML already carry it; guards and
18
+ // routes work with app paths (strip), and app paths given to go()/navigate()/redirect() get it (app). '/' when absent.
19
+ const base = () => {
20
+ const m = G.document && document.querySelector('meta[name=brain-base]')
21
+ return (m && m.getAttribute('content')) || '/'
22
+ }
23
+ const strip = p => {
24
+ const b = base()
25
+ return b !== '/' && (p + '/').startsWith(b) ? '/' + p.slice(b.length) : p
26
+ }
27
+ const app = u => {
28
+ const b = base()
29
+ return typeof u === 'string' && b !== '/' && u.startsWith('/') && !u.startsWith('//') && !(u + '/').startsWith(b) ? b + u.slice(1) : u
30
+ }
31
+
32
+ // navigate('/account', { replace }) (§41.5): an app path (the base is added), through the running router (no reload,
33
+ // guards run); without a router it is a normal page load.
34
+ let active = null
35
+ export function navigate(to, o = {}) {
36
+ const u = app(String(to))
37
+ if (active) return active.go(u, o.replace ? 'replace' : true)
38
+ o.replace ? location.replace(u) : location.assign(u)
39
+ }
40
+ G.__brainNavigate = navigate // brain-form follows a server function's { redirect } without importing the router
41
+
42
+ // route() lives in brain-env.js (it needs the core's signals; a page without islands downloads only this router):
43
+ // after every client navigation the router calls the hook it installs.
44
+ const routed = () => G.__brainRouted && G.__brainRouted()
45
+
46
+ // Active links (§41.5): every internal <a href> to the current page gets aria-current="page" (the CSS hook is
47
+ // [aria-current=page]); the others lose it. On start and after every client navigation.
48
+ const current = () => {
49
+ if (!G.document || !G.location) return
50
+ for (const a of document.querySelectorAll('a[href]')) {
51
+ let u
52
+ try {
53
+ u = new URL(a.getAttribute('href'), location.href)
54
+ } catch {
55
+ continue
56
+ }
57
+ if (u.origin === location.origin && u.pathname.replace(/\/$/, '') === location.pathname.replace(/\/$/, '')) a.setAttribute('aria-current', 'page')
58
+ else if (a.getAttribute('aria-current') === 'page') a.removeAttribute('aria-current')
59
+ }
60
+ }
61
+
62
+ // A form() with unsaved changes (brain-form keeps them in globalThis.__brainDirty) asks before leaving (§41.5); one
63
+ // being submitted does not (#299): its submit() may navigate on success before it returns
64
+ const leave = () => {
65
+ for (const f of G.__brainDirty || []) if (f.dirty && !f.busy) return !G.confirm || G.confirm('Leave this page? Changes you made will not be saved.')
66
+ return true
67
+ }
68
+
69
+ // Same shapes as server.mjs, so one _guard.jsbrain works on both sides: { redirect, status } and { deny }
70
+ const R = Symbol('redirect') // older compiled guards
71
+ export function redirect(url, status = 303) {
72
+ throw { redirect: String(url), status }
73
+ }
74
+ export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
75
+ // guard result → true (go on) · false (cancel) · string (redirect) · null (full load: the server decides, e.g. 403)
76
+ const outcome = v =>
77
+ v && typeof v === 'object'
78
+ ? R in v
79
+ ? v[R]
80
+ : typeof v.redirect === 'string'
81
+ ? v.redirect
82
+ : v.deny !== undefined
83
+ ? null
84
+ : true
85
+ : v === false || typeof v === 'string'
86
+ ? v
87
+ : true
88
+
89
+ export function router(o = {}) {
90
+ const region = o.region || 'main'
91
+ const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
92
+ let seq = 0, through = null, stale = false
93
+ // Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
94
+ // service worker announcing one, turns client navigation into full loads so old and new code never mix.
95
+ const build = () => {
96
+ const m = document.querySelector('meta[name=brain-build]')
97
+ return m ? m.getAttribute('content') : null
98
+ }
99
+ const sw = G.navigator && navigator.serviceWorker
100
+ const onMsg = e => typeof e.data === 'string' && e.data.startsWith('brain:build ') && build() && e.data.slice(12) !== build() && (stale = true)
101
+ if (sw && sw.addEventListener) sw.addEventListener('message', onMsg)
102
+ // a guard is a function, or the URL of its `_guard.jsbrain` chunk (BUILD #220): loaded the first time a navigation
103
+ // reaches that folder, then cached by the module map; its default export runs with the same ctx
104
+ const gs = Object.entries(o.guards || {})
105
+ .map(([pre, g]) => [pre, typeof g === 'string' ? c => import(g).then(m => m.default(c)) : g])
106
+ .sort((a, b) => a[0].length - b[0].length)
107
+ current()
108
+ // → true (go on) · false (cancel) · string (redirect) · null (guard failed: full load)
109
+ async function check(u) {
110
+ const url = new URL(u, location.href), path = strip(url.pathname) // guards see app paths (no base)
111
+ for (const [pre, g] of gs) {
112
+ if (!(path === pre || path.startsWith(pre.endsWith('/') ? pre : pre + '/'))) continue
113
+ let v
114
+ try {
115
+ v = await g({ url, path, query: Object.fromEntries(url.searchParams) })
116
+ } catch (e) {
117
+ return e && typeof e === 'object' && (R in e || 'redirect' in e) ? outcome(e) : null
118
+ }
119
+ v = outcome(v)
120
+ if (v !== true) return v
121
+ }
122
+ return true
123
+ }
124
+
125
+ // _loading.brain (#235): when the next page takes longer than 150 ms, the nearest loading view of the target route
126
+ // (`loading: [{ rx, view }]`, most specific first, emitted by the build with the `core` URL) is mounted in the
127
+ // region until the page arrives; fast navigations never show it, so nothing flickers.
128
+ const loads = o.loading || []
129
+ function waiting(url, my) {
130
+ const path = strip(new URL(url, location.href).pathname), l = loads.find(x => new RegExp(x.rx).test(path))
131
+ const w = { done: false, t: 0 }
132
+ if (!l || !o.core) return w
133
+ w.t = setTimeout(() => {
134
+ Promise.all([import(o.core), import(l.view)])
135
+ .then(([c, v]) => {
136
+ const el = document.querySelector(region), mount = c.m || c.mount
137
+ if (w.done || my !== seq || !el || !mount) return // the page arrived first
138
+ for (const e of [el, ...walkAll(el)]) if (e.$d) e.$d(), (e.$d = null)
139
+ for (const n of [...el.childNodes]) if (!/^(STYLE|LINK)$/.test(n.nodeName)) n.remove() // the view's CSS stays (BUILD #35)
140
+ el.$d = mount(v.default, el, {})
141
+ })
142
+ .catch(() => {})
143
+ }, 150)
144
+ return w
145
+ }
146
+
147
+ async function load(url) {
148
+ const my = ++seq
149
+ let doc, r
150
+ if (stale) return hard(url)
151
+ const slow = waiting(url, my)
152
+ // the previous route's background requests (prefetch) are dropped by brain-data's queue (§41.4)
153
+ try {
154
+ G.dispatchEvent && G.dispatchEvent(new Event('brain:navigate'))
155
+ } catch {}
156
+ try {
157
+ r = await fetch(url, { headers: { 'x-brain-nav': '1' } })
158
+ const b = r.headers && r.headers.get && r.headers.get('x-brain-build')
159
+ if (b && build() && b !== build()) return void (my === seq && hard(url)) // a new deploy: load it whole
160
+ const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
161
+ if (!r.ok || !/html/.test(ct)) throw Error(r.status)
162
+ doc = parse(await r.text())
163
+ } catch {
164
+ return void ((slow.done = true), clearTimeout(slow.t), my === seq && hard(url))
165
+ }
166
+ slow.done = true
167
+ clearTimeout(slow.t)
168
+ if (my !== seq) return // a newer navigation won
169
+ // another language (§37): a full load brings its own dictionaries, `lang` and `dir`; locales never mix in memory
170
+ const lang = d => d && d.documentElement && d.documentElement.getAttribute('lang')
171
+ if (lang(doc) && lang(document) && lang(doc) !== lang(document)) return hard(url)
172
+ if (r.redirected && r.url && G.history && r.url !== new URL(url, location.href).href) history.replaceState(history.state, '', r.url)
173
+ const cur = document.querySelector(region), next = doc.querySelector(region)
174
+ if (!cur || !next) return hard(url)
175
+ const swap = () => {
176
+ // read before the swap: afterwards the fetched page's scripts are in the document too
177
+ const own = document.querySelector('script[nonce]'), nonce = own && (own.nonce || own.getAttribute('nonce'))
178
+ for (const el of [cur, ...walkAll(cur)]) if (el.$d) el.$d(), (el.$d = null)
179
+ cur.replaceChildren(...[...next.childNodes].map(n => (document.adoptNode ? document.adoptNode(n) : n)))
180
+ // Scripts parsed by DOMParser never run: the region's own scripts (the view's mount, `e.$d = mount(...)`) are
181
+ // recreated so they execute. Already-imported modules come from the module map: their state survives.
182
+ // The page's CSP only trusts its own nonce: a recreated script carries the current page's nonce, never the one
183
+ // that came with the fetched page (each server response has a fresh one).
184
+ for (const sc of cur.querySelectorAll('script')) {
185
+ if (sc.type && !/^(module|text\/javascript)$/.test(sc.type)) continue
186
+ const n = document.createElement('script')
187
+ for (const a of sc.attributes) if (a.name !== 'nonce') n.setAttribute(a.name, a.value)
188
+ if (nonce) (n.nonce = nonce), n.setAttribute('nonce', nonce)
189
+ n.textContent = sc.textContent
190
+ sc.replaceWith(n)
191
+ }
192
+ // QA #291: the view's mount script stores its dispose (`e.$d = mount(...)`) after the swap, since module scripts
193
+ // run later; until then the region shows the server's HTML with no handlers. Clicks and submits there are held and
194
+ // replayed on the mounted view, by position, so the first click after a navigation is never lost.
195
+ if (!cur.$d && [...cur.querySelectorAll('script[type=module]')].some(sc => /\$d\s*=/.test(sc.textContent))) hold(cur)
196
+ for (const a of next.attributes || []) cur.setAttribute(a.name, a.value)
197
+ if (doc.title) document.title = doc.title
198
+ for (const s of ['meta[name=description]', 'link[rel=canonical]']) {
199
+ const a = document.head && document.head.querySelector(s), b = doc.querySelector(s)
200
+ if (a && b) a.replaceWith(document.adoptNode ? document.adoptNode(b) : b)
201
+ }
202
+ // SEO (§32): og:*, twitter:*, robots and JSON-LD follow the page; the region's own tags stay where they are
203
+ const tags = (r, skip) =>
204
+ [...((r && r.querySelectorAll && r.querySelectorAll('meta,script')) || [])].filter(e => {
205
+ for (let p = e.parentNode; p; p = p.parentNode) if (p === skip) return false
206
+ const k = e.getAttribute('property') || e.getAttribute('name') || ''
207
+ return e.tagName === 'SCRIPT' ? e.getAttribute('type') === 'application/ld+json' : /^(og:|twitter:|robots$)/.test(k)
208
+ })
209
+ if (document.head) {
210
+ for (const e of tags(document.head)) e.remove()
211
+ for (const e of tags(doc.head || doc, next)) document.head.appendChild(document.adoptNode ? document.adoptNode(e) : e)
212
+ }
213
+ // the next page's route params (or none): route() reads them
214
+ const pa = document.head && document.head.querySelector('meta[name=brain-params]'), pb = doc.querySelector('meta[name=brain-params]')
215
+ if (pa) pa.remove()
216
+ if (pb && document.head) document.head.appendChild(document.adoptNode ? document.adoptNode(pb) : pb)
217
+ routed()
218
+ current()
219
+ }
220
+ if (document.startViewTransition) {
221
+ const vt = document.startViewTransition(swap)
222
+ vt.ready.catch(() => {}) // rejects when a faster navigation skips this transition: expected, not an error
223
+ await vt.updateCallbackDone
224
+ } else swap()
225
+ }
226
+ // a full load must not be intercepted again (Navigation API): `through` lets that one navigation pass
227
+ const hard = url => ((through = new URL(url, location.href).href), (o.hard || (u => location.assign(u)))(url))
228
+ const same = u => {
229
+ try {
230
+ const x = new URL(u, location.href), b = base()
231
+ // same origin and inside the app's base path (§23 #148); anything else is a full load
232
+ return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !(o.skip && o.skip(x))
233
+ } catch {
234
+ return false
235
+ }
236
+ }
237
+
238
+ const nav = G.navigation
239
+ if (nav && nav.addEventListener) {
240
+ const h = e => {
241
+ if (through && e.destination.url === through) return void (through = null)
242
+ if (e.navigationType === 'reload') return // a reload is always a real full load (e.g. after a deploy deleted a chunk)
243
+ if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
244
+ if (e.cancelable && !leave()) return e.preventDefault()
245
+ const to = e.destination.url, from = nav.currentEntry && nav.currentEntry.url
246
+ if (!gs.length) return e.intercept({ handler: () => load(to) }) // scroll + focus reset handled by the browser
247
+ let g
248
+ e.intercept({
249
+ // Chrome 141+: decided before the URL commits (cancel = the address bar never changes; native redirect).
250
+ async precommitHandler(c) {
251
+ g = await check(to)
252
+ if (typeof g === 'string' && c.redirect) c.redirect(new URL(app(g), location.href).href), (g = true)
253
+ else if (g === false) throw new DOMException('guard', 'AbortError')
254
+ },
255
+ async handler() {
256
+ if (g === undefined) g = await check(to) // no precommit support: the URL already committed
257
+ if (g === true) return load(nav.currentEntry ? nav.currentEntry.url : to)
258
+ if (g === null) return hard(to)
259
+ nav.navigate(g === false ? from : new URL(app(g), location.href).href, { history: 'replace' })
260
+ },
261
+ })
262
+ }
263
+ nav.addEventListener('navigate', h)
264
+ return (active = {
265
+ go: (u, how) => nav.navigate(app(u), how === 'replace' ? { history: 'replace' } : undefined),
266
+ check,
267
+ stop: () => ((active = null), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
268
+ })
269
+ }
270
+
271
+ // Fallback: History API. Scroll position is kept in history.state so back/forward restores it.
272
+ const save = () => history.replaceState({ ...(history.state || {}), y: G.scrollY || 0 }, '')
273
+ let here = location.href
274
+ const go = async (u, push = true, hops = 0) => {
275
+ if (!hops && !leave()) return push || history.pushState(history.state, '', here) // stay (back/forward: restore the URL)
276
+ const g = gs.length ? await check(u) : true
277
+ if (g === null || hops > 5) return hard(u)
278
+ if (g === false) return push || history.pushState(history.state, '', here) // back/forward blocked: stay
279
+ if (g !== true) return go(new URL(app(g), location.href).href, push, hops + 1)
280
+ if (push) save(), history[push === 'replace' ? 'replaceState' : 'pushState']({ y: 0 }, '', u) // push: true | 'replace' | false (back/forward)
281
+ await load(u)
282
+ here = location.href
283
+ const hash = new URL(u, location.href).hash, t = hash && document.getElementById(hash.slice(1))
284
+ if (t) t.scrollIntoView()
285
+ else G.scrollTo && G.scrollTo(0, push ? 0 : (history.state && history.state.y) || 0)
286
+ focusMain()
287
+ }
288
+ const click = e => {
289
+ if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
290
+ let a = e.target
291
+ while (a && a.tagName !== 'A') a = a.parentNode
292
+ if (!a || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download') || !same(a.href || a.getAttribute('href'))) return
293
+ const u = new URL(a.href || a.getAttribute('href'), location.href)
294
+ if (u.pathname === location.pathname && u.hash) return // in-page anchor: native
295
+ e.preventDefault()
296
+ go(u.href)
297
+ }
298
+ const pop = () => go(location.href, false)
299
+ const focusMain = () => {
300
+ const m = document.querySelector(region), h = m && (m.querySelector('h1') || m)
301
+ if (h && h.focus) {
302
+ if (!h.hasAttribute('tabindex')) h.setAttribute('tabindex', '-1')
303
+ h.focus({ preventScroll: true }) // screen readers announce the new view
304
+ }
305
+ }
306
+ document.addEventListener('click', click)
307
+ G.addEventListener && G.addEventListener('popstate', pop)
308
+ return (active = {
309
+ go: (u, push = true) => go(app(u), push),
310
+ check,
311
+ stop: () => ((active = null), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
312
+ })
313
+ }
314
+
315
+ const HOLD = ['click', 'submit']
316
+ function hold(el) {
317
+ if (el.$hold) el.$hold() // a newer navigation drops what the previous page held
318
+ const q = []
319
+ const keep = e => {
320
+ const path = []
321
+ for (let n = e.target; n && n !== el; n = n.parentNode) path.unshift([].indexOf.call(n.parentNode.childNodes, n))
322
+ q.push([e, path])
323
+ e.preventDefault()
324
+ e.stopImmediatePropagation()
325
+ }
326
+ const done = () => {
327
+ clearTimeout(t)
328
+ for (const k of HOLD) el.removeEventListener(k, keep, true)
329
+ delete el.$d
330
+ el.$hold = null
331
+ }
332
+ // a page whose script never mounts (it failed to load) stops holding after 5 s: later clicks act on the HTML as is
333
+ const t = setTimeout(done, 5000)
334
+ for (const k of HOLD) el.addEventListener(k, keep, true)
335
+ el.$hold = done
336
+ Object.defineProperty(el, '$d', {
337
+ configurable: true,
338
+ get: () => undefined,
339
+ set(v) {
340
+ done()
341
+ el.$d = v
342
+ for (const [e, path] of q) {
343
+ let n = el
344
+ for (const i of path) n = n && n.childNodes[i]
345
+ if (n) n.dispatchEvent(new e.constructor(e.type, e))
346
+ }
347
+ },
348
+ })
349
+ }
350
+ function walkAll(n, out = []) {
351
+ for (const c of n.childNodes) if (c.nodeType === 1) out.push(c), walkAll(c, out)
352
+ return out
353
+ }
package/brain-rpc.js ADDED
@@ -0,0 +1,17 @@
1
+ // BRAINLLM RPC client (INTERNALS §11.1), loaded by the core's `rpc()` on the first call (the core stays small: #226).
2
+ // Same-origin POST with an anti-CSRF header; returns the response JSON. A failure (network or status ≥400) is reported
3
+ // to the catcher and rethrown to the caller; 422 (validation, `{ errors: { field: msg } }` that form() marks) is
4
+ // expected, not reported. It never imports the core (that would be a hash cycle): the core hands it `oops` and the base.
5
+ export async function rpc(id, a, oops, base) {
6
+ let r, j
7
+ try {
8
+ r = await fetch(base + '_r/' + id, { method: 'POST', headers: { 'content-type': 'application/json', 'x-brain-rpc': '1' }, body: JSON.stringify(a) })
9
+ const t = await r.text()
10
+ j = t ? JSON.parse(t) : undefined
11
+ } catch (e) {
12
+ throw (oops(e, null, 'rpc'), e)
13
+ }
14
+ if (r.ok) return j
15
+ const e = Object.assign(Error((j && j.error) || 'rpc ' + id + ': ' + r.status), { code: 'rpc', status: r.status, id, errors: j && j.errors })
16
+ throw (r.status == 422 || oops(e, null, 'rpc'), e)
17
+ }
@@ -0,0 +1,69 @@
1
+ // BRAINLLM schema mini-language (INTERNALS §14, §15): shared by form() (brain-form.js), api() validation
2
+ // (brain-data.js) and server.mjs, so a page that only fetches data does not load the form module.
3
+ // Per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists, value for num) + `[T]`
4
+ // list of T, or a nested schema object.
5
+ const RX_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
6
+
7
+ // 'str!<=80' → { t: 'str', req: true, min, max, list: false }
8
+ export function rule(s) {
9
+ if (typeof s === 'object') return { t: 'obj', s, req: false }
10
+ const req = s.includes('!'), r = { t: '', req, list: false, min: null, max: null }
11
+ let x = s.replace(/!/g, '')
12
+ const m = /^(\[?)(\w+)\]?((?:[<>]=-?\d+(?:\.\d+)?)*)$/.exec(x)
13
+ if (!m) throw Error(`form: invalid rule '${s}' (use str num bool email url date, ! required, <=N >=N, [T])`)
14
+ r.list = !!m[1]
15
+ r.t = m[2]
16
+ for (const [, op, n] of m[3].matchAll(/([<>])=(-?\d+(?:\.\d+)?)/g)) r[op === '<' ? 'max' : 'min'] = +n
17
+ return r
18
+ }
19
+
20
+ const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
21
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
22
+
23
+ // Message for one value, or '' when valid.
24
+ export function check(r, v) {
25
+ if (r.list) {
26
+ if (empty(v)) return r.req || r.min > 0 ? (r.req && !r.min ? 'Required' : `Choose at least ${r.min}`) : ''
27
+ if (!Array.isArray(v)) return 'Must be a list'
28
+ if (r.min != null && v.length < r.min) return `Choose at least ${r.min}`
29
+ if (r.max != null && v.length > r.max) return `At most ${plural(r.max, 'item')}`
30
+ const one = { ...r, list: false, req: true, min: null, max: null }
31
+ for (let i = 0; i < v.length; i++) {
32
+ const e = check(one, v[i])
33
+ if (e) return `Item ${i + 1}: ${e}`
34
+ }
35
+ return ''
36
+ }
37
+ if (r.t === 'bool') return r.req && v !== true ? 'Required' : ''
38
+ if (empty(v)) return r.req ? 'Required' : ''
39
+ const s = String(v)
40
+ switch (r.t) {
41
+ case 'num': {
42
+ const n = typeof v === 'number' ? v : s.trim() === '' ? NaN : Number(s)
43
+ if (!Number.isFinite(n)) return 'Enter a number'
44
+ if (r.min != null && n < r.min) return `Must be at least ${r.min}`
45
+ if (r.max != null && n > r.max) return `Must be at most ${r.max}`
46
+ return ''
47
+ }
48
+ case 'email':
49
+ if (!RX_EMAIL.test(s)) return 'Enter a valid email'
50
+ break
51
+ case 'url':
52
+ try {
53
+ if (!/^https?:$/.test(new URL(s).protocol)) return 'Enter a valid URL'
54
+ } catch {
55
+ return 'Enter a valid URL'
56
+ }
57
+ break
58
+ case 'date':
59
+ if (Number.isNaN(Date.parse(s))) return 'Enter a valid date'
60
+ return ''
61
+ case 'str':
62
+ break
63
+ default:
64
+ throw Error(`form: unknown type '${r.t}'`)
65
+ }
66
+ if (r.min != null && s.length < r.min) return `At least ${plural(r.min, 'character')}`
67
+ if (r.max != null && s.length > r.max) return `At most ${plural(r.max, 'character')}`
68
+ return ''
69
+ }
package/brain-wc.js ADDED
@@ -0,0 +1,69 @@
1
+ // BRAINLLM Web Components (SPEC §6.2): define(tag, Comp, css) exports a component as a Custom Element.
2
+ // Imports ./brain.js relatively. Props = attributes from Comp.p (string) or JS properties (any value).
3
+ import { sig, mount, _ev } from './brain.js'
4
+
5
+ const sheets = new Map()
6
+ // Events that do not cross the shadow root (composed: false): also delegated on each shadow root.
7
+ const LOCAL = ['change', 'submit', 'reset', 'select', 'toggle', 'invalid']
8
+
9
+ export function define(tag, C, css = '') {
10
+ const P = C.p || []
11
+ class E extends HTMLElement {
12
+ static observedAttributes = P
13
+ static formAssociated = !!C.form
14
+ constructor() {
15
+ super()
16
+ this.s = {}
17
+ for (const k of P) this.s[k] = sig(this.getAttribute(k))
18
+ this.i = C.form ? this.attachInternals() : null
19
+ this.x = null
20
+ }
21
+ attributeChangedCallback(k, _, v) {
22
+ this.s[k].v = v
23
+ }
24
+ connectedCallback() {
25
+ if (this.x) return
26
+ const r = this.shadowRoot || this.attachShadow({ mode: 'open' }) // reuses Declarative Shadow DOM
27
+ let sh = sheets.get(tag)
28
+ if (!sh) sheets.set(tag, (sh = new CSSStyleSheet())), sh.replaceSync(css)
29
+ r.adoptedStyleSheets = [sh]
30
+ if (!r.$w) {
31
+ r.$w = 1
32
+ for (const t of LOCAL) r.addEventListener(t, _ev)
33
+ }
34
+ r.textContent = '' // v0: DSD content is re-rendered, same as hydrate
35
+ const el = this
36
+ const $p = new Proxy(
37
+ {},
38
+ {
39
+ get: (_, k) =>
40
+ k in el.s
41
+ ? el.s[k].v
42
+ : k === '$internals'
43
+ ? el.i
44
+ : typeof k === 'string' && k.startsWith('on:')
45
+ ? d => el.dispatchEvent(new CustomEvent(k.slice(3), { detail: d, bubbles: true, composed: true }))
46
+ : undefined,
47
+ }
48
+ )
49
+ this.x = mount(C, r, $p)
50
+ }
51
+ disconnectedCallback() {
52
+ // in a microtask: moving the element (disconnect and reconnect) does not destroy it
53
+ queueMicrotask(() => {
54
+ if (!this.isConnected && this.x) this.x(), (this.x = null)
55
+ })
56
+ }
57
+ }
58
+ for (const k of P)
59
+ Object.defineProperty(E.prototype, k, {
60
+ get() {
61
+ return this.s[k].v
62
+ },
63
+ set(v) {
64
+ this.s[k].v = v
65
+ },
66
+ })
67
+ customElements.define(tag, E)
68
+ return E
69
+ }