@roxyon/mcp 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.
@@ -0,0 +1,1914 @@
1
+ # LumenJS — Complete Reference for AI Models
2
+
3
+ > **Audience note for the reader (human or AI):** this document assumes you already know Vue and/or React well, and explains LumenJS **by contrast**. Wherever LumenJS syntax visually resembles Vue or React, assume it behaves like Vue/React **unless this document explicitly says otherwise** — the differences are usually small but breaking (e.g. `:if` looks like Vue's shorthand-bind but is actually Vue's `v-if`).
4
+
5
+ > **Target version: LumenJS V1.** Everything in this document describes V1,
6
+ > which is what the Rahimoun app and the Console are written in. V1 requires
7
+ > explicit `bind=` declarations for reactivity (§5.1) and `var` for every bound
8
+ > variable. A later V2 removes `bind` in favour of automatic reactivity — do not
9
+ > mix V2 idioms into V1 code.
10
+
11
+ **Sources used to compile this document:**
12
+ - The live `lumenjs.com` docs-site checkout (sparse — only a loops guide and a header widget survive in the current working copy).
13
+ - A fuller historical archive of the same docs site (`_ws17/lumenjs.com`, `lumenjs.com_bu`), which contains ~50 additional guide pages not present in the current checkout (routing, modals, storage, drag/sort, events, uploader, realtime API, etc).
14
+ - The compiled framework engine itself (`Rahimoun/src/js/bea.js`), reverse-read for directive names and exact runtime behavior.
15
+ - A large real production app (**Rahimoun**, a charity-management SPA) for real-world usage patterns across ~150 `.view` files.
16
+
17
+ Where the docs-site prose and the engine source disagreed or a feature was undocumented, the engine source (i.e. actual runtime behavior) was treated as authoritative.
18
+
19
+ ---
20
+
21
+ ## 1. What LumenJS is
22
+
23
+ LumenJS is a small, **no-build, no-virtual-DOM** reactive framework created by a company called BEA (also the name behind `beacdn.com`, `beaapis.com`, `bea.sa`). It ships as a single client-side script (historically loaded from a CDN, e.g. `https://s.beacdn.com/e/`, plus a local compiled copy such as `bea.js`) built on top of jQuery. There is no compilation step for the framework itself — `.view` files are plain HTML+JS+CSS files fetched and interpreted directly in the browser.
24
+
25
+ It is paired with a companion CSS framework called **Mobius CSS**, which supplies the utility classes (`w50`, `p20`, `round`, `fs16`, `c`, `l`, `r`, etc.) and a handful of custom elements used purely for layout (`<tbl_nm>`, `<rw>`, `<cl>`, `<grid>`, `<grid_nm>`). **These layout tags are not LumenJS directives** — they're Mobius CSS's flexbox/table abstraction, analogous to Bootstrap's `<div class="row">`/`<div class="col">`. This document does not attempt to be a Mobius CSS reference; only the LumenJS-authored parts of markup are documented in full.
26
+
27
+ There is also a companion CLI (`@lmjs/cli`, invoked as `lm`, `lu`, or `lumen`) that scaffolds and serves projects.
28
+
29
+ ### Mental model, contrasted with Vue/React up front
30
+
31
+ | Concept | Vue / React | LumenJS |
32
+ |---|---|---|
33
+ | Compilation | Vue SFCs / JSX compile to render functions | **None.** `.view` files are HTML fetched over the network and diffed/patched live by the runtime |
34
+ | Component identity | Explicit component classes/functions with lifecycle | A "view" is just an HTML file; a "component" (`comp=`) and a "template" (`tpl=`) are two *other*, different kinds of reusable HTML files — see §4 |
35
+ | State declaration | `data()`/`ref()`/`useState()` | **`var` (never `let`/`const`) in a `<script>` tag inside the view.** A variable becomes reactive only when named in `bind="varName"` on an ancestor element — see §5.1 |
36
+ | Routing | A router library (vue-router, react-router) with route config | **URL path segments map directly to nested view files on disk.** No route config; `nodes[n]` gives you the URL segments at runtime (§9) |
37
+ | Global store | Vuex/Pinia/Redux/Context | A single global `globals` object, auto-persisted to `localStorage` |
38
+ | List rendering | `v-for` / `.map()` | `:for` — similar syntax, very different semantics (arrays *or* a plain number to repeat N times) — see §6.1 |
39
+ | Conditional rendering | `v-if`/`v-else-if`/`v-else` or ternaries/`&&` | `:if` / `:else-if` / `:else` — **same names as Vue minus the `v-` prefix**, and mostly the same semantics |
40
+ | Two-way form binding | `v-model` | **No `:model` equivalent exists.** Forms are handled wholesale via `o-sub`/`b-sub` on the `<form>` (§7) |
41
+ | Event binding | `@click` (Vue) / `onClick` (React) | `@click` — **same syntax as Vue**, but the event catalog is much larger (touch/swipe/drag/sort/scroll — §6.3) and handlers receive `(ev, el)` where `el` is a **jQuery-wrapped** element, not a native DOM node |
42
+ | HTTP client | fetch/axios + your own code | Built-in global `API.get/post/put/delete/patch(path, data, headers, api_index)`, configured via `config.json` |
43
+ | Watchers | `watch()`/`useEffect()` | A single global `watch = { varName: fn }` object (§5.3) |
44
+
45
+ ---
46
+
47
+ ## 2. Project anatomy
48
+
49
+ A LumenJS project (scaffolded by `lm create`) has this shape (from the real `Rahimoun` app):
50
+
51
+ ```
52
+ src/
53
+ index.js # Reactor() bootstrap — the only mandatory JS entry point
54
+ index.html # shell HTML: <script> tags + <div main></div> mount point
55
+ config.json # API base URLs/headers + local dev server + (optionally) FTP deploy config
56
+ css/ # plain CSS
57
+ js/ # bea.js (the compiled engine) + any extra scripts
58
+ views/ # every .view file = one URL route (see §9)
59
+ home.view
60
+ login.view
61
+ account/
62
+ admin/
63
+ dashboard.view -> /account/admin/dashboard
64
+ modals/
65
+ user.view -> opened as a modal, not a route
66
+ widgets/
67
+ header.view -> shared chrome, not a route
68
+ tpls/ # .tpl files: list-item / row templates rendered via `tpl="name"` (see §4.3)
69
+ comps/ # .tpl files: reusable "components" rendered via `comp="name"` (see §4.2)
70
+ ```
71
+
72
+ There is no `dist/` step required during development; `lm build` produces a deployable bundle (optionally `--serverless`) and `lm build --deploy` / `lm build --serverless --deploy` push it directly.
73
+
74
+ ### 2.1 Which files in THIS repo are production (important)
75
+
76
+ Most of this repo is **old UI kept for reference only**. Do not treat a stale file
77
+ as evidence of current conventions, schema, or stack.
78
+
79
+ **Production-ready:**
80
+
81
+ | Path | Notes |
82
+ |---|---|
83
+ | `src/views/_dashboards/cloudhosting/**` | all files; finalized, Hetzner-Console-style UI |
84
+ | `src/views/_forms/` | only `pay.view`, `domains_create.view`, `cloudhosting_create.view` |
85
+ | `src/views/_lists/` | all |
86
+ | `src/views/modals/` | only `pay`, `done`, `confirm_payment` |
87
+ | `src/views/_account/` | `invoices`, `members`, `profile`, `settings` |
88
+ | `src/views/*.view` | top-level routing shells: `account`, `cloudhosting`, `domains`, `auth`, `dashboard` |
89
+
90
+ **Everything else is stale reference.** Notably `src/views/modals/add_domain.view`
91
+ is NOT production — its "System (Apache) Page" strings contradict the real stack.
92
+
93
+ ### 2.2 Platform facts
94
+
95
+ - Web server is **nginx**. There is no `.htaccess`; per-site rules are generated
96
+ nginx config plus a reload.
97
+ - Each hosting subscription is an **Incus container** ("Roxyon Secure Isolation
98
+ Layer"), so per-account CPU/memory/process/inode metrics are obtainable.
99
+ - The realtime backend is **Swoole over WebSocket**.
100
+ - Production code calls the backend through the app wrappers **`rx.*` / `engine.*`**,
101
+ not the generic `API.*` global documented in §10.
102
+
103
+ ### CLI commands (exhaustive, from the docs)
104
+
105
+ ```bash
106
+ npm i @lmjs/cli -g # install the CLI
107
+ lm create My First Project # scaffold a new project
108
+ lm run # or: lm serve — dev server
109
+ lm create -v myview # scaffold a new top-level view
110
+ lm create -s mysubview # scaffold a sub-view
111
+ lm create -t mytpl # scaffold a template (tpls/ file)
112
+ lm purge # purge offline cache (PWA-style)
113
+ lm build # production build + Node.js server
114
+ lm build --deploy # ...and deploy
115
+ lm build --serverless # production build, no server
116
+ lm build --serverless --deploy # ...and deploy
117
+ ```
118
+
119
+ ---
120
+
121
+ ## 3. Bootstrapping: `Reactor(...)`
122
+
123
+ `index.js` calls a single global function, `Reactor`, once:
124
+
125
+ ```js
126
+ Reactor({
127
+ App: "[main]", // name of the main view component (mount target)
128
+ defaultView: "home", // view shown at "/"
129
+ authView: "login", // view shown when `auth()` returns false
130
+ init: function () {
131
+ // runs once, before first render — do async bootstrap fetches here
132
+ },
133
+ tick: function () {
134
+ // runs after EVERY re-render (any reactive variable change), like a
135
+ // global post-render hook. No dependency array — it just always fires.
136
+ },
137
+ auth: function () {
138
+ return true; // gate for any view whose <settings> declares requireAuth: true
139
+ },
140
+ });
141
+ ```
142
+
143
+ Contrast with Vue/React: there is no equivalent of `createApp().mount()` with a component tree — `Reactor()` *is* the entire app definition. `tick` has no React/Vue analogue; it is closest to a global, un-scoped `useEffect` with no dependency array that runs after *every* DOM patch anywhere in the app.
144
+
145
+ `Reactor` also exposes methods for programmatic control: `render`, `navigate`, `authorize`, `deauthorize` (documented but usage examples were not recovered from the archives).
146
+
147
+ ---
148
+
149
+ ## 4. Three kinds of reusable HTML: views, components, templates
150
+
151
+ LumenJS has **three distinct file kinds**, easy to conflate with Vue/React "components" — they are not interchangeable:
152
+
153
+ | Kind | Folder | Invoked with | Maps to a URL? | Analogue |
154
+ |---|---|---|---|---|
155
+ | **View** | `views/` | automatically, by URL path, or `view="path/to/file"` | Yes (top-level) / No (sub-view) | Vue/React "page" component |
156
+ | **Component** | `comps/` | `<div comp="name" :data="varName"></div>` | Never | A reusable Vue/React component |
157
+ | **Template** | `tpls/` | `<div tpl="name">` — almost always paired with `:for` to render one template per array item | Never | A Vue `<slot>`-less render-prop / list-item partial |
158
+
159
+ ### 4.1 Views and sub-views
160
+
161
+ A `.view` file can freely mix HTML, a `<script>` block, and a `<style>` block — order doesn't matter, and there's no single-file-component wrapper syntax (no `<template>` tag like Vue SFCs — the whole file body up to `<script>`/`<style>` *is* the template).
162
+
163
+ ```html
164
+ <div class="textColor" bind="text">
165
+ This is your text: {{text}}
166
+ </div>
167
+ <script>
168
+ var text = "Hello World !";
169
+ </script>
170
+ <style>
171
+ .textColor { color: green; }
172
+ </style>
173
+ ```
174
+
175
+ A **sub-view** is just another view file nested inside a parent via the `view` attribute:
176
+
177
+ ```html
178
+ <div view="child"></div>
179
+ ```
180
+
181
+ Pass data down to it with `:data` (the child must `bind` the same variable name to render it):
182
+
183
+ ```html
184
+ <div bind="text,childName">
185
+ This is your text: {{text}}
186
+ <div view="child" :data="childName"></div>
187
+ </div>
188
+ <script>
189
+ var text = "Hello World !";
190
+ var childName = "Mike";
191
+ </script>
192
+ ```
193
+
194
+ Inside `child.view`, the passed value shows up as a same-named variable (bound implicitly) — there is no `props` object for views the way there is for components (see below) or modals (see §8).
195
+
196
+ **Per-view settings** — a `<settings>` block (JSON) controls chrome and auth:
197
+
198
+ ```html
199
+ <settings>
200
+ {
201
+ "hasNav": false,
202
+ "hasHeader": false,
203
+ "hasFooter": false,
204
+ "requireAuth": false
205
+ }
206
+ </settings>
207
+ ```
208
+ `hasNav`/`hasHeader`/`hasFooter` accept `true`, `false`, or a string path to a custom widget view. `requireAuth: true` routes unauthenticated visitors to `authView` (via the `Reactor` `auth()` callback).
209
+
210
+ ### 4.2 Components (`comps/*.tpl`, invoked with `comp=`)
211
+
212
+ ```html
213
+ <!-- comps/comp.tpl -->
214
+ <div bind="compName">
215
+ Hello I'm a Component called ~ {{compName}} ~
216
+ </div>
217
+ ```
218
+ ```html
219
+ <!-- calling view -->
220
+ <div bind="compName">
221
+ Below is a Component
222
+ <div comp="comp" :data="compName"></div>
223
+ </div>
224
+ <script>
225
+ var compName = "Mike";
226
+ </script>
227
+ ```
228
+ Functionally very similar to a sub-view invoked with `view=` — the docs themselves describe components as "a multi-used item," e.g. a card used in several places. In practice, real code (Rahimoun) leans on `tpl=` far more than `comp=` for this purpose.
229
+
230
+ ### 4.3 Templates (`tpls/*.tpl`, invoked with `tpl=`)
231
+
232
+ Templates are almost always combined with `:for` to render one instance per collection item, with the loop item's own scope available inside — this is the real workhorse for list rows/cards in production code:
233
+
234
+ ```html
235
+ <!-- tpls/item.tpl -->
236
+ <tbl_nm class="" fixed>
237
+ <rw>
238
+ <cl :for="lstOpts['columns'] as key,cl" :for-if="!cl['hide']">
239
+ <div :if="key=='Status'">{{ statuses[item[key]].name }}</div>
240
+ <div :else>{{ item[key] }}</div>
241
+ </cl>
242
+ </rw>
243
+ </tbl_nm>
244
+ ```
245
+ ```html
246
+ <!-- calling view/template -->
247
+ <li :for="lstOpts['items'] as item; lok" tpl="item"></li>
248
+ ```
249
+
250
+ `tpl` also accepts an **inline attribute-value template literal** convention used pervasively for computed class strings — see §6.5.
251
+
252
+ ### 4.4 The built-in list components: `<lst>` and `<lmlst>`
253
+
254
+ Two engine-provided custom elements exist purely to render paginated/data-bound lists without hand-rolled `:for` markup, seen throughout Rahimoun:
255
+
256
+ ```html
257
+ <lst class="clear" tpl="list" :data="lstOpts"></lst>
258
+ ```
259
+ ```html
260
+ <lmlst @query="queryList" :config="myConfig" />
261
+ ```
262
+ `<lst>` renders via a `tpls/list.tpl` template you supply (itself using `:for`/`:for-if`/`tpl="item"` internally, plus a `<lst-footer>` pager block driven by `lstOpts.page`/`lstOpts.pages`). `<lmlst>` is a newer/alternate variant driven by a `:config` object and a `@query` event instead of a pre-fetched `lstOpts` object. Neither has a public options reference recovered from the docs archive — treat them as app-level list-rendering sugar built on the primitives documented below, not a documented public API surface.
263
+
264
+ ---
265
+
266
+ ## 5. Reactivity
267
+
268
+ ### 5.1 `bind` — declaring what's reactive
269
+
270
+ **This is the single biggest conceptual difference from Vue/React**, and the one most likely to trip up a model that "fills the gap with Vue by default":
271
+
272
+ - In Vue, *any* variable returned from `setup()`/`data()` is reactive everywhere it's used, automatically.
273
+ - In LumenJS, a variable in a view's `<script>` is **not tracked for re-rendering unless some ancestor element carries `bind="thatVariableName"`**.
274
+
275
+ ```html
276
+ <div bind="name">
277
+ <p>My name is: {{name}}</p>
278
+ </div>
279
+ <script>
280
+ var name = "Raouf";
281
+ </script>
282
+ ```
283
+
284
+ #### Rule 1 — reactive state is declared with `var`, never `let` or `const`
285
+
286
+ Any variable named in a `bind=` attribute **must** be declared with `var` in the
287
+ view's `<script>`. This is not a style preference; `let` and `const` are
288
+ block-scoped and are not visible to the runtime's expression evaluator, so a
289
+ `bind`-ed `let` silently never renders and never updates. There is no error.
290
+
291
+ ```html
292
+ <!-- CORRECT -->
293
+ <script>
294
+ var items = [];
295
+ var loading = false;
296
+ </script>
297
+
298
+ <!-- BROKEN — renders nothing, reports nothing -->
299
+ <script>
300
+ let items = [];
301
+ const loading = false;
302
+ </script>
303
+ ```
304
+
305
+ `let`/`const` are fine for variables that are **never** named in a `bind=` and
306
+ never appear in `{{ }}` — loop counters inside a function body, intermediate
307
+ values, and so on. The moment a variable is rendered or bound, it must be `var`.
308
+
309
+ #### Rule 1b — `bind` renders DESCENDANTS only, never its own element
310
+
311
+ The element carrying `bind` does **not** get its own attributes interpolated —
312
+ only the subtree beneath it. So `bind` always goes on the **parent** of whatever
313
+ needs rendering.
314
+
315
+ ```html
316
+ <!-- BROKEN — renders the literal {{nodes[1]}} into the href -->
317
+ <a href="/cloudhosting/{{nodes[1]}}/advanced" bind="nodes">Back</a>
318
+
319
+ <!-- CORRECT — bind on the parent -->
320
+ <flx bind="nodes">
321
+ <a href="/cloudhosting/{{nodes[1]}}/advanced">Back</a>
322
+ </flx>
323
+ ```
324
+
325
+ The bind list controls **when** to re-render, not what is resolvable: a subtree
326
+ under `bind="globals"` can still interpolate `{{nodes[1]}}` fine.
327
+
328
+ #### Rule 1c — never nest a `bind` inside another `bind`
329
+
330
+ A `bind` on a descendant of another `bind` silently stops the subtree rendering;
331
+ interpolations come out empty with no error. Use **sibling** binds for independent
332
+ regions, never nested ones. One bind per region is enough.
333
+
334
+ #### Rule 1d — put `bind` on the lowest element that needs it
335
+
336
+ A bound subtree is re-rendered **wholesale**, so everything inside is rebuilt:
337
+ a Select2 control is re-initialised, an input the user is typing into is emptied,
338
+ a `<select>` loses its selection. Keep static or stateful controls **outside** the
339
+ bound region, and bind only the part that actually changes.
340
+
341
+ #### Rule 1e — `:if` goes on a CHILD of the bound element
342
+
343
+ Never on the bound element itself (any depth below is fine):
344
+
345
+ ```html
346
+ <div bind="alerts">
347
+ <flx :if="alerts.length > 0"> … </flx>
348
+ </div>
349
+ ```
350
+
351
+ Give such a wrapper `display: contents` so it does not become a flex item and
352
+ introduce a stray gap.
353
+
354
+ #### Rule 2 — every rendered variable needs a `bind` ancestor
355
+
356
+ `bind` takes a **comma-separated list** of variable names: `bind="items,index"`.
357
+ Nothing is inferred from usage inside `{{ }}` — every top-level variable you
358
+ mutate and expect to re-render must be listed explicitly on an enclosing
359
+ element.
360
+
361
+ Omitting `bind` is the single most common failure mode when writing LumenJS
362
+ with Vue habits: the initial value renders once and then never updates again,
363
+ with no console error and no visible symptom until a user reports stale data.
364
+ If a value appears in `{{ }}` or drives a `:if` / `:for`, it belongs in a
365
+ `bind`.
366
+
367
+ One `bind` on a wrapper covering the whole reactive region is idiomatic;
368
+ scattering many small `bind`s is not:
369
+
370
+ ```html
371
+ <div bind="items,loading,filters">
372
+ <div :if="loading">Loading…</div>
373
+ <div :for="items as item">{{ item.Name }}</div>
374
+ </div>
375
+ ```
376
+
377
+ Contrast with Vue: there is no reactive-proxy wrapping of the variable itself —
378
+ mutating `items.push(x)` works because LumenJS re-diffs the DOM subtree under
379
+ the nearest `bind` ancestor, not because the array became a Proxy. Equally,
380
+ **reassigning** the whole variable (`items = newArray`) is fine and is the
381
+ normal pattern after a fetch; Vue's `.value` / immutable-update rules do not
382
+ apply.
383
+
384
+ ### 5.2 `{{ }}` interpolation, and the backtick variant
385
+
386
+ Standard interpolation is `{{ expression }}` — this works in text nodes and (unlike Vue) directly inside quoted attribute values:
387
+
388
+ ```html
389
+ <p>My name is: {{ Ourarray[`Ourindex`] }}</p>
390
+ ```
391
+ Note the inner variable-in-a-variable syntax: to index an array/object with another *variable's* value inside `{{ }}`, wrap the index variable in backticks: `{{ Ourarray[\`Ourindex\`] }}`. This is **not** a JS template literal — it's LumenJS's own escape hatch inside the moustache parser to disambiguate "the literal key `Ourindex`" from "the value of the variable `Ourindex`."
392
+
393
+ A **second, different** backtick convention shows up in real production attribute values — wrapping an *entire* `{{ }}` expression (not just a sub-key) in backticks, seen extensively for computed class/style strings:
394
+
395
+ ```html
396
+ <cl class="`{{lstOpts['columns'][key]['_mini'] ? 'lst-cl-mini' : 'lst-cl'}}`">
397
+ ```
398
+ This is the safe way to interpolate an expression that itself contains quotes (ternaries with string literals) into an already-quoted attribute — plain `{{ }}` works for simple attribute interpolation, but reach for the backtick-wrapped form once the expression contains its own `'single quotes'`.
399
+
400
+ ### 5.2b Function calls inside `{{ }}` are not re-evaluated
401
+
402
+ A function call in a moustache renders once and then goes **stale** — it is not
403
+ re-run when the subtree re-renders. Compute into a plain `var` and render that,
404
+ recomputing wherever the state changes:
405
+
406
+ ```html
407
+ <!-- stale after the first render -->
408
+ <pre>{{buildNginx()}}</pre>
409
+
410
+ <!-- correct -->
411
+ <pre>{{confText}}</pre>
412
+ ```
413
+ ```js
414
+ function commit() {
415
+ rules = rules.slice();
416
+ confText = buildNginx(); // recompute on every mutation
417
+ }
418
+ ```
419
+
420
+ ### 5.3 `watch` — the global watcher object
421
+
422
+ There is no per-component `watch()` call. There is exactly **one** global object, `watch`, keyed by variable name:
423
+
424
+ ```js
425
+ watch = {
426
+ myVariable: function () {
427
+ // fires whenever `myVariable`'s value changes (deep — including nested object mutation)
428
+ },
429
+ };
430
+ ```
431
+ **Gotcha (explicitly called out in the docs):** `watch = {...}` is a plain object *assignment*, so declaring it twice **overwrites** the first set of watchers entirely — there's no merging. Two safe patterns:
432
+ ```js
433
+ // 1. one object literal, multiple keys
434
+ watch = {
435
+ myKey: function () { /* ... */ },
436
+ myKey2: function () { /* ... */ },
437
+ };
438
+ // 2. property assignment, anywhere, any number of times
439
+ watch["myKey3"] = function () { /* ... */ };
440
+ ```
441
+ This is a sharp edge with no Vue/React equivalent — Vue's `watch: {}` option and multiple `watch()` calls never clobber each other like this.
442
+
443
+ ### 5.4 `tick()` — global post-render hook
444
+
445
+ Declared once, inside `Reactor({...})` (§3). Fires after *every* re-render anywhere in the app — there's no scoping, no dependency array, and no per-component equivalent.
446
+
447
+ ### 5.5 `nodes` — the URL, pre-split
448
+
449
+ ```js
450
+ alert(nodes); // e.g. ["guide", "global-variables", "nodes"]
451
+ ```
452
+ `nodes` is the URL path split on `/`. **`nodes[0]` is always the main/top-level view name.** Nested sub-view routing (e.g. `views/_guide/*.view` dispatching on `nodes[1]`) is done by hand, by `bind`-ing `nodes` and rendering `<div view="_guide/{{nodes[1]}}/{{nodes[2]}}"></div>` with a default fallback:
453
+ ```html
454
+ <div bind="nodes">
455
+ <div view="_guide/{{nodes[1]}}/{{nodes[2]}}"></div>
456
+ </div>
457
+ <script>
458
+ if (!nodes[2]) nodes[2] = "structure";
459
+ </script>
460
+ ```
461
+ This is the entirety of LumenJS's router — **there is no route-config file, no `<Route>` component, no `useParams()`.** Nesting is just string-building a `view=` path out of `nodes[n]` segments yourself.
462
+
463
+ ### 5.6 `globalWatch()` — re-scanning DOM you inserted yourself
464
+
465
+ When you build HTML as a string and drop it in with `$(sel).html(...)`, `.append(...)`,
466
+ `el.insertAdjacentHTML(...)` etc., LumenJS **does not know it happened**. The engine
467
+ only scans on its own render passes. Anything in that fragment that relies on the
468
+ engine wiring something up stays dead until you call the global **`globalWatch()`**.
469
+
470
+ `globalWatch()` (defined in `bea.js`) runs, in order: re-executes any
471
+ `<script type="beajs">` blocks → binds every `[bind]:not(.binded)` → then
472
+ `renderViewsTpls()` (`[view]` / `[tpl]` / `[comp]`) → `renderPlugins()` → `loadPics()`
473
+ (`[lazy]`) → `reloads()` → `tick()`.
474
+
475
+ `renderPlugins()` is the important one — it is what initialises:
476
+
477
+ | selector | becomes |
478
+ |---|---|
479
+ | `[sl]:not(.select2-hidden-accessible)` | a Select2 control |
480
+ | `form:not(.binded,[norm])` | an `o-sub` / `b-sub` intercepted form |
481
+ | `[crslf]:not(.flickity-enabled)` / `[crsl]:not(.crsl-initialized)` | a carousel |
482
+ | `[up]:not([upid])` | a file-drop uploader (injects the hidden `<input type=file>`) |
483
+
484
+ Every one of those has a `:not(...)` guard, so **`globalWatch()` is idempotent** —
485
+ calling it again never double-initialises what's already live. Call it once, after
486
+ the DOM insert:
487
+
488
+ ```js
489
+ $('.host').html('<select sl sl-prt="self" name="zone">' + opts + '</select>');
490
+ globalWatch(); // now it's a Select2, not a bare <select>
491
+ ```
492
+
493
+ **What does NOT need `globalWatch()`:** `@click` / `@change` / other `@event`
494
+ directives in injected HTML already work — the engine delegates events from
495
+ `document`, so a handler on a `<span @click="fn">` fires even though the span was
496
+ added after render (this also survives Iconify swapping the `<span class="iconify">`
497
+ for an `<svg>`). It is only the *plugin-wrapping* attributes above, plus `[bind]`
498
+ and `{{ }}` interpolation, that need the re-scan.
499
+
500
+ **Iconify** specifically: `<span class="iconify" data-icon="…">` is picked up by
501
+ Iconify's own `MutationObserver`, so it usually renders on its own — but after a big
502
+ `innerHTML`/`outerHTML` swap call `Iconify.scan()` (guarded: `window.Iconify && Iconify.scan()`)
503
+ if an icon fails to appear. **Do not `addClass`/`removeClass` an iconify element to change
504
+ its state** (e.g. a green "on" class): Iconify replaces the `<span>` with a freshly built
505
+ `<svg>` and re-copies the *placeholder's original* class string, so a class you added a tick
506
+ earlier is silently dropped. Toggle the class on a non-iconify wrapper, or use a plain
507
+ element for the stateful control (a CSS pill switch, etc.).
508
+
509
+ ---
510
+
511
+ ## 6. Directives — exhaustive reference
512
+
513
+ Attribute-directive syntax uses two prefixes, both borrowed visually from Vue: `:` for data/structural bindings, `@` for events. **Do not assume Vue semantics beyond the name** — cross-reference every entry below.
514
+
515
+ ### 6.1 `:for` — loops (contrast: Vue `v-for`)
516
+
517
+ | Vue `v-for` | LumenJS `:for` |
518
+ |---|---|
519
+ | `v-for="item in items"` | `:for="items as item"` |
520
+ | `v-for="(item, i) in items"` | `:for="items as item; i"` (custom index name after `;`) |
521
+ | `v-for="(val, key) in obj"` | `:for="myObject as key, value"` |
522
+ | `v-for="n in 5"` (Vue supports N too) | `:for="5"` |
523
+ | implicit loop var `item` name required | **default item/index names exist**: bare `:for="items"` (no `as`) still gives you `{{ number }}`-style item fields directly in scope, plus a magic `index` variable, with no `as` clause at all |
524
+ | key required (`:key`) | no key attribute exists or is needed |
525
+
526
+ Forms, exhaustively:
527
+ ```html
528
+ <li :for="items">The number is: {{number}}</li> <!-- array of objects, no alias -->
529
+ <div :for="3">Item {{index}}</div> <!-- fixed repeat count, no data -->
530
+ <li :for="cartItems as item">{{ item }}</li> <!-- array, aliased -->
531
+ <rw :for="myData as item">...</rw> <!-- array of objects, aliased -->
532
+ <li :for="myObject as key, value">{{ key }}: {{ value }}</li> <!-- object iteration -->
533
+ <li :for="newItems as item">{{ index + 1 }}. {{ item }}</li> <!-- magic `index`, 0-based -->
534
+ <li :for="newItems as item; myIndex">{{ myIndex + 1 }}. {{ item }}</li> <!-- custom index name -->
535
+ ```
536
+
537
+ **Attributes on the `:for` element itself need backticks.** Interpolation in an
538
+ attribute of the looping element only resolves when the moustache is wrapped in
539
+ backticks — that is what tells the engine to evaluate it in loop scope. Object-key
540
+ iteration uses **double** backticks:
541
+
542
+ ```html
543
+ <div :for="arrayOfElements as kuku" data-id="`{{kuku + ' ' + index}}`">
544
+ <div :for="ObjectData as k,v" data-id="``{{k}}``">
545
+ ```
546
+
547
+ Without the backticks the attribute renders empty. (Descendants of the loop element
548
+ interpolate normally, so the alternative is to put the loop on a bare wrapper and
549
+ the interpolated attributes on an inner element.)
550
+
551
+ **Never put `:for` inside a form control or anything holding live state.** A V1
552
+ re-render rebuilds the subtree and destroys that state — a `<select>` loses its
553
+ selection, and an input the user is mid-way through typing into is emptied. Build
554
+ those options as an HTML string in JS and inject them with `.html()`, or write them
555
+ out literally in the markup:
556
+
557
+ ```js
558
+ // idiomatic: see modals/email_create.view
559
+ $('select[name="Domain"]').html(opts.join(''));
560
+ ```
561
+
562
+ **Nested `:for` does not resolve the inner loop variable.** An inner loop inside an
563
+ outer one renders the right number of rows but every interpolation comes out empty
564
+ (the DOM shows `clone="x"` with blank values). Build nested structures as an HTML
565
+ string in JS and inject them, as `sites.view`'s `gridRender` and
566
+ `applications.view` do.
567
+
568
+ `:for` is also **slow for large loops** in V1 — prefer building rows as an HTML
569
+ string (as `lmlst`'s `gridRender` does) for anything long.
570
+
571
+ A `:for` array does **not** need to be in a `bind` unless it changes; bind it so the
572
+ DOM updates when it does.
573
+
574
+ **Loop modifiers** (siblings of `:for` on the same element — no Vue equivalent at all):
575
+ ```html
576
+ <li :for="items2 as item"
577
+ :for-limit="5" <!-- render at most 5 -->
578
+ :for-offset="2" <!-- skip the first 2 -->
579
+ :for-if="item.value > 10"> <!-- per-item conditional filter, applied before limit/offset -->
580
+ {{ item.key }}: {{ item.value }}
581
+ </li>
582
+ ```
583
+
584
+ ### 6.2 `:if` / `:else-if` / `:else` — conditionals (contrast: Vue `v-if`/`v-else-if`/`v-else`)
585
+
586
+ Naming and chaining semantics are essentially identical to Vue (adjacent-sibling chain, first true branch wins, `:else` has no value):
587
+ ```html
588
+ <button :if="!isLoggedIn">Login</button>
589
+ <p :if="age < 18">...</p>
590
+ <p :else-if="age < 21">...</p>
591
+ <p :else>...</p>
592
+ ```
593
+ Unlike React's `{cond && <X/>}` or `? :`, and like Vue, these are DOM-structural — the element (and, per the engine's `refItem`/`getRefTree` logic, matching `:for` blocks) is only materialized when true, not merely hidden.
594
+
595
+ ### 6.3 Events — `@eventName="handler"` (contrast: Vue `@click`, React `onClick`)
596
+
597
+ Syntax matches Vue exactly: `@event="functionName"` (calls a global function) or `@event="inline js expression"`. **Handler signature differs from both Vue and React**: every handler receives `(ev, el)`:
598
+ - `ev` — the native-ish event object (`ev.type` tells you which concrete event fired for multiplexed handlers like drag/scroll/sort, see below).
599
+ - `el` — **a jQuery-wrapped element**, not a raw DOM node and not a component-ref. Use `el.css(...)`, `el.data(...)`, `el.val()`, etc. Get the raw DOM node with `el[0]`.
600
+
601
+ ```html
602
+ <button @click="handleButtonClick">Click me</button>
603
+ <script>
604
+ function handleButtonClick(ev, el) { alert("clicked"); }
605
+ </script>
606
+ ```
607
+
608
+ **Full event catalogue** (exhaustive, gathered from docs + engine):
609
+
610
+ *Click/tap:*
611
+ `@click`, `@dblclick`, `@clickhold`, `@click2` (two-finger click), `@click2hold`, `@singleclick` (debounced single click), `@tap`, `@dbltap`, `@taphold`, `@tap2`, `@tap2hold`, `@singletap`
612
+
613
+ *Swipe / orientation:*
614
+ `@swipe`, `@swipe-left`, `@swipe-right`, `@swipe-up`, `@swipe-down`, `orientationchange` (window-level, not per-element)
615
+
616
+ *Mouse:*
617
+ `@mouse-over`, `@mouse-enter`, `@mouse-move`, `@mouse-leave`, `@mouse-out`, `@mouse-up`, `@mouse-down` — note the **hyphenated** form (not `@mouseover` camelCase like a native DOM listener, and not Vue's `@mouseover`)
618
+
619
+ *Scroll:*
620
+ `@scroll`, `@scroll-start`, `@scroll-end`, `@scroll-bottom`, `@scroll-top`, `@scroll-right`, `@scroll-left` (the last four fire once when the scroll container hits that edge)
621
+
622
+ *Drag (requires the `drag` attribute on the element, see §6.4):*
623
+ `@drag` (continuous, while dragging), `@drag-start`, `@drag-stop`
624
+
625
+ *Sort (requires the `sort` attribute on the container, see §6.4):*
626
+ `@sort` (fires per reorder), `@sort-start`, `@sort-stop`, `@sort-change` (live, during drag), `@sort-update` (final, on drop) — handlers for the `-start/-stop/-change/-update` variants receive a third argument `ui` with `ui.originalPosition.{left,top}`
627
+
628
+ *Forms:* standard `@change`, `@blur`, `@focus`, `@keydown`, `@keyup`, `@keypress` (native-named, unlike the hyphenated mouse events)
629
+
630
+ *File drop / upload (see §6.6):* `@drop`, `@file-removed`, `@file-uploaded`, `@files-added`, `@upload-progress`
631
+
632
+ *Modal (see §8):* `@modal-open`, `@modal-close`, `@modal-beforeclose`
633
+
634
+ *Select2 wrapper (see §6.7):* `@sl-select`, `@sl-unselect`
635
+
636
+ *Custom list widget:* `@query` (used by `<lmlst>`, §4.4)
637
+
638
+ ### 6.4 Draggable / sortable
639
+
640
+ No attribute value is required to turn on the behavior — presence of the bare attribute is enough:
641
+ ```html
642
+ <div drag class="grab">Drag me</div>
643
+ <div drag drag-revert="false">Drag me!</div> <!-- default true: snaps back after drop -->
644
+
645
+ <ul sort>
646
+ <li :for="4">{{index+1}}</li>
647
+ </ul>
648
+ ```
649
+ Combine with the drag/sort events from §6.3 to react to movement.
650
+
651
+ ### 6.5 Modals
652
+
653
+ See §8 for the full write-up; attribute summary:
654
+ `modal="path/to/subview"`, `modal-class="css-class"`, `modal-title="..."`, `modal-description="..."`, `close-modal` (bare attribute on any element inside the modal, closes it like the X button).
655
+
656
+ CSS targeting hooks for the modal chrome (Mobius CSS classes, not directives, but only meaningful in this context): `pop-c` (content), `pop-x` (close X), `pop-h` (header), `pop-t` (title), `pop-d` (description), `pop-b` (body).
657
+
658
+ ### 6.6 Forms — `o-sub`, `b-sub`, `norm`
659
+
660
+ **There is no `:model`/`v-model` two-way-binding directive in LumenJS.** Forms are handled wholesale on submit, not field-by-field:
661
+
662
+ | Attribute | Behavior |
663
+ |---|---|
664
+ | *(none — default)* | Submitting navigates to `action` (if present) or stays on the same view; input values land in `View.props` (or `View.params` if `method="get"`, as a query string) of the target view. Works with zero JS. |
665
+ | `o-sub="fnName"` | Fires `fnName(f, d)` **on submit**, where `f` is the form node and `d` is an object of `{name: value}` for every input. **Mutually exclusive with `action`/`method`/`target`** — combine with `pushURL(...)` inside the handler to navigate (see §9.2). |
666
+ | `b-sub="fnName"` | Fires `fnName(f, d)` **before** submit; `return false` cancels the submission (validation gate) — otherwise behaves like a normal form (respects `action`). |
667
+ | `norm` (bare) | Opts the `<form>` back out of all LumenJS interception — plain full-page-reload HTML form behavior. |
668
+
669
+ ```html
670
+ <form o-sub="onSubmit">
671
+ <input type="text" name="name">
672
+ <input type="submit" value="submit">
673
+ </form>
674
+ <script>
675
+ function onSubmit(f, d) {
676
+ pushURL("/view-example", { "data": d });
677
+ }
678
+ </script>
679
+ ```
680
+
681
+ Field-level validation: add `:required` to an `<input>`/`<select>`/`<textarea>` — the engine adds a `.required` class to the closest `<label>` and blocks submission (adding a `.validation-error` class to the form) if empty. This is the closest thing to Vue/React form validation primitives that exists — there's no per-field custom-validator directive beyond this boolean required check plus your own `b-sub` logic.
682
+
683
+ ### 6.7 Select2 wrapper (`sl-*`)
684
+
685
+ A large family of attributes wraps the Select2 jQuery plugin onto `<select>`-like elements. Confirmed from the engine: `sl-id`, `sl-value` (preselect a value), `sl-text`, `sl-query` (name of a global function used as a custom async data source), `sl-class`, `sl-dir`, `sl-min`/`sl-mins` (`minimumResultsForSearch`, default 10 — set high to hide the search box), `sl-nrmsg` (no-results message), `sl-ntgs` (allow free-text tags), `sl-prt` (dropdown parent selector, or `"self"` → the select's `.parent()`; default `"body"`), `sl-nosrch`/`nosearch` (disable search box), plus events `@sl-select`/`@sl-unselect`. Treat this family as "Select2 configuration surface," not core reactivity — there is no dedicated guide page recovered for it; behavior above is reconstructed from the compiled engine.
686
+
687
+ **Verified from the compiled wrapper (`bea.js`):**
688
+
689
+ - The init selector is `$("[sl]:not(.select2-hidden-accessible)")`, run inside
690
+ `renderPlugins()`. A `[sl]` select you inject with jQuery/vanilla stays a raw
691
+ browser control until the next `globalWatch()` (§5.6) — or init it by hand,
692
+ `$(sel).select2({ dropdownParent: $(sel).parent(), ... })`, guarded by the same
693
+ `:not(.select2-hidden-accessible)` check.
694
+ - **Clear button is opt-in via the bare `clear` attribute** (changed 2026-08-31;
695
+ was hardcoded `allowClear:true` before). `<select sl>` → no clear ×;
696
+ `<select sl clear>` → shows it, styled in the Console as a solid dark-red (`#900`)
697
+ square button (`styles.css`, `.select2-selection__clear`). Only add `clear` when
698
+ an empty/unset value is actually meaningful for that field.
699
+ - After replacing a select's `<option>`s with `$(sel).html(opts)`, call
700
+ `$(sel).trigger('change')` so the Select2 display re-reads them.
701
+
702
+ ### 6.8 DOM utility / class-toggle directives (no Vue/React equivalent at all)
703
+
704
+ These exist because LumenJS has no component-local state for simple UI toggles (open/closed menus, active tabs) — instead you point one element at another by CSS selector:
705
+
706
+ | Attribute | Behavior |
707
+ |---|---|
708
+ | `t="<selector>"` + `tcn="<class>"` | On click/touch of the element carrying `t`, **toggle** class `tcn` (default `"active"`) on the element(s) matched by the selector in `t`. Also toggles `.toggled` on the trigger itself. Seen used for mobile-menu hamburgers: `<div t=".mobileMenu,.header__btnmenu" tcn="active">`. |
709
+ | `acl="<selector>"` + `aclcn="<class>"` (add-class), `rcl="<selector>"` + `rclcn="<class>"` (remove-class) | Same idea, but split into an explicit add-target and remove-target instead of a single toggle target — used when opening one panel must simultaneously close a different one. |
710
+ | `actv` (value = selector or empty) | On click, removes `.active` from the selector's matches (if given) and adds `.active` to itself. A crude "only one of these is active" pattern with **no data model backing it** — purely a DOM class effect. |
711
+ | `actvt` (value = selector or empty) | Same as `actv` but *toggles* rather than unconditionally activating (won't re-activate an already-active element). |
712
+ | `stop` (bare) | Calls `event.stopPropagation()` for the triggering interaction — an escape hatch for nested `t`/`acl`/`actv` elements. |
713
+ | `confirm="message"` / `econfirm="js expr"` | Native `confirm()` gate before the element's action proceeds; `econfirm` evaluates a JS expression instead of a literal string. |
714
+
715
+ ### 6.9 Navigation active-state matching: `n0`, `n1`, `n2`, …
716
+
717
+ ```html
718
+ <a href="/guide" n0="guide">Guide</a>
719
+ ```
720
+ The numeric suffix corresponds to the `nodes[n]` depth (§5.5) — the engine compares `nodes[N]` against the attribute's value to decide whether to mark that link/nav-item as the active one. This is LumenJS's entire "active nav link" story; there's no `<router-link active-class>` — you hand-annotate every link with which URL segment and value it corresponds to.
721
+
722
+ ### 6.10 Misc single-purpose attributes
723
+
724
+ | Attribute | Behavior |
725
+ |---|---|
726
+ | `lazy` + `data-src="url"` | Lazy-loads a background/image source once in viewport (also `data-srcset`, `data-sizes` for responsive variants). |
727
+ | `tip="text"`, `tip-pos="top\|...`, `tip-class="..."` | Built-in tooltip. |
728
+ | `pinnable`, `pinnable-offset-up`, `pinnable-offset-down` | Sticky/pin-on-scroll behavior with configurable trigger offsets. |
729
+ | `crslf` + `crslf-opts='{...}'` | Turns a container into a carousel (Flickity-based). Options object (all optional, defaults noted): `draggable` (true), `freeScroll` (false), `wrapAround` (false), `groupCells` (1), `autoPlay` (false), `fullscreen` (false), `fade` (false), `adaptiveHeight` (false), `cellAlign` ("center"), `contain` (false), `rightToLeft` (true), `prevNextButtons` (true), `pageDots` (true). |
730
+ | `up` (bare, on a container) + `up-accept="mime/types"` | Marks a container as a file-uploader drop zone (auto-injects a hidden `<input type="file">`); pair with the `@drop`/`@files-added`/`@file-uploaded`/`@file-removed`/`@upload-progress` events (§6.3). |
731
+ | `:hover="css-declarations"` | Applies raw inline CSS text (`"color:red;font-weight:bold;"`) on hover/touch-move, removes it on leave — a directive-level substitute for a CSS `:hover` pseudoclass, used because scoped `<style>` inheritance across sub-views is otherwise all-or-nothing. |
732
+ | `:required` | See §6.6. |
733
+ | `:data` | See §4.1/§4.2 — passes a variable to a sub-view/component. |
734
+ | `:config` | Passes a config object, used by `<lmlst>` (§4.4). |
735
+ | `:props`, `:props-bind`, `:lookup-callback` | Present in the engine's attribute table; no worked examples survive in the docs archive. Likely component-prop-declaration and custom-datasource-callback hooks respectively — flagged here for completeness, use with caution/verify against engine behavior before relying on them. |
736
+
737
+ ---
738
+
739
+ ## 7. Global built-in objects and functions
740
+
741
+ None of these need an import — they are ambient globals, available in every view's `<script>` block.
742
+
743
+ | Name | What it is |
744
+ |---|---|
745
+ | `globals` | A plain object, **auto-persisted to `localStorage`** and reactive when `bind`-ed. Add/edit: `globals.data = {...}`. Delete: `delete globals.data`. This is LumenJS's entire global-store story — no actions/mutations/reducers, just direct property assignment. |
746
+ | `session` | Same API surface as `globals` (`session.key = value`, `delete session.key`), backed by `sessionStorage` instead of `localStorage` (cleared when the tab/browser closes). |
747
+ | `cookies` | Cookie helper object: `cookies.setItem(key, value, expiry, path, domain, secure)`, `cookies.getItem(key)`, `cookies.removeItem(key)`, `cookies.hasItem(key)`, `cookies.keys()`. |
748
+ | `nodes` | URL path split on `/`, see §5.5. |
749
+ | `View.params` | Query-string data (from `<a href="/x?name=Hana">` or `method="get"` forms) available in the *target* view. |
750
+ | `View.props` | Data pushed via `pushURL(url, data)` or a submitted `o-sub`/`b-sub`/default form, available in the target view. |
751
+ | `pushURL(path, data?)` | Programmatic SPA navigation (no full reload). Second argument populates `View.props` on arrival. This plus `<a href>` and `<form>` are the **entire routing API** — see §9. |
752
+ | `watch` | Global watcher object, §5.3. |
753
+ | `API.get/post/put/delete/patch(path, data, headers, api_index, opts)` | Built-in HTTP client, see §10. |
754
+ | `LIVE` | Global WebSocket client (`new _Live("wss://...")`) with an `_emit(event, payload, isBinary)` method returning a promise — used internally for realtime features (e.g. live production counters on lumenjs.com itself, and chunked file uploads) and available for app-level realtime channels. No public guide page for it survived in the archives; treat it as an advanced/internal-facing API. |
755
+ | `cl(...)` | Seen throughout real code as a `console.log` shorthand — appears to be a project/engine convenience, not `console.log` itself. |
756
+
757
+ ---
758
+
759
+ ## 8. Modals in depth
760
+
761
+ A modal renders a sub-view's body inside a popup, without a route change or losing the parent view's state.
762
+
763
+ ```html
764
+ <button modal="modal_view"
765
+ modal-class="my-modal"
766
+ modal-title="Title Here"
767
+ modal-description="Optional subtitle">
768
+ Open
769
+ </button>
770
+ ```
771
+ Any element (not just buttons) with a `modal` attribute opens that popup on click. Structure (targetable via Mobius classes, not directives): content `pop-c`, close-X `pop-x`, header `pop-h`, title `pop-t` (default text "New Modal"), description `pop-d`, body `pop-b`.
772
+
773
+ Close it from inside with a bare `close-modal` attribute on any descendant element, equivalent to clicking the X.
774
+
775
+ **Multiple/nested modals**: give each trigger its own `modal-class` to scope CSS (`.my-modal-a pop-c { ... }`); a modal's own body can contain further `modal=` triggers, opening a second modal stacked on the first.
776
+
777
+ **Events**: `@modal-open`, `@modal-close`, `@modal-beforeclose` on the *trigger* element. Handlers receive `(ev, el, modal)`. `@modal-beforeclose` can be used to gate closing (e.g. prompt-before-discard).
778
+
779
+ **Passing data parent → modal**: return an object from the `@modal-open` handler; inside the modal view, retrieve it via `view.scope`:
780
+ ```js
781
+ // parent view
782
+ function myFunc(ev, el, modal) {
783
+ return { key1: "hello", key2: 1234 };
784
+ }
785
+ ```
786
+ ```js
787
+ // modal view
788
+ var myData;
789
+ view.scope(function (view, props) {
790
+ myData = props.modal.key2;
791
+ });
792
+ ```
793
+ **Passing data modal → parent**: mutate `props.modal` from inside the modal's `view.scope` callback — new keys you add become visible to the parent's context.
794
+
795
+ This `view.scope(function(view, props){...})` pattern is the closest LumenJS gets to a component "props" object — it is **only** used for modals, not for ordinary sub-views (§4.1) or components (§4.2), which instead rely on implicit same-name variable binding via `:data`.
796
+
797
+ ---
798
+
799
+ ## 9. Routing
800
+
801
+ There is no router library, no route-config file, and no `<Route>`/`useNavigate()` — routing is three primitives, all covered above:
802
+
803
+ 1. **`<a href="/path?query=val">`** — plain anchor; LumenJS intercepts the click, swaps the view without a full reload. Query-string data lands in the target view's `View.params`.
804
+ 2. **`pushURL(path, data?)`** — programmatic equivalent; `data` lands in `View.props` (not `View.params`) on arrival.
805
+ 3. **`<form>`** — see §6.6 for the full `o-sub`/`b-sub`/`norm` matrix; default (no special attribute) behavior still avoids a full reload and populates `View.props`/`View.params` on the target view.
806
+
807
+ File-system nesting: a URL like `/account/admin/dashboard` maps directly to `views/account/admin/dashboard.view` — there is no separate manifest mapping URLs to files (except where a view deliberately re-dispatches on `nodes[n]` itself, §5.5, for guide-style catch-all sections).
808
+
809
+ ---
810
+
811
+ ## 10. HTTP / data fetching
812
+
813
+ Configured once in `config.json`:
814
+ ```json
815
+ {
816
+ "node": { "host": "127.0.0.1", "port": "3000" },
817
+ "apis": [
818
+ {
819
+ "baseURL": "YOUR_API_URL",
820
+ "headers": {
821
+ "X-BEA-Application-ID": "YOUR_APPLICATION_ID",
822
+ "X-BEA-Authorization": "YOUR_REST_API_KEY"
823
+ }
824
+ }
825
+ ]
826
+ }
827
+ ```
828
+ Then, anywhere, the global `API` object:
829
+ ```js
830
+ API.get("path", data, headers, api_index)
831
+ .then((data) => { /* ... */ })
832
+ .catch((err) => { /* ... */ });
833
+
834
+ API.post("path", data, headers, api_index).then(...).catch(...);
835
+ API.put("path/" + objectId, data, headers, api_index).then(...).catch(...);
836
+ API.delete("path/" + objectId, data, headers, api_index).then(...).catch(...);
837
+ ```
838
+ - `path` — a full URL, or just a table/resource name if `config.json` already has a matching `baseURL` configured.
839
+ - `data` — request body / query object.
840
+ - `headers` — normally omitted (pulled from `config.json`); override per-call if needed.
841
+ - `api_index` — which entry in the `apis[]` array to use, if you configured more than one backend.
842
+
843
+ In real production code (Rahimoun), apps commonly wrap a second, app-specific HTTP client (there called `engine`, an instance of a BEA-provided `Engine` class configured with app/API keys) alongside or instead of the generic `API` global — `API` is the documented, generic primitive; `engine`/`Engine` is that particular backend's SDK layered on top, not a LumenJS core concept.
844
+
845
+ ---
846
+
847
+ ## 10b. The Roxyon BaaS query DSL
848
+
849
+ Everything in §10 covers *how* to make a request. This section covers the
850
+ **request body**, which is where nearly all real Console/Rahimoun page logic
851
+ lives.
852
+
853
+ > **Do not invent query syntax.** This DSL is not Parse, not Firebase, not
854
+ > MongoDB, and not SQL — it resembles all of them in places and differs in the
855
+ > details. Every operator, key, and idiom below is exhaustive for the common
856
+ > path; if something you want is not listed here, it probably does not exist,
857
+ > and inventing it produces a query that returns *plausible wrong data* rather
858
+ > than an error.
859
+
860
+ ### 10b.1 Mental model
861
+
862
+ A **class** is a MySQL table; an **object** is a row. `objectId` is a Hashids
863
+ string, never the raw integer. There are **no foreign keys** — a pointer column
864
+ holds the target row's `objectId` as a plain string, and the relationship only
865
+ exists in the schema metadata.
866
+
867
+ ```js
868
+ API.get("/Patients", { fields: "objectId,firstName,lastName", limit: 50 })
869
+ ```
870
+
871
+ The response is always:
872
+
873
+ ```js
874
+ { results: [ {...}, {...} ] } // plus "count" when count:1 is set
875
+ ```
876
+
877
+ ### 10b.2 Body keys
878
+
879
+ | Key | Purpose |
880
+ |---|---|
881
+ | `fields` | Comma-separated columns to select. Supports aggregates: `"Count(*) as c,Gender"` |
882
+ | `where` | Filter object — see §10b.3 |
883
+ | `limit` | Row cap. **`-1` does NOT mean unlimited — it means 1000.** Omitting it defaults to 10 |
884
+ | `offset` | Skip N rows (pagination) |
885
+ | `order` | `"createdAt"` ascending, `"-createdAt"` descending |
886
+ | `groupby` | Comma-separated columns; `"none"` collapses to a single aggregate row |
887
+ | `having` | Filter on aggregate results |
888
+ | `count` | `1` adds a `count` key to the response. Pair with `limit: 0` for a count with no rows |
889
+ | `include` | Resolve a pointer to its target row — see §10b.4 |
890
+ | `locale` | `"en,ar"` to pull localized columns |
891
+ | `media` | `"images"`, `"files"` — attach media sub-records |
892
+ | `crops` | `"ax300,ax600"` — which image crop sizes to return |
893
+ | `_then` | Chain a follow-up request — see §10b.6 |
894
+ | `on_duplicate` | On POST: what to do if the row already exists — see §10b.6 |
895
+
896
+ ### 10b.3 `where` and its operators
897
+
898
+ A bare value means equality:
899
+
900
+ ```js
901
+ where: { Status: "completed" }
902
+ ```
903
+
904
+ An object means explicit operators:
905
+
906
+ ```js
907
+ where: {
908
+ Status: { in: "new,inreview" },
909
+ createdAt: { gte: "2026-07-01", lte: "2026-07-31" },
910
+ Name: { contains: "حسن" }
911
+ }
912
+ ```
913
+
914
+ **The complete operator list.** Anything not here does not exist:
915
+
916
+ | Operator | Meaning |
917
+ |---|---|
918
+ | `eq` / `ne` | equals / not equals (aliases: `equals`, `notequals`, `neq`) |
919
+ | `gt` `gte` `lt` `lte` | numeric and date comparison |
920
+ | `in` / `nin` | in / not in a comma-separated list |
921
+ | `contains` / `notcontains` | substring match |
922
+ | `startswith` / `notstartswith` | prefix match |
923
+ | `endswith` / `notendswith` | suffix match |
924
+ | `regex` (alias `REGEXP`) | regular expression |
925
+ | `month` / `year` | match a date part; accepts the literal `"current"` |
926
+ | `exists` | pointer is / is not set |
927
+ | `arrayKeyIn` / `arrayKeyAll` | match inside a `pointerArray` / `array` column |
928
+
929
+ Relative dates have dedicated operators — prefer them over building a literal
930
+ range, because they cannot go stale:
931
+
932
+ ```js
933
+ where: { createdAt: { month: "current" } } // this month
934
+ where: { createdAt: { year: "current" } } // this year
935
+ ```
936
+
937
+ #### OR groups
938
+
939
+ ```js
940
+ where: {
941
+ or: [
942
+ { Status: "new" },
943
+ { Severity: "urgent" }
944
+ ]
945
+ }
946
+ ```
947
+
948
+ #### Subqueries (`select`)
949
+
950
+ The equivalent of SQL's `WHERE x IN (SELECT ...)`. This is how you filter one
951
+ class by a condition on another:
952
+
953
+ ```js
954
+ where: {
955
+ Application: {
956
+ select: {
957
+ query: {
958
+ className: "Applications",
959
+ where: { Type: "hospital" }
960
+ },
961
+ field: "objectId"
962
+ }
963
+ }
964
+ }
965
+ ```
966
+
967
+ #### The `published` idiom
968
+
969
+ **Every query silently appends `eye='1' AND published='1'` unless the `where`
970
+ mentions those keys.** Rows are soft-deleted by setting `published = 0`, so a
971
+ normal query never sees them. To include unpublished rows you must say so:
972
+
973
+ ```js
974
+ where: { published: { REGEXP: "0|1" } } // include both states
975
+ ```
976
+
977
+ This looks strange and is easy to mistake for dead code. It is not — omitting
978
+ it is why a query "loses" rows that visibly exist in the database.
979
+
980
+ ### 10b.4 `include` — resolving pointers
981
+
982
+ A pointer column holds an `objectId` string. `include` fetches the target row
983
+ and attaches it as `_<ClassName>`:
984
+
985
+ ```js
986
+ API.get("/Patients", {
987
+ fields: "objectId,firstName,Branch",
988
+ limit: 50,
989
+ include: {
990
+ className: "Branches",
991
+ field: "Branch", // the pointer column on Patients
992
+ fields: "Name"
993
+ }
994
+ })
995
+ ```
996
+
997
+ Each result row then carries:
998
+
999
+ ```js
1000
+ { objectId: "...", firstName: "...", Branch: "9GQJl7MZKm",
1001
+ _Branch: { results: [ { objectId: "9GQJl7MZKm", Name: "فرع طرابلس" } ] } }
1002
+ ```
1003
+
1004
+ Note the shape: **`_<Field>.results[0]`**, an array, not a bare object. Real
1005
+ code almost always flattens it immediately:
1006
+
1007
+ ```js
1008
+ for (var i = 0; i < r.results.length; i++) {
1009
+ var row = r.results[i];
1010
+ row.Branch = row._Branch.results[0];
1011
+ }
1012
+ ```
1013
+
1014
+ `include` may be a single object or an **array** of objects, and may nest.
1015
+
1016
+ #### Reverse includes
1017
+
1018
+ To query from the class being pointed *at*, add `reverse: true`. This lists each
1019
+ branch together with its patients:
1020
+
1021
+ ```js
1022
+ API.get("/Branches", {
1023
+ fields: "Name",
1024
+ limit: 50,
1025
+ include: {
1026
+ className: "Patients",
1027
+ field: "Branch", // the pointer column on Patients
1028
+ reverse: true,
1029
+ fields: "firstName,lastName",
1030
+ where: { Gender: "female" }
1031
+ }
1032
+ })
1033
+ ```
1034
+
1035
+ A class may have several pointers to the same target, and may point at itself
1036
+ (`Domains.ParentDomain -> Domains`) — both work.
1037
+
1038
+ **Where the result lands.** Each include is returned on the parent row under
1039
+ `_ClassName`, always as `{ results: [...] }`, never as a bare array — so it is
1040
+ `row._Pricing.results[0]`, and an empty `results` is a row with no children
1041
+ rather than a missing key:
1042
+
1043
+ ```js
1044
+ let pricing = pln._Pricing.results[0]; // reverse include
1045
+ let service = pln._Service.results[0]; // forward include
1046
+ ```
1047
+
1048
+ **Reverse includes nest.** An include inside a reverse include resolves against
1049
+ the child rows, which is how one request can reach a grandchild. This fetches
1050
+ every application with its processes, its routes, and the name of each route's
1051
+ domain — replacing four sequential round trips with one:
1052
+
1053
+ ```js
1054
+ rx.get("/Applications", {
1055
+ fields: "objectId,Name,Status,SourcePath,ConfigRevision,AppliedRevision",
1056
+ limit: -1,
1057
+ where: { Subscription: sub },
1058
+ include: [
1059
+ { className: "ApplicationProcesses", field: "Application",
1060
+ fields: "objectId,Type,Command,Status", reverse: 1 },
1061
+ { className: "ApplicationRoutes", field: "Application",
1062
+ fields: "objectId,Domain,Path,Enabled", reverse: 1,
1063
+ include: { className: "Domains", field: "Domain",
1064
+ fields: "objectId,Name" } }
1065
+ ]
1066
+ })
1067
+ // app._ApplicationRoutes.results[0]._Domain.results[0].Name
1068
+ ```
1069
+
1070
+ `field` is always the pointer column on the *child* class, in both directions.
1071
+
1072
+ #### The wire format, when calling the API by hand
1073
+
1074
+ `rx.get()` and `AsyncHttpClient::get()` serialise the query with
1075
+ `http_build_query`, so nested parameters go out as PHP bracket notation, not as
1076
+ JSON:
1077
+
1078
+ ```
1079
+ include[0][className]=ApplicationProcesses&include[0][field]=Application
1080
+ &include[0][reverse]=1&include[1][include][className]=Domains
1081
+ ```
1082
+
1083
+ A hand-built request that JSON-encodes `include` or `where` into the query
1084
+ string is silently misread: `include` comes back as `Invalid Field Name`, and a
1085
+ JSON `where` is **dropped entirely** — the query then returns arbitrary rows
1086
+ rather than failing, which is how a `where` on `objectId` once returned a
1087
+ different record. Build query strings with `http_build_query` (PHP) or
1088
+ `urlencode` over bracketed keys (anything else), or go through `rx`.
1089
+
1090
+ #### Adding columns: what `/Schemas` accepts
1091
+
1092
+ `PUT /Schemas/{Class}` adds `data`, `options`, `integer`, `boolean` and string
1093
+ columns, and rejects anything beyond the minimum keys — `options` fields fail if
1094
+ given `dbcol`/`default_value`/`editable`, so send only `name`, `type` and
1095
+ `options`.
1096
+
1097
+ **Pointers use the nested shape** — the same one `POST /schemas/{Class}` takes
1098
+ when the class is first created:
1099
+
1100
+ ```php
1101
+ "User" => [
1102
+ "name" => "User",
1103
+ "type" => "pointer",
1104
+ "pointer" => [
1105
+ "field" => "Email", // column to display
1106
+ "className" => "users", // the class KEY (lowercase, = dbtbl)
1107
+ "objectId" => $class_users->objectId // objectId of the class pointed AT
1108
+ ],
1109
+ "required" => 1
1110
+ ]
1111
+ ```
1112
+
1113
+ Note this differs from how a pointer is *read back*: `GET /schemas` returns it
1114
+ flattened, as `pointer: "users"` plus `pointerClassId` and `pointer_col`. Do not
1115
+ send it back in the shape you read it.
1116
+
1117
+ **Adding a pointer to an existing class via `PUT` did not work.** Every attempt
1118
+ returned `code 117, "Class Doesn't Exists"`, including the shape above with and
1119
+ without `required`, and with the pointed class's own objectId. `createFields()`
1120
+ in the BaaS clearly does understand pointers, so the capability exists and the
1121
+ rejection is happening somewhere earlier — the cause was not identified. The
1122
+ error names the class rather than the field, which sends you looking at the
1123
+ wrong thing. Declare pointers when the class is created, or use the schema UI.
1124
+
1125
+ Two details from the BaaS source worth knowing when hand-writing a field spec:
1126
+ the default value is read from `default`, **not** `default_value` (which is what
1127
+ `GET /schemas` returns it as), and `options` fields reject the extra keys a read
1128
+ returns — send only `name`, `type` and `options`.
1129
+
1130
+ ### 10b.5 Aggregates and grouping
1131
+
1132
+ Aggregates go in `fields`, not a separate key:
1133
+
1134
+ ```js
1135
+ API.get("/Patients", {
1136
+ fields: "Count(*) as c,Gender",
1137
+ groupby: "Gender",
1138
+ limit: 100
1139
+ })
1140
+ // -> [ { c: "3859", Gender: "male" }, { c: "5284", Gender: "female" } ]
1141
+ ```
1142
+
1143
+ Available functions: `Count`, `Sum`, `Avg`, `Min`, `Max`.
1144
+
1145
+ Three things that catch people out:
1146
+
1147
+ 1. **Aggregate values come back as strings**, not numbers. `parseFloat()` before
1148
+ arithmetic; concatenating them by accident is a common bug.
1149
+ 2. **`groupby: "none"`** removes grouping entirely and returns one aggregate row.
1150
+ Without any `groupby`, the engine defaults to grouping by `_pos`, i.e. per
1151
+ row.
1152
+ 3. With `groupby` set, `count: 1` returns the **number of groups**, not the
1153
+ number of rows.
1154
+
1155
+ `objectId`, `createdAt` and `updatedAt` are injected into every select
1156
+ automatically. In a grouped query they hold an arbitrary member of each group
1157
+ and are meaningless — ignore them rather than displaying them.
1158
+
1159
+ ### 10b.6 Chaining and upserts
1160
+
1161
+ #### `_then` — a follow-up request
1162
+
1163
+ Runs after the first query, with `row(FIELD)` interpolating values from the
1164
+ first result set:
1165
+
1166
+ ```js
1167
+ API.get("/Subscriptions", {
1168
+ fields: "objectId,Plan",
1169
+ limit: 20,
1170
+ count: 1,
1171
+ _then: [
1172
+ {
1173
+ path: "/Domains",
1174
+ method: "get",
1175
+ body: {
1176
+ count: 1,
1177
+ limit: 0,
1178
+ where: { Subscription: "row(objectId)" }
1179
+ }
1180
+ }
1181
+ ]
1182
+ })
1183
+ ```
1184
+
1185
+ `row(oType)` may also interpolate into the **path** (`"path": "/row(oType)"`)
1186
+ for polymorphic pointers.
1187
+
1188
+ #### `on_duplicate` — insert-or-update
1189
+
1190
+ On POST, if the row already exists, run this instead. Combined with the atomic
1191
+ increment string `"+=1"`, this is how the platform allocates sequential
1192
+ reference numbers:
1193
+
1194
+ ```js
1195
+ API.post("/Stats", {
1196
+ Year: 2026, Month: 8, Type: "patient", Count: 1,
1197
+ on_duplicate: {
1198
+ method: "PUT",
1199
+ path: "/Stats",
1200
+ body: {
1201
+ where: { Year: 2026, Month: 8, Type: "patient" },
1202
+ Count: "+=1"
1203
+ }
1204
+ },
1205
+ _then: {
1206
+ method: "GET",
1207
+ path: "/Stats",
1208
+ body: { limit: 1, fields: "*",
1209
+ where: { Year: 2026, Month: 8, Type: "patient" } }
1210
+ }
1211
+ })
1212
+ ```
1213
+
1214
+ ### 10b.7 `/batch` — several queries in one round trip
1215
+
1216
+ ```js
1217
+ var reqs = {
1218
+ requests: [
1219
+ { path: "/Patients", method: "GET", body: { count: 1, limit: 0 } },
1220
+ { path: "/Applications", method: "GET",
1221
+ body: { fields: "Count(*) as c,Status", groupby: "Status", limit: 100 } }
1222
+ ]
1223
+ };
1224
+
1225
+ API.post("/batch", reqs).then(function (r) {
1226
+ var patientCount = r[0].count;
1227
+ var byStatus = r[1].results;
1228
+ });
1229
+ ```
1230
+
1231
+ The response is an **array** positionally matching `requests` — not an object.
1232
+
1233
+ ### 10b.8 Field types and what comes back
1234
+
1235
+ | Schema type | JS value returned |
1236
+ |---|---|
1237
+ | `string`, `longText`, `email`, `key`, `color` | string |
1238
+ | `integer`, `number` | string (parse before arithmetic) |
1239
+ | `boolean` | `0` / `1` |
1240
+ | `date`, `datetime`, `time`, `year` | ISO-ish string |
1241
+ | `options` | string, one of the declared values |
1242
+ | `pointer` | `objectId` string, plus `_<Field>.results[]` when included |
1243
+ | `pointerArray`, `array` | comma-separated string of `objectId`s |
1244
+ | `data` | **a JSON string — call `JSON.parse()` yourself** |
1245
+
1246
+ The `data` type is the one that bites: it looks like an object in the schema and
1247
+ arrives as a string.
1248
+
1249
+ #### `Plans.Limits` and `Plans.Features` — the real shapes
1250
+
1251
+ `Limits` is the **internal, machine-readable** column (snake_case). The complete
1252
+ real shape, with storage values in **MB**:
1253
+
1254
+ ```json
1255
+ {"max_processes":20,"memory_limit":512,"inodes":50000,
1256
+ "web_storage":40960,"mail_storage":61440}
1257
+ ```
1258
+
1259
+ There is **no CPU key and no bandwidth key**. Do not invent `memory`, `cpu_cores`,
1260
+ `max_sites`, `max_databases` or `max_emails` — none exist. Read a limit with a
1261
+ documented fallback, and ask before assuming a new key.
1262
+
1263
+ `Features` is the **user-facing** column: nested display strings, marketing copy,
1264
+ never parse it for real limits.
1265
+
1266
+ ```js
1267
+ Features['Websites & Storage']['Disk Space'] // "40 GB NVMe"
1268
+ Features['Email & Databases'].Databases // 10
1269
+ ```
1270
+
1271
+ Both are `data` columns, so `JSON.parse()` them (`_dashboards/cloudhosting.view`
1272
+ does this once and shares `Limits` with every tab).
1273
+
1274
+ Related real fields: `Datacenters` carry `Name`, `Region`, `Flag`, `Identifier`.
1275
+ `globals.phpversions` is keyed by version string (`"8.3"`) with `objectId`,
1276
+ `ReleaseDate` and `EOLDate` inside each record — so resolving a `PHPVersion`
1277
+ pointer needs a reverse scan, not a lookup. `EOLDate` is `ReleaseDate + 2 years`,
1278
+ i.e. **end of active support**; PHP gives a further year of security fixes, so do
1279
+ not label a version "end of life" from that date alone.
1280
+
1281
+ ```js
1282
+ var plan = r.results[0];
1283
+ plan.Limits = JSON.parse(plan.Limits);
1284
+ plan.Features = JSON.parse(plan.Features);
1285
+ ```
1286
+
1287
+ ### 10b.9 Writing
1288
+
1289
+ ```js
1290
+ API.post("/Patients", { firstName: "...", Branch: branchObjectId });
1291
+ API.put("/Patients/" + objectId, { Mobile: "..." });
1292
+ API.delete("/Patients/" + objectId);
1293
+ ```
1294
+
1295
+ Deletion is normally **soft** — set `published: 0` rather than issuing a
1296
+ `DELETE`, so the row stays recoverable and drops out of every normal query
1297
+ automatically.
1298
+
1299
+ ### 10b.9b Writes fail WITHOUT rejecting
1300
+
1301
+ The API resolves on a failed write instead of rejecting, so `.catch()` never runs
1302
+ and a failure looks exactly like success to a `.then()` handler:
1303
+
1304
+ ```js
1305
+ // success
1306
+ {"results":[{"objectId":"9GQJl7MZKm"}]}
1307
+ // failure — still a RESOLVED promise
1308
+ {"results":[{"code":1054,"error":"Unknown column 'X' in 'SET'","type":"DBQueryError"}]}
1309
+ // top-level failures happen too
1310
+ {"code":105,"error":"Invalid Field Name","type":"InvalidFieldName"}
1311
+ ```
1312
+
1313
+ Always run a write's response through `rxError(r)` (in `index.js`) before telling
1314
+ the user it saved — it returns the message, or `''` on success:
1315
+
1316
+ ```js
1317
+ rx.put("/Domains", {...}).then(function (r) {
1318
+ var err = rxError(r);
1319
+ if (err) { /* surface it */ return; }
1320
+ /* only now is it actually saved */
1321
+ });
1322
+ ```
1323
+
1324
+ One exception worth knowing: `db_create.view` relies on code **1062** (duplicate key)
1325
+ being returned for its `on_duplicate` flow, so it checks for that case before
1326
+ treating an error as fatal.
1327
+
1328
+ ### 10b.9c A `select` subquery inside an `or` group crashes the API
1329
+
1330
+ `or` works, and `select` works, but a subquery nested inside an `or` returns an
1331
+ empty response (a server error). Resolve the ids in a first query, then use `in`:
1332
+
1333
+ ```js
1334
+ // step 1: ids of the parents whose subdomains match
1335
+ // step 2:
1336
+ where: { Subscription: sub, Type: "primary",
1337
+ or: [ { Name: { contains: kw } }, { objectId: { in: ids.join(',') } } ] }
1338
+ ```
1339
+
1340
+ ### 10b.9d `/Schemas` — reading and changing the schema
1341
+
1342
+ `rx.get("/Schemas")` returns every class with its fields, types, pointers and
1343
+ options — the authoritative source for what exists. Use it instead of guessing at
1344
+ field names.
1345
+
1346
+ **Create a class** — `POST /schemas/{ClassName}`:
1347
+
1348
+ ```js
1349
+ rx.post("/schemas/Ports", {
1350
+ className: "Ports",
1351
+ classType: "custom",
1352
+ fields: {
1353
+ Port: { name: "Port", type: "integer", required: 1 },
1354
+ Status: { name: "Status", type: "options", required: 1,
1355
+ options: "available,reserved,allocated", default: "reserved" },
1356
+ Subscription: {
1357
+ name: "Subscription", type: "pointer", required: 0,
1358
+ // objectId is the TARGET CLASS's objectId, read from /Schemas
1359
+ pointer: { field: "Name", className: "subscriptions", objectId: "aPbxP39VeL" }
1360
+ }
1361
+ },
1362
+ indexes: {
1363
+ Port: { name: "Port", field: "Port", unique: 1 },
1364
+ Status: { name: "Status", field: "Status" }
1365
+ },
1366
+ options: { images:0, videos:0, files:0, audios:0, variants:0, specs:0,
1367
+ categories:0, feature:0, MailOnAdd:0, MailOnEdit:0, locale:["en"] }
1368
+ });
1369
+ ```
1370
+
1371
+ **Add a column** — `PUT /Schemas/{ClassName}` carrying only the new field(s):
1372
+
1373
+ ```js
1374
+ rx.put("/Schemas/Applications", {
1375
+ className: "Applications",
1376
+ fields: { Env: { name: "Env", type: "data", required: 0, unique: 0 } }
1377
+ });
1378
+ ```
1379
+
1380
+ Field `type` values in use: `string`, `longText`, `integer`, `number`, `boolean`,
1381
+ `email`, `date`, `datetime`, `time`, `year`, `options`, `color`, `key`, `data`,
1382
+ `pointer`, `pointerArray`. A `pointer` needs `pointer: { className, field, objectId }`
1383
+ where `objectId` identifies the target **class** and `field` is the column to display.
1384
+
1385
+ **Gotcha:** the `indexes` block reads back **empty** from `/Schemas` even when the
1386
+ index exists and MySQL is enforcing it. Verify a unique index by attempting a
1387
+ duplicate insert and checking for code **1062**, not by reading the schema.
1388
+
1389
+ ### 10b.10 Debugging
1390
+
1391
+ `debug_query: 1` in the body returns the generated SQL alongside the results.
1392
+ Use it when a query returns something unexpected; do not ship it.
1393
+
1394
+ ---
1395
+
1396
+ ## 11. A minimal complete example
1397
+
1398
+ ```html
1399
+ <!-- views/patients.view -->
1400
+ <div bind="items,loading,total">
1401
+ <h2>Patients ({{ total }})</h2>
1402
+
1403
+ <div :if="loading">Loading…</div>
1404
+
1405
+ <div :else :for="items as item; i">
1406
+ {{ i + 1 }}. {{ item.firstName }} {{ item.lastName }}
1407
+ — {{ item._Branch.results[0].Name }}
1408
+ </div>
1409
+ </div>
1410
+
1411
+ <script>
1412
+ // every bound variable is `var`, never let/const (§5.1)
1413
+ var items = [];
1414
+ var loading = true;
1415
+ var total = 0;
1416
+
1417
+ API.get("/Patients", {
1418
+ fields: "objectId,firstName,lastName,Branch",
1419
+ where: { Gender: "female" },
1420
+ order: "-createdAt",
1421
+ limit: 50,
1422
+ count: 1,
1423
+ include: {
1424
+ className: "Branches",
1425
+ field: "Branch",
1426
+ fields: "Name"
1427
+ }
1428
+ }).then(function (r) {
1429
+ items = r.results; // reassignment is fine and idiomatic
1430
+ total = r.count;
1431
+ loading = false;
1432
+ }).catch(function (err) {
1433
+ loading = false;
1434
+ cl("patients load failed", err);
1435
+ });
1436
+ </script>
1437
+
1438
+ <style>
1439
+ h2 { color: teal; }
1440
+ </style>
1441
+
1442
+ <settings>
1443
+ { "hasNav": true, "hasHeader": true, "hasFooter": true, "requireAuth": false }
1444
+ </settings>
1445
+ ```
1446
+
1447
+ ---
1448
+
1449
+ ## 12. Full syntax-contrast table (Vue / React → LumenJS)
1450
+
1451
+ | Looks like… | In Vue/React it means | In LumenJS it actually means |
1452
+ |---|---|---|
1453
+ | `:for="items as item"` | (not valid Vue syntax, but visually close to `v-for`) | Loop directive; **also** accepts a bare integer (`:for="5"`) to repeat N times with no backing array — no Vue/React equivalent |
1454
+ | `:if` / `:else-if` / `:else` | N/A literally, but reads as `v-if` minus the `v-` | Same chaining semantics as Vue's `v-if`/`v-else-if`/`v-else` — this one **does** transfer cleanly |
1455
+ | `{{ expr }}` | Vue interpolation (text-only in Vue) | Works in text **and directly inside quoted HTML attributes** — Vue requires `:attr="expr"` for the latter; LumenJS does not distinguish |
1456
+ | `` `{{ expr }}` `` (backticks around the moustache) | Nothing — not valid Vue/React | LumenJS-specific "raw/safe" interpolation form for attribute values whose expression contains its own quotes (§5.2) |
1457
+ | `@click="handler"` | Vue: calls `handler($event)`; DOM `event` only | Calls `handler(ev, el)` — **second argument `el` is always a jQuery object**, not the native node and not a Vue/React synthetic-event target |
1458
+ | `@mouseover` | Native DOM event name | LumenJS spells it `@mouse-over` (hyphenated) — the un-hyphenated native form is not the documented directive name |
1459
+ | `v-model` / controlled `<input value={} onChange={}>` | Two-way field binding | **Does not exist.** Nearest equivalent is whole-form `o-sub`/`b-sub` handlers receiving a `d` object of all field values at submit time (§6.6) |
1460
+ | `bind="x"` | Looks like Vue's `v-bind` shorthand (`:x`) — **it is not that** | Declares `x` as a reactive variable for this subtree; unrelated to attribute binding. Required — without it the value renders once and never updates, silently |
1461
+ | `let x = ...` / `const x = ...` for state | Standard modern JS, works fine | **Breaks reactivity silently.** Bound variables must be `var` (§5.1) |
1462
+ | `:data="x"` | Looks like a Vue prop binding | Passes variable `x` into a sub-view/component by same-name convention, not a named-prop API |
1463
+ | `view="path"` | N/A | Renders another `.view` file inline — closest Vue analogue is `<component :is="...">`, but resolved against the filesystem, not a component registry |
1464
+ | `comp="name"` | N/A | Renders a file from `comps/` — a second, separate "component" concept from both views and templates |
1465
+ | `tpl="name"` | N/A | Renders a file from `tpls/`, almost always alongside `:for` — a third, separate reusable-HTML concept |
1466
+ | Component props object | `props` (Vue/React) | Only exists for **modals**, via `view.scope(function(view, props){...})` (§8) — ordinary sub-views/components use implicit same-name variable binding instead |
1467
+ | `watch: {...}` (Vue option) / `watch()`/`useEffect()` | Per-component, additive, dependency-scoped | One **global** `watch` object; reassigning it wholesale **overwrites** previously-registered watchers (§5.3) |
1468
+ | Router (`vue-router`/`react-router`) with a route table | Declarative route config | No route table at all — URL segments map 1:1 to nested view files on disk; deeper dispatch is hand-written against `nodes[n]` (§5.5, §9) |
1469
+ | Global store (Pinia/Redux) | Actions/mutations/reducers, often with devtools | Direct mutation of a single `globals` object, auto-mirrored to `localStorage` — no action layer |
1470
+ | `key` prop in lists | Required for diffing correctness | No `key` attribute exists on `:for` |
1471
+ | CSS scoping (`<style scoped>`) | Compiler-enforced style isolation per component | No compiler, so no true scoping — styles in a parent view's `<style>` block are inherited by sub-views rendered inside it; the `:hover` *directive* (§6.10) exists specifically to work around the lack of scoped `:hover` pseudo-class control |
1472
+
1473
+ ---
1474
+
1475
+ ## 13. Corrections and additions from live verification (2026-08-29, "Talabat" BaaS project)
1476
+
1477
+ Everything below was checked against a real, freshly-created BaaS project
1478
+ (not read from docs or reverse-engineered from an old app) while provisioning
1479
+ a schema for a new client. Two things in §10b.9d turned out to be wrong or
1480
+ incomplete; everything else here is new material the earlier sections didn't
1481
+ cover. Source: `_Talabat/schema/README.md` and `_Talabat/schema/provision.js`
1482
+ in this BEACDN tree have the full worked example.
1483
+
1484
+ ### 13.1 `GET /Schemas`'s real response shape (corrects §10b.9d)
1485
+
1486
+ §10b.9d says `/Schemas` "returns every class with its fields, types,
1487
+ pointers and options" without specifying the envelope. The real shape is
1488
+ **one row per app**, not one row per class:
1489
+
1490
+ ```js
1491
+ { "results": [
1492
+ { "objectId": "2OaQrLGxeq", "appName": "Talabat", "classes": { /* ... */ } }
1493
+ ] }
1494
+ ```
1495
+
1496
+ `classes` is an object keyed by the **lowercase `dbtbl` name**, not an array:
1497
+
1498
+ ```js
1499
+ classes.restaurants = {
1500
+ objectId: "e2L8WE7V07", // <- use this as a pointer's target objectId
1501
+ className: "Restaurants", // display name, original casing
1502
+ dbtbl: "restaurants",
1503
+ Options: { images: 1, videos: 0, /* ... */ locale: ["en"] },
1504
+ fields: {
1505
+ Name: { objectId, name, dbcol, type: "string", required: "1", /* ... */ },
1506
+ Restaurant: { // a pointer field, once one exists
1507
+ type: "pointer",
1508
+ pointer: "restaurants", // target's dbtbl (lowercase)
1509
+ pointerClassId: "e2L8WE7V07", // target's objectId
1510
+ pointer_col: "Name", // the target's display field
1511
+ },
1512
+ },
1513
+ };
1514
+ ```
1515
+
1516
+ A brand-new project's `classes` is `{}` — there is no default/built-in entry
1517
+ for anything, including `Users` (§13.2).
1518
+
1519
+ ### 13.2 There is no pre-existing built-in `Users` class
1520
+
1521
+ On a fresh project, `/Users` doesn't exist (`GET /Users` → `code 103,
1522
+ "Invalid Class Name"`), and `POST /Auth/register` fails with
1523
+ `{"code":110,"error":"Invalid Class Request Method","type":"InvalidMethod"}`
1524
+ until a `Users` class is created **exactly like any other custom class**
1525
+ via `POST /schemas/Users`. Minimum viable shape that makes `/Auth/register`,
1526
+ `/Auth/login`, and `/Auth/me` work: `firstName`, `lastName`, `Email` (type
1527
+ `email`, required), `Password`, plus whatever else the app needs
1528
+ (`Mobile`, `authType`, `emailVerified`, `image`, ...).
1529
+
1530
+ ### 13.3 The full `/Auth` flow, confirmed end-to-end
1531
+
1532
+ This is the sequence that actually works, with real responses:
1533
+
1534
+ ```js
1535
+ // 1. App-level token exchange — Application ID + the mobile CLIENT KEY
1536
+ // (the console's "client key (for iOS/Android/Flutter)", a distinct
1537
+ // credential from the "js key (for JavaScript clients)" and the
1538
+ // "REST API key"), sent as X-BEA-JavaScript-Key regardless of the
1539
+ // "js"-sounding header name:
1540
+ POST /Auth
1541
+ X-BEA-Application-ID: <app id>
1542
+ X-BEA-JavaScript-Key: <CLIENT key, not the js key, for a mobile client>
1543
+ body: {"scope":"public"}
1544
+ → {"access_token":"<jwt>","refresh_token":"<jwt>","token_type":"Bearer","expires_in":3700}
1545
+
1546
+ // 2. Register — needs the app access token from step 1, as x-bea-access-token:
1547
+ POST /Auth/register
1548
+ x-bea-access-token: <access_token from step 1>
1549
+ body: {"Email":"...", "Password":"...", "firstName":"...", "lastName":"...", "authType":"normal"}
1550
+ → {"results":[{"objectId":"...", "createdAt":"...", "updatedAt":"..."}]}
1551
+
1552
+ // 3. Login — same auth as register, different response shape (flat, no `results`):
1553
+ POST /Auth/login
1554
+ x-bea-access-token: <access_token>
1555
+ body: {"Email":"...", "Password":"..."}
1556
+ → {"session_token":"<jwt>","refresh_token":"<jwt>","token_type":"Bearer","expires_in":3700}
1557
+ // wrong credentials:
1558
+ → {"Server":"...", "code":121, "error":"Invalid Email Or Password", "type":"InvalidUserData"}
1559
+
1560
+ // 4. Current user — use the SESSION token from login, not the access token:
1561
+ POST /Auth/me
1562
+ x-bea-session-token: <session_token from step 3>
1563
+ body: {}
1564
+ → {"objectId":"...", "firstName":"...", "lastName":"...", "Email":"...",
1565
+ "Password":"...", // <- comes back in PLAINTEXT; do not log/display this response as-is
1566
+ "Mobile":"", "authType":"normal", "emailVerified":0, "image":""}
1567
+
1568
+ // 5. Logout:
1569
+ POST /Auth/logout
1570
+ x-bea-session-token: <session_token>
1571
+ → {"success": true}
1572
+ ```
1573
+
1574
+ **Critical gotcha, found by accident, not by reading anything**: calling
1575
+ `/Auth/register`, `/Auth/login`, or `/Auth/me` with the **static REST API
1576
+ key** (`X-BEA-Authorization` + `X-BEA-Application-ID`, the §10 `API`-global
1577
+ auth style) instead of an access/session token does **not** error. It
1578
+ returns HTTP 200 with a **fresh, unrelated app token** (the same shape as
1579
+ step 1) and silently ignores the request body entirely — no error, no
1580
+ rejection, and it is easy to mistake for a successful call since the shape
1581
+ looks plausible. `/Auth/*` endpoints must be driven by the access/session
1582
+ token flow above, never the REST API key.
1583
+
1584
+ ### 13.4 §10b.9d's "adding a pointer to an existing class via PUT doesn't
1585
+ work" is not a blanket rule
1586
+
1587
+ §10b.9d documents `PUT /Schemas/{Class}` failing with `code 117, "Class
1588
+ Doesn't Exists"` when adding a pointer field to a class that already has
1589
+ data. Retested this exact operation live, twice, on two different
1590
+ already-existing classes with real rows in them:
1591
+
1592
+ ```js
1593
+ PUT /Schemas/Restaurants
1594
+ body: { "className": "Restaurants", "fields": {
1595
+ "Location": { "name":"Location", "type":"pointer", "required":0,
1596
+ "pointer": { "field":"Name", "className":"locations", "objectId":"<Locations objectId>" } }
1597
+ }}
1598
+ → {"results":{"fields":{"success":1,"message":"Fields updated successfully"}, "localization":false}}
1599
+ ```
1600
+
1601
+ Confirmed genuinely applied (not a false-success — see §10b.9b's warning
1602
+ that success messages can lie) by reading it back via `GET /Schemas`: the
1603
+ field appeared with the correct `pointerClassId`. Did this twice
1604
+ (`Restaurants` and `Addresses`), both succeeded. Whatever caused the
1605
+ documented failure, it is not a standing rule on this project as of
1606
+ 2026-08-29 — don't treat §10b.9d's warning as gospel without retesting
1607
+ against the project you're actually working with; do treat a `PUT`
1608
+ "success" as worth a read-back check regardless, per §10b.9b.
1609
+
1610
+ ### 13.5 The built-in media/images subsystem — not documented anywhere in
1611
+ this file until now
1612
+
1613
+ Turning on a class's `images` option (`PUT /Schemas/{Class}` with
1614
+ `{"options": {"images": 1}}`) does **not** add a schema field — confirmed by
1615
+ reading the schema back before and after (field list unchanged). It
1616
+ activates a separate media subsystem: a dedicated sub-resource keyed by the
1617
+ record's own `objectId`, not a field in the record's body. Confirmed by
1618
+ reading real production code (`Rahimoun/src/views/account/admin/
1619
+ userprofile.view`), not by guessing from the option's name:
1620
+
1621
+ ```js
1622
+ // Upload — note the body is an ARRAY, not an object:
1623
+ POST /{ClassName}/{objectId}/images
1624
+ body: [ { "title": "...", "album": "untitled", "file": "<dataURL string>" } ]
1625
+
1626
+ // Read — media:"images" (+ optional crops:"ax300,ax600") on a single-object GET:
1627
+ GET /{ClassName}/{objectId}?media=images
1628
+ → { "results": [ { ..., "images": { "untitled": [
1629
+ { "objectId": "...", "dir": "...", "imageax600": "...", /* one imageax<N> per requested crop */ }
1630
+ ] } } ] }
1631
+ // build a URL as: entry.dir + entry.imageax600
1632
+
1633
+ // Delete:
1634
+ DELETE /{ClassName}/{objectId}/images/{imageObjectId}
1635
+ ```
1636
+
1637
+ `album` groups images under a name (`"untitled"` is what production code
1638
+ defaults to) — a class can have more than one album. This is a *different*
1639
+ endpoint/shape than the list-query `media`/`crops` params in §10b.2 applied
1640
+ per-row in a multi-row response; this one is scoped to a single object.
1641
+
1642
+ ### 13.6 Real confirmed write-failure and `where`-serialization examples
1643
+
1644
+ Concrete, from-the-wire versions of what §10b.3 and §10b.9b describe in the
1645
+ abstract:
1646
+
1647
+ ```js
1648
+ // Bad column name — resolves (HTTP 200), doesn't reject, per §10b.9b:
1649
+ POST /Restaurants { "NoSuchColumn": "x" }
1650
+ → {"results":[{"code":1054,"error":"Unknown column 'NoSuchColumn' in 'INSERT INTO'","type":"DBQueryError"}]}
1651
+ ```
1652
+
1653
+ `where` bracket-notation vs. JSON-encoding (§10b.4's wire-format warning),
1654
+ confirmed side by side against two real rows named "Probe Diner" and
1655
+ "Second Spot":
1656
+
1657
+ ```
1658
+ GET /Restaurants?fields=objectId,Name&where=%7B%22Name%22%3A%22Probe+Diner%22%7D (JSON-encoded)
1659
+ → returns BOTH rows — the where clause was silently ignored, not an error
1660
+
1661
+ GET /Restaurants?fields=objectId,Name&where[Name]=Probe%20Diner (bracket notation)
1662
+ → returns only "Probe Diner"
1663
+ ```
1664
+
1665
+ One implementation detail worth knowing for anyone building a Dart/Flutter
1666
+ (or other Dio-based) client: **Dio's default `queryParameters` handling
1667
+ already serializes a nested `Map` value as bracket notation** — passing
1668
+ `{"where": {"Name": "Probe Diner"}}` as `queryParameters` produces
1669
+ `where%5BName%5D=Probe+Diner` on the wire with no extra code, confirmed by
1670
+ inspecting the actual outgoing request URI. Don't manually JSON-encode
1671
+ `where`/`include` before handing them to Dio; the naive-looking default
1672
+ already does the right thing.
1673
+
1674
+ ### 13.7 Numeric field encoding: contradicts §10b.8 for this project
1675
+
1676
+ §10b.8 states `integer`/`number` columns always come back as strings. Not
1677
+ what was observed here, confirmed twice now: `Latitude`/`Longitude` via an
1678
+ `include`-resolved pointer, and `Rating`/`IsOpen` read directly (no
1679
+ `include`) on a real, freshly-seeded `Restaurants` row — both cases returned
1680
+ native JSON numbers (`4.5`, not `"4.5"`), not strings. Whichever project
1681
+ §10b.8 was originally written against, this project's BaaS version doesn't
1682
+ match it for numeric encoding. **Practical guidance regardless**: parse
1683
+ defensively either way — call `.toString()` on the value before
1684
+ `num.parse`/`parseFloat`, which is correct whether the wire value is a JSON
1685
+ number or a numeric string, and cheap insurance either way.
1686
+
1687
+ ### 13.8 `fields` is not optional in practice; a wrong-token-type 500 on
1688
+ `/Auth/logout`
1689
+
1690
+ Omitting `fields` from a `GET` does **not** return every column the way a
1691
+ bare SQL `SELECT *` would — it returns only `objectId` plus
1692
+ `createdAt`/`updatedAt`, and every custom column is silently absent, not an
1693
+ error. Confirmed live against real, non-empty rows (seeded `Restaurants`
1694
+ data, queried back with no `fields` param — every custom field came back
1695
+ `undefined`). Every query needs its column list spelled out explicitly;
1696
+ there's no safe default to lean on, and the failure mode is silent missing
1697
+ data, not an exception.
1698
+
1699
+ Separately: `POST /Auth/logout` returns a genuine HTTP 500 when called with
1700
+ an app-level access token (`x-bea-access-token`) instead of a real user
1701
+ session token (`x-bea-session-token`) — this is NOT the same as an
1702
+ expired/garbage session token, which the endpoint handles gracefully
1703
+ (`HTTP 200, {"error":"Invalid token: Incomplete segments"}`). Reproduced via
1704
+ a real client app hitting this exact failure mode; the client-side root
1705
+ cause of sending the wrong token type in that instance wasn't nailed down
1706
+ with certainty (token-store persistence across repeated debug rebuilds on
1707
+ an iOS Simulator is one plausible culprit, not confirmed). Regardless of
1708
+ cause: treat any call that's only meaningful for a logged-in user
1709
+ (`/Auth/logout`, `/Auth/me`) as something to skip entirely client-side when
1710
+ no real session is stored, rather than trusting a generic access-token
1711
+ fallback to either work or fail cleanly.
1712
+
1713
+ ---
1714
+
1715
+ ## 14. Mobius CSS — the utility scale (verified against `src/css/bea.css`, 2026-08-30)
1716
+
1717
+ The spec deliberately doesn't try to be a full Mobius reference (§1), but the
1718
+ **spacing/size scale is not linear** and inventing an off-scale class is the most
1719
+ common way to break a Roxyon Console view. An off-scale class fails **silently** —
1720
+ no error, the rule just isn't there, and the element sits with 0 of whatever you
1721
+ asked for. `src/css/bea.css` is the ground truth; `src/css/styles.css` holds the
1722
+ Console's theme classes (`bdgc`, `_cgr`, `_cr`, `_cdg`, `_cgreen`, `iconify`,
1723
+ `secondary-nav*`, …) and any project-specific additions **go at the bottom of
1724
+ `styles.css`**.
1725
+
1726
+ ### 14.1 The spacing scale — `p m` and every side/axis variant
1727
+
1728
+ Prefixes: `p pv ph pt pb pl pr` · `m mt mb ml mr` (there is **no `mv`**; `mh` only
1729
+ exists as `mh100 mh350` — use `mya`/`mxa`/`ma` for auto margins).
1730
+
1731
+ Allowed integers (px), **all families identical**:
1732
+
1733
+ ```
1734
+ 0 1 2 3 4 5 6 8 10 15 20 25 30 40 50 60 70 80 90 100 110 120 130 140 150 160 180 200
1735
+ ```
1736
+
1737
+ Gaps between steps: 1 below 6, **no 7**, then 8, **no 9**, 10, then **+5 up to 30**,
1738
+ then **+10 up to 200**. So `p7 p11 p12 p13 p14 p16 p18 p22 …` **do not exist** —
1739
+ round to the nearest listed value (`p14`→`p15`, `p18`→`p20`, `mt12`→`mt10`).
1740
+
1741
+ ### 14.2 Gap — `g`
1742
+
1743
+ ```
1744
+ 5 8 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85 90 95 100
1745
+ ```
1746
+
1747
+ Note `g8` exists (added 2026-08-29) but **`g6` and `g12` do not** — use `g5`/`g10`.
1748
+ `<flx>` / `.df` is the flex container; `g<n>` sets its `gap`.
1749
+
1750
+ ### 14.3 Font-size / line-height — `fs` / `lh`
1751
+
1752
+ Every integer `0`–`30`, then `32 34 36 38 40 42 44 46 48 50 55 60 65 70 75 80 85
1753
+ 90 95 100 110 120 130 140 150 160`. `fs14`/`fs16` are fine; `fs31 fs33 …` are not.
1754
+
1755
+ ### 14.4 Other scales
1756
+
1757
+ - `z` (z-index): `1 10 20…200 300…1000 2000…10000`
1758
+ - `op` (opacity ×%): `0 5 10 20 30 40 50 60 70 80 90 100`
1759
+ - `mw` (max-width ×%): `50 60 70 80 100`
1760
+ - radius: `round` (.25rem) `round2x` (.5) `round3x` (.75) `round4x` (1rem) `round0`
1761
+ `round100` `roundl` `roundr`
1762
+ - weight: `b`/`b700` (bold) `b400 b500 b600 b900` `n`
1763
+
1764
+ ### 14.5 Layout utilities that trip people up
1765
+
1766
+ | class | actual rule | note |
1767
+ |---|---|---|
1768
+ | `.l` | `text-align: right` | **RTL-first framework — `l` is RIGHT, `r` is LEFT.** `.c` is centre. |
1769
+ | `.r` | `text-align: left` | |
1770
+ | `.h` | hide | part of a giant combined selector in bea.css; toggled at runtime via `addClass('h')`. Note the responsive variants `h-m_sb` etc. |
1771
+ | `.fh` | `display:none !important` | plain force-hide; use this for a hidden programmatic `modal=` trigger (`.h` sometimes keeps the element out of a `modal` binding — `.fh` is what existing code like `_forms/domains_create.view`'s `._checkModal` uses). |
1772
+ | `.ns` | `user-select:none` | |
1773
+ | `.oe` | `white-space:nowrap` | (not ellipsis) |
1774
+ | `.cc` | `position:absolute;top/left:50%` | centre-absolute; needs a `translate` or it's off by half its own size |
1775
+ | `.df .fc .fr .fw .f1` | flex shorthands | `<flx>` element == `.df` |
1776
+ | `.ac .as .ae` / `.jc .js .je .jsb .jsa` | align-items / justify-content | **inert on a plain `<div>`** — they only do anything once the element is `display:flex`. Use `<flx class="je g15 ac">`, not `<div class="je g15 ac">`. Same for `g<n>` (it's `gap`). |
1777
+ | `.bdgc` | card background+border (theme) | Console-specific, in `styles.css` |
1778
+
1779
+ ### 14.6 Adding a class
1780
+
1781
+ If a value genuinely isn't on the scale and rounding hurts the design, append to
1782
+ the **bottom of `src/css/styles.css`** (never `bea.css` — that's the vendored
1783
+ framework). Prefer a scoped `<style>` block in the view with an explicit
1784
+ `gap:`/`padding:` when the exact px matters for one component only — see the
1785
+ comment in `_dashboards/dashboard.view` (`.dsh-act`) for the house pattern:
1786
+ *"A rule that is wrong fails visibly; a class that is not there fails quietly."*
1787
+
1788
+ ---
1789
+
1790
+ ## 15. Roxyon Console — house conventions (forms, inputs, modals, colour)
1791
+
1792
+ Design reference for the whole Console is **Hetzner Cloud Console, dark mode** — clone
1793
+ its layout, density and interaction patterns. The rules below are things the user has
1794
+ asked for more than once; treat them as required, not stylistic.
1795
+
1796
+ ### 15.1 Forms
1797
+
1798
+ - `<form data-theme="dark" o-sub="handler">` — **always** `data-theme="dark"` on the
1799
+ `<form>`, and on any non-form container that holds inputs (a card, a list row with
1800
+ an inline editor). Without it the fields theme light and read as a bug.
1801
+ - Field-level validation is only `:required` (§6.6) + your own `b-sub`. There is no
1802
+ per-field validator directive and no `v-model`.
1803
+ - Close a modal form from JS with `$('[close-modal]:last').click()`.
1804
+
1805
+ ### 15.2 Inputs
1806
+
1807
+ ```html
1808
+ <label class="f1 field__input-wrap">
1809
+ <input type="text" name="Foo" class="ba0 bgt dark" placeholder="example.com" />
1810
+ </label>
1811
+ ```
1812
+
1813
+ - `.dark` sets **white text only** — the wrapper paints the background. `.dark` + `.input`
1814
+ together = white text on white = invisible field. Never pair them.
1815
+ - Inside the older `.rx-input_wrap` labelled-field pattern the wrapper forces
1816
+ `background: transparent`, so there the correct combo is `hc-field__input input dark`
1817
+ — and *only* there.
1818
+
1819
+ ### 15.3 Selects — always `sl`
1820
+
1821
+ `<select sl sl-prt="self" :required name="Foo">…</select>`. See §6.7 for the wrapper
1822
+ details. The two things that bite:
1823
+
1824
+ 1. A select injected from JS after an async load is a **raw browser control** until
1825
+ the next `globalWatch()` (§5.6). Prefer `globalWatch()`; the manual fallback is
1826
+ `$(sel).select2({ dropdownParent: $(sel).parent(), … })` guarded by
1827
+ `:not(.select2-hidden-accessible)`.
1828
+ 2. The clear-× is **opt-in** — `<select sl clear>` shows the dark-red (`#900`) clear
1829
+ button, `<select sl>` doesn't. Add `clear` only when unsetting the field is a real
1830
+ choice (rare in this app — most selects always hold a valid value).
1831
+
1832
+ ### 15.4 Checkbox — the house pattern (not `input-check`)
1833
+
1834
+ ```html
1835
+ <div class="round4x checkbox red p8">
1836
+ <span class="__nses"><input class="h" type="checkbox" name="Foo"></span>
1837
+ <span class="iconify" data-icon="mdi:checkbox-blank-outline"></span>
1838
+ <span class="iconify" data-icon="mdi:checkbox-marked"></span>
1839
+ </div>
1840
+ ```
1841
+
1842
+ ### 15.5 Modals — the edit-in-place pattern
1843
+
1844
+ Beyond §8's basics, the Console pattern for "edit a row from a list without a route
1845
+ change and without re-rendering the whole list":
1846
+
1847
+ 1. One **hidden** trigger in the parent view:
1848
+ `<div class="fh _fooModal" modal="…/modals/foo" modal-class="gen-modal" @modal-open="_fooArgs"></div>`
1849
+ (`.fh`, never `.h` — see §14.5).
1850
+ 2. Parent sets a module-scoped var, then clicks the trigger:
1851
+ `_fooPending = { id, record }; $('._fooModal').click();`
1852
+ `function _fooArgs() { return _fooPending; }`
1853
+ 3. Modal reads it: `view.scope(function (view, props) { var p = props.modal; … });`
1854
+ 4. **Modal → parent callback:** the parent attaches a function to `window`
1855
+ (`window._fooApply = function (row, mode) { … }`); the modal calls it on a
1856
+ successful save. (View `<script>`s share globals — this is the same mechanism as
1857
+ `myList.refresh()` being visible inside `modals/add_site.view`.)
1858
+ 5. The save API returns the **stored row**, so the parent patches exactly one node
1859
+ (`document.querySelector('.row[data-id="'+id+'"]').outerHTML = rowHtml(row)`), or
1860
+ inserts one, or removes one. **Re-rendering the whole list on every save/cancel is
1861
+ the glitch to avoid** — a modal Cancel should touch nothing, a Save should touch
1862
+ one row. After an `outerHTML`/`innerHTML` swap that contains icons, call
1863
+ `Iconify.scan()`; `@click` bindings survive the swap on their own (§5.6).
1864
+
1865
+ `modal="path"` with a bare `close-modal` and `@modal-open`/`@modal-close`/
1866
+ `@modal-beforeclose` handlers is all still §8 — this is just how they're wired for
1867
+ list editing.
1868
+
1869
+ ### 15.6 `rx.*` — no `rx.del`
1870
+
1871
+ The Console wraps only `rx.get`, `rx.post`, `rx.put` (`engine.*` is the same object).
1872
+ **There is no `rx.del` / `rx.delete`.** For a delete, POST to a `/delete` subpath the
1873
+ handler understands (the `_payment` block does this: `/_payment/card/delete`,
1874
+ `/_payment/card/default`, …) — or drop to the raw framework client `API.delete(path)`.
1875
+
1876
+ ### 15.7 Escape anything you build as an HTML string
1877
+
1878
+ Any value interpolated into a `.html(...)` / `insertAdjacentHTML(...)` string must go
1879
+ through an escaper first (`& < > "`). A DKIM key, a php.ini description containing
1880
+ `<?`, a domain with odd characters — all have torn a section apart in the past.
1881
+ Never use `[ctc]`/attribute selectors that match every row for copy affordances — use
1882
+ `.rx-copy` + `data-copy="…"` + `@click="rxCopy"` (global, in `styles.css` / `index.js`).
1883
+
1884
+ ### 15.8 Colour
1885
+
1886
+ Text-colour helpers (in `styles.css`): `_cr` (red / danger), `_cgreen`, `_camber`,
1887
+ `_corange`, `_cgr` (muted grey), `_cdg` (darker grey), `_cpurple`, `_cpink`, `_cb`,
1888
+ `_cw` (white), `_cmain`, `_cp`, `_cg`, `_cgl`, `_cicon`.
1889
+
1890
+ Theme CSS variables worth knowing (don't hard-code hex when one of these fits):
1891
+ `--color-border--dark`, `--color-card-background`, `--color-input--background`,
1892
+ `--color-box-code-background`, `--color-text`, `--color-text-secondary`,
1893
+ `--color-status-indicator--{green,amber,orange,red,grey}`,
1894
+ `--color-status-badge-{green,orange,red}--{background,color}`.
1895
+
1896
+ For a set of category badges (DNS record types, service states…) that each need their
1897
+ own accent, set a single custom prop per class and derive fill/border from it, with an
1898
+ rgba fallback before the `color-mix()`:
1899
+
1900
+ ```css
1901
+ .badge { color: var(--tc, #9aa0a6);
1902
+ background: rgba(138,138,138,.13); border: 1px solid rgba(138,138,138,.38);
1903
+ background: color-mix(in srgb, var(--tc) 14%, transparent);
1904
+ border-color: color-mix(in srgb, var(--tc) 40%, transparent); }
1905
+ .badge-a { --tc: #3b82f6; } /* set --tc on the ROW too, so a hover accent can read it */
1906
+ ```
1907
+
1908
+ ### 15.9 "Editable?" — records the platform owns
1909
+
1910
+ When surfacing platform-managed data for editing (DNS is the worked example), lock the
1911
+ records the system re-derives and would silently break: **SOA** and **`*._domainkey.*`
1912
+ DKIM** (rspamd publishes the key). Leave the ones customers legitimately customise
1913
+ (SPF `@` TXT, DMARC `_dmarc` TXT, apex `NS` — with a warning). Enforce on the server;
1914
+ the `managed:true` flag from the API just drives the UI.