@livenetworks/ashlar 1.5.1 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.md +189 -177
  2. package/js/COMPONENTS.md +55 -49
  3. package/js/index.js +22 -5
  4. package/js/ln-accordion/README.md +186 -137
  5. package/js/ln-accordion/ln-accordion.js +1 -1
  6. package/js/ln-ajax/README.md +73 -4
  7. package/js/ln-ajax/ln-ajax.js +1 -1
  8. package/js/ln-ajax/src/ln-ajax.js +13 -13
  9. package/js/ln-api-connector/README.md +261 -150
  10. package/js/ln-api-connector/ln-api-connector.js +1 -1
  11. package/js/ln-api-connector/src/ln-api-connector.js +368 -265
  12. package/js/ln-api-queue/README.md +219 -0
  13. package/js/ln-api-queue/ln-api-queue.js +1 -0
  14. package/js/ln-api-queue/src/ln-api-queue.js +320 -0
  15. package/js/ln-api-queue/src/queue-storage.js +455 -0
  16. package/js/ln-autoresize/README.md +47 -0
  17. package/js/ln-autoresize/ln-autoresize.js +1 -1
  18. package/js/ln-autosave/README.md +33 -1
  19. package/js/ln-autosave/ln-autosave.js +1 -1
  20. package/js/ln-autosave/src/ln-autosave.js +4 -4
  21. package/js/ln-circular-progress/README.md +27 -13
  22. package/js/ln-circular-progress/ln-circular-progress.js +1 -1
  23. package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
  24. package/js/ln-confirm/README.md +165 -86
  25. package/js/ln-confirm/_ln-confirm.scss +11 -0
  26. package/js/ln-confirm/ln-confirm.js +1 -1
  27. package/js/ln-confirm/src/ln-confirm.js +82 -42
  28. package/js/ln-core/README.md +896 -0
  29. package/js/ln-core/hash.js +92 -0
  30. package/js/ln-core/helpers.js +337 -8
  31. package/js/ln-core/index.js +4 -2
  32. package/js/ln-core/persist.js +29 -3
  33. package/js/ln-core/positioning.js +1 -35
  34. package/js/ln-core/window-cache.js +213 -0
  35. package/js/ln-couchdb-connector/README.md +177 -156
  36. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
  37. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
  38. package/js/ln-data-coordinator/README.md +536 -165
  39. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
  40. package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
  41. package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
  42. package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
  43. package/js/ln-data-store/README.md +241 -94
  44. package/js/ln-data-store/ln-data-store.js +1 -1
  45. package/js/ln-data-store/src/ln-data-store.js +694 -699
  46. package/js/ln-date/README.md +39 -1
  47. package/js/ln-date/ln-date-dev.scss +14 -0
  48. package/js/ln-date/ln-date.js +1 -1
  49. package/js/ln-date/src/ln-date.js +236 -178
  50. package/js/ln-debug/README.md +58 -0
  51. package/js/ln-debug/ln-debug.js +1 -0
  52. package/js/ln-debug/src/ln-debug.js +19 -0
  53. package/js/ln-dropdown/README.md +31 -8
  54. package/js/ln-dropdown/ln-dropdown.js +1 -1
  55. package/js/ln-dropdown/src/ln-dropdown.js +30 -13
  56. package/js/ln-editor/README.md +274 -0
  57. package/js/ln-editor/ln-editor.js +1 -0
  58. package/js/ln-editor/ln-editor.scss +43 -0
  59. package/js/ln-editor/src/ln-editor.js +619 -0
  60. package/js/ln-external-links/README.md +44 -5
  61. package/js/ln-external-links/ln-external-links.js +1 -1
  62. package/js/ln-fill/README.md +199 -0
  63. package/js/ln-fill/ln-fill.js +1 -0
  64. package/js/ln-fill/src/ln-fill.js +87 -0
  65. package/js/ln-filter/README.md +92 -2
  66. package/js/ln-filter/ln-filter-dev.scss +8 -0
  67. package/js/ln-filter/ln-filter.js +1 -1
  68. package/js/ln-filter/src/ln-filter.js +22 -7
  69. package/js/ln-form/README.md +212 -49
  70. package/js/ln-form/ln-form.js +1 -1
  71. package/js/ln-form/src/ln-form.js +99 -199
  72. package/js/ln-http/README.md +125 -89
  73. package/js/ln-http/ln-http.js +1 -1
  74. package/js/ln-http/src/ln-http.js +32 -13
  75. package/js/ln-icon/README.md +130 -0
  76. package/js/ln-icon/ln-icon.js +1 -0
  77. package/js/{ln-icons/src/ln-icons.js → ln-icon/src/ln-icon.js} +20 -15
  78. package/js/ln-link/README.md +47 -9
  79. package/js/ln-link/ln-link.js +1 -1
  80. package/js/ln-link/src/ln-link.js +22 -6
  81. package/js/ln-list/README.md +129 -0
  82. package/js/ln-list/ln-list.js +1 -0
  83. package/js/ln-list/ln-list.scss +28 -0
  84. package/js/ln-list/src/ln-list.js +1216 -0
  85. package/js/ln-modal/README.md +185 -154
  86. package/js/ln-modal/ln-modal-dev.scss +13 -0
  87. package/js/ln-modal/ln-modal.js +1 -1
  88. package/js/ln-modal/ln-modal.scss +28 -4
  89. package/js/ln-modal/src/ln-modal.js +137 -201
  90. package/js/ln-modal-coordinator/README.md +99 -0
  91. package/js/ln-modal-coordinator/ln-modal-coordinator-dev.scss +8 -0
  92. package/js/ln-modal-coordinator/ln-modal-coordinator.js +1 -0
  93. package/js/ln-modal-coordinator/src/ln-modal-coordinator.js +345 -0
  94. package/js/ln-nav/README.md +46 -3
  95. package/js/ln-nav/ln-nav.js +1 -1
  96. package/js/ln-nav/src/ln-nav.js +92 -127
  97. package/js/ln-number/README.md +28 -1
  98. package/js/ln-number/ln-number-dev.scss +13 -0
  99. package/js/ln-number/ln-number.js +1 -1
  100. package/js/ln-number/src/ln-number.js +443 -350
  101. package/js/ln-options/README.md +66 -0
  102. package/js/ln-options/ln-options.js +1 -0
  103. package/js/ln-options/src/ln-options.js +74 -0
  104. package/js/ln-popover/README.md +40 -5
  105. package/js/ln-popover/ln-popover.js +1 -1
  106. package/js/ln-popover/src/ln-popover.js +38 -11
  107. package/js/ln-progress/README.md +413 -442
  108. package/js/ln-progress/ln-progress.js +1 -1
  109. package/js/ln-progress/src/ln-progress.js +98 -150
  110. package/js/ln-router/README.md +432 -0
  111. package/js/ln-router/ln-router.js +1 -0
  112. package/js/ln-router/src/ln-router.js +539 -0
  113. package/js/ln-search/README.md +158 -83
  114. package/js/ln-search/ln-search-dev.scss +8 -0
  115. package/js/ln-search/ln-search.js +1 -1
  116. package/js/ln-search/src/ln-search.js +12 -9
  117. package/js/ln-slug/README.md +110 -0
  118. package/js/ln-slug/ln-slug.js +1 -0
  119. package/js/ln-slug/src/ln-slug.js +74 -0
  120. package/js/ln-sort/README.md +170 -0
  121. package/js/ln-sort/ln-sort-dev.scss +8 -0
  122. package/js/ln-sort/ln-sort.js +1 -0
  123. package/js/ln-sort/src/ln-sort.js +178 -0
  124. package/js/ln-sortable/README.md +133 -95
  125. package/js/ln-sortable/ln-sortable.js +1 -1
  126. package/js/ln-stat/README.md +55 -0
  127. package/js/ln-stat/ln-stat.js +1 -0
  128. package/js/ln-stat/src/ln-stat.js +55 -0
  129. package/js/ln-table/README.md +285 -101
  130. package/js/ln-table/ln-table-dev.scss +17 -0
  131. package/js/ln-table/ln-table.js +1 -1
  132. package/js/ln-table/ln-table.scss +1 -1
  133. package/js/ln-table/src/ln-table.js +1493 -473
  134. package/js/ln-table-coordinator/README.md +73 -0
  135. package/js/ln-table-coordinator/ln-table-coordinator.js +1 -0
  136. package/js/ln-table-coordinator/src/ln-table-coordinator.js +160 -0
  137. package/js/ln-tabs/README.md +221 -137
  138. package/js/ln-tabs/ln-tabs-dev.scss +18 -0
  139. package/js/ln-tabs/ln-tabs.js +1 -1
  140. package/js/ln-tabs/src/ln-tabs.js +41 -24
  141. package/js/ln-time/README.md +14 -0
  142. package/js/ln-time/ln-time.js +1 -1
  143. package/js/ln-time/src/ln-time.js +223 -192
  144. package/js/ln-toast/README.md +178 -122
  145. package/js/ln-toast/ln-toast.js +1 -15
  146. package/js/ln-toast/src/ln-toast.js +232 -210
  147. package/js/ln-toast/template.html +16 -12
  148. package/js/ln-toggle/README.md +61 -2
  149. package/js/ln-toggle/ln-toggle-dev.scss +13 -0
  150. package/js/ln-toggle/ln-toggle.js +1 -1
  151. package/js/ln-toggle/src/ln-toggle.js +56 -43
  152. package/js/ln-tooltip/README.md +95 -58
  153. package/js/ln-tooltip/ln-tooltip-dev.scss +9 -0
  154. package/js/ln-tooltip/ln-tooltip.js +1 -1
  155. package/js/ln-tooltip/src/ln-tooltip.js +45 -6
  156. package/js/ln-translations/README.md +118 -96
  157. package/js/ln-translations/ln-translations.js +1 -1
  158. package/js/ln-translations/src/ln-translations.js +10 -5
  159. package/js/ln-upload/README.md +85 -10
  160. package/js/ln-upload/ln-upload.js +1 -1
  161. package/js/ln-upload/src/ln-upload.js +26 -10
  162. package/js/ln-validate/README.md +72 -0
  163. package/js/ln-validate/ln-validate-dev.scss +15 -0
  164. package/js/ln-validate/ln-validate.js +1 -1
  165. package/js/ln-validate/src/ln-validate.js +64 -0
  166. package/package.json +61 -56
  167. package/scss/base/_global.scss +2 -2
  168. package/scss/base/_typography.scss +1 -2
  169. package/scss/components/_date.scss +1 -0
  170. package/scss/components/_dropdown.scss +0 -5
  171. package/scss/components/_editor.scss +5 -0
  172. package/scss/components/_filter.scss +26 -25
  173. package/scss/components/_form.scss +7 -0
  174. package/scss/components/_ln-table.scss +13 -3
  175. package/scss/components/_sort.scss +10 -0
  176. package/scss/components/_table.scss +36 -23
  177. package/scss/components/_toast.scss +68 -41
  178. package/scss/components/_toggle.scss +16 -0
  179. package/scss/config/_density.scss +205 -117
  180. package/scss/config/_icons.scss +10 -9
  181. package/scss/config/_tokens.scss +69 -47
  182. package/scss/config/mixins/_accordion.scss +2 -7
  183. package/scss/config/mixins/_alert.scss +4 -4
  184. package/scss/config/mixins/_breadcrumbs.scss +2 -2
  185. package/scss/config/mixins/_btn.scss +1 -1
  186. package/scss/config/mixins/_card.scss +89 -14
  187. package/scss/config/mixins/_collapsible.scss +3 -0
  188. package/scss/config/mixins/_colors.scss +1 -1
  189. package/scss/config/mixins/_diagnostics.scss +11 -0
  190. package/scss/config/mixins/_display.scss +1 -0
  191. package/scss/config/mixins/_dropdown.scss +3 -5
  192. package/scss/config/mixins/_editor.scss +124 -0
  193. package/scss/config/mixins/_empty-state.scss +7 -0
  194. package/scss/config/mixins/_filter.scss +3 -5
  195. package/scss/config/mixins/_footer.scss +1 -2
  196. package/scss/config/mixins/_form.scss +207 -21
  197. package/scss/config/mixins/_index.scss +2 -0
  198. package/scss/config/mixins/_ln-table.scss +41 -2
  199. package/scss/config/mixins/_modal.scss +149 -66
  200. package/scss/config/mixins/_nav.scss +3 -4
  201. package/scss/config/mixins/_popover.scss +39 -0
  202. package/scss/config/mixins/_sidebar.scss +1 -1
  203. package/scss/config/mixins/_sort.scss +42 -0
  204. package/scss/config/mixins/_table.scss +85 -60
  205. package/scss/config/mixins/_toast.scss +179 -152
  206. package/scss/config/mixins/_tooltip.scss +17 -7
  207. package/scss/config/mixins/_typography.scss +4 -2
  208. package/scss/ln-ashlar-dev.scss +27 -9
  209. package/scss/ln-ashlar.scss +7 -1
  210. package/js/ln-data-table/README.md +0 -110
  211. package/js/ln-data-table/ln-data-table.js +0 -1
  212. package/js/ln-data-table/ln-data-table.scss +0 -10
  213. package/js/ln-data-table/src/ln-data-table.js +0 -1087
  214. package/js/ln-icons/README.md +0 -88
  215. package/js/ln-icons/ln-icons.js +0 -1
  216. package/js/ln-table/ln-table-sort.js +0 -1
  217. package/js/ln-table/src/ln-table-sort.js +0 -156
@@ -0,0 +1,896 @@
1
+ # ln-core
2
+
3
+ Shared helper module imported by all components — re-exported from
4
+ `js/ln-core/index.js`. No DOM attribute, no constructor, no
5
+ MutationObserver. Source: `js/ln-core/`.
6
+
7
+ ## Helpers
8
+
9
+ ln-core exposes helpers in these categories:
10
+
11
+ - **Templates** — clone and populate `<template>` fragments
12
+ - **Events** — dispatch standard and cancelable CustomEvents; shared data-request cycle for collection components (`requestData`)
13
+ - **DOM binding** — declarative data-attribute-driven rendering (`fill`, `renderList`, `fillTemplate`)
14
+ - **Forms** — serialize/populate by field `name`
15
+ - **Dictionaries** — extract i18n strings from hidden elements
16
+ - **Discovery** — `registerComponent` for MutationObserver-backed auto-init
17
+ - **Reactivity** — `reactiveState`, `deepReactive`, `createBatcher`
18
+ - **Windowing** — sliding-window sparse cache for server-side virtualization (`createWindowCache`)
19
+ - **Layout** — viewport-aware positioning, teleport, measurement
20
+ - **Persistence** — localStorage wrappers with `ln:` prefix
21
+ - **Hash codec** — namespaced URL fragment state (`hashParse`, `hashGet`, `hashSet`)
22
+ - **Cryptography** — high-performance Web Crypto helpers for encryption at rest (`crypto.js`)
23
+
24
+ Source of truth: `js/ln-core/helpers.js`, `js/ln-core/reactive.js`, and `js/ln-core/crypto.js`. Import from `'../ln-core'` (barrel).
25
+
26
+ ---
27
+
28
+ ## helpers.js
29
+
30
+ ### cloneTemplate(name, componentTag)
31
+
32
+ Clone a `<template data-ln-template="name">` element. Cached after first lookup.
33
+
34
+ ```js
35
+ const frag = cloneTemplate('track-item', 'ln-playlist');
36
+ const li = frag.querySelector('[data-ln-track]');
37
+ ```
38
+
39
+ - Returns `DocumentFragment` (result of `tmpl.content.cloneNode(true)`)
40
+ - Returns `null` + console warning if template not found
41
+ - `componentTag` is used only in the warning message
42
+
43
+ ### dispatch(element, eventName, detail)
44
+
45
+ Fire a non-cancelable `CustomEvent` that bubbles.
46
+
47
+ ```js
48
+ dispatch(this.dom, 'ln-toggle:open', { target: this.dom });
49
+ ```
50
+
51
+ - `bubbles: true`, `cancelable: false`
52
+ - `detail` defaults to `{}` if omitted
53
+
54
+ ### dispatchCancelable(element, eventName, detail)
55
+
56
+ Fire a cancelable `CustomEvent` that bubbles. Returns the event object so the caller can check `defaultPrevented`.
57
+
58
+ ```js
59
+ const before = dispatchCancelable(this.dom, 'ln-toggle:before-open', { target: this.dom });
60
+ if (before.defaultPrevented) return;
61
+ ```
62
+
63
+ - `bubbles: true`, `cancelable: true`
64
+ - `detail` defaults to `{}` if omitted
65
+
66
+ ### requestData(component, eventName, keyName)
67
+
68
+ Shared data-request cycle for collection components (`ln-table`, `ln-list`). Runs the local render pass over the hydrated rows, then dispatches a `*:request-data` event so the coordinator can fetch the authoritative dataset.
69
+
70
+ ```js
71
+ requestData(this, 'ln-list:request-data', 'list'); // inside ln-list
72
+ requestData(this, 'ln-table:request-data', 'table'); // inside ln-table
73
+ ```
74
+
75
+ - Render order: `_applyFilterAndSort()` → reset `_vStart`/`_vEnd` to `-1` → `_render()` → `_updateFooter()` → `dispatch`.
76
+ - `eventName` — the `*:request-data` event the component emits.
77
+ - `keyName` — the payload property carrying the component name (`'list'` / `'table'`). The dispatched `detail` is always `{ sort, filters, search, [keyName]: component.name }` — same shape, only the identifier key varies per component.
78
+ - **Instance contract:** `component` must expose `_applyFilterAndSort()`, `_render()`, `_updateFooter()`, `_vStart`, `_vEnd`, `dom`, `name`, `currentSort`, `currentFilters`, `currentSearch`. Both `ln-table` and `ln-list` satisfy it; each defines its own one-line `_requestData` wrapper around this helper.
79
+ - Dispatches via `dispatch` — the emitted event bubbles and is non-cancelable.
80
+
81
+ ### fill(root, data)
82
+
83
+ Declarative DOM binding. Reads `data-ln-*` attributes on descendants and applies values from the `data` object.
84
+
85
+ ```js
86
+ fill(li, { number: 1, title: 'Track', artist: 'Artist', isPlaying: true });
87
+ ```
88
+
89
+ | Attribute | Behavior | Example |
90
+ |-----------|----------|---------|
91
+ | `data-ln-field="prop"` | `el.textContent = data[prop]` | `<span data-ln-field="title"></span>` |
92
+ | `data-ln-attr="attr:prop, attr:prop"` | `el.setAttribute(attr, data[prop])` | `<input data-ln-attr="value:name, placeholder:hint">` |
93
+ | `data-ln-show="prop"` | `el.classList.toggle('hidden', !data[prop])` | `<span data-ln-show="hasError">` |
94
+ | `data-ln-class="cls:prop"` | `el.classList.toggle(cls, !!data[prop])` | `<li data-ln-class="active:isPlaying">` |
95
+
96
+ > [!IMPORTANT]
97
+ > These four are `fill()` **binding attributes, not components** — there is no `ln-field`, `ln-attr`, `ln-show`, or `ln-class` element. They apply only where a component calls `fill()`.
98
+
99
+ - Returns `root` for chaining
100
+ - Skips `null`/`undefined` values (existing content preserved)
101
+ - Nothing calls `fill()` automatically — a component must call it explicitly, and re-call it to update. Renderer pipelines that clone templates (`ln-table` rows, `renderList`'s clone pass) do not call `fill()`; inside those templates use `{{ field }}` instead. `fill()` does not process `{{ }}` placeholders.
102
+
103
+ ### lnFill(container, record)
104
+
105
+ Fan-out helper — dispatches `ln-fill` at every `[data-ln-form]` and
106
+ `[data-ln-fillable]` descendant. Each fillable self-handles: a form fills
107
+ or resets; a display region fills or clears its `[data-ln-field]` elements.
108
+ `record = null` triggers a reset/clear on all targets.
109
+
110
+ ```js
111
+ // At the modal open boundary — form fills, title heading fills, one call
112
+ window.lnCore.lnFill(modalEl, record); // record → fill; null → reset/clear
113
+ ```
114
+
115
+ - Also dispatches `ln-fill` at `container` itself when it matches
116
+ `[data-ln-form]` or `[data-ln-fillable]` — so `lnFill(formEl, record)` works
117
+ when called directly on a form element. Source: `js/ln-core/helpers.js` L164–165.
118
+ - `container` — any element; scans its entire subtree.
119
+ - `record` — plain object or `null`.
120
+ - Dispatches `ln-fill` (`bubbles: true`, `detail = record ?? null`) at each target.
121
+ - Returns `container` for chaining.
122
+ - Exported from `js/ln-core/index.js`; available at `window.lnCore.lnFill`.
123
+ - Back-compat: `ln-form` also still accepts `ln-form:fill` / `ln-form:reset` (aliases).
124
+
125
+ ### fillTemplate(clone, data)
126
+
127
+ Replace `{{ key }}` text-node placeholders in a cloned template fragment with values from `data`. Flat keys only (no nested `{{ item.text }}`).
128
+
129
+ ```js
130
+ const frag = cloneTemplate('filter-item', 'ln-filter');
131
+ fillTemplate(frag, { text: 'Engineering' });
132
+ ```
133
+
134
+ ```html
135
+ <template data-ln-template="filter-item">
136
+ <label><input type="checkbox"> {{ text }}</label>
137
+ </template>
138
+ ```
139
+
140
+ - Walks text nodes via `TreeWalker(clone, NodeFilter.SHOW_TEXT)` (pass 1)
141
+ - In pass 1: replaces `{{ key }}` with `data[key]` (whitespace inside braces is flexible)
142
+ - **Pass 2: element attributes** — iterates every element in the clone (including the
143
+ root element itself when it is an `Element`, since `querySelectorAll` does not include
144
+ root). For each attribute whose value contains `{{`, replaces tokens via
145
+ `el.setAttribute(attr.name, resolved)`. Uses `setAttribute` — never `innerHTML` —
146
+ so injection risk is the same as `data-ln-attr`. Source: `js/ln-core/helpers.js` L197–233.
147
+ - Missing keys produce empty string in both passes
148
+ - No-op in either pass when no `{{` is present
149
+ - Returns `clone` for chaining
150
+ - **Different system from `fill()` — not interchangeable.** `fillTemplate` consumes `{{ key }}` placeholders once, at clone time (in both text nodes and element attribute values); after that the content is plain and never updates. `data-ln-field` is read only by `fill()`, and `fill()` runs only where a component explicitly calls it. Neither function processes the other's syntax.
151
+ - Called automatically by `renderList` on freshly cloned elements only — on keyed re-renders the placeholders are already consumed, so `{{ key }}` values never update. Values that must update on re-render belong in your `fillFn` (e.g. `fill()` + `data-ln-field`).
152
+ - **`ln-table` row templates never call `fill()`** — rows support `{{ field }}` and `data-ln-table-cell-attr` only; a `data-ln-field` inside a row template is silently ignored. Decision matrix: `docs/architecture/data-flow.md` §5.
153
+
154
+ ### renderList(container, items, templateName, keyFn, fillFn, componentTag)
155
+
156
+ Keyed list rendering with DOM reuse. Existing children matched by `data-ln-key` attribute.
157
+
158
+ ```js
159
+ renderList(
160
+ this.listEl,
161
+ state.tracks,
162
+ 'track-item',
163
+ function (track) { return track.id; },
164
+ function (el, track, i) { fill(el, { number: i + 1, title: track.title }); },
165
+ 'ln-playlist'
166
+ );
167
+ ```
168
+
169
+ - `keyFn(item)` returns a unique string key for each item
170
+ - `fillFn(el, item, index)` updates the DOM element with item data
171
+ - Reuses existing elements with matching `data-ln-key` (avoids re-clone)
172
+ - Replaces all container children atomically (`textContent = '' + appendChild(frag)`)
173
+ - Calls `fillTemplate(clone, item)` automatically for newly-cloned elements — `{{ key }}` placeholders in the template resolve from the item data without extra wiring.
174
+
175
+ ### guardBody(setupFn, componentTag)
176
+
177
+ Guard against script loading before `<body>` exists. If `document.body` is `null`, defers `setupFn` to `DOMContentLoaded`.
178
+
179
+ ```js
180
+ guardBody(function () {
181
+ observer.observe(document.body, { ... });
182
+ }, 'ln-toggle');
183
+ ```
184
+
185
+ - Logs a warning suggesting the `defer` attribute on the `<script>` tag
186
+ - Used by every component's `_domObserver()` function
187
+
188
+ ### findElements(root, selector, attribute, ComponentClass)
189
+
190
+ Find all elements with `[selector]` inside `root` (including root itself) and instantiate `ComponentClass` on each one that doesn't already have an instance.
191
+
192
+ ```js
193
+ findElements(node, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
194
+ ```
195
+
196
+ - Checks `el[attribute]` to prevent double-initialization
197
+ - Sets `el[attribute] = new ComponentClass(el)`
198
+ - Used in constructors and MutationObserver callbacks
199
+
200
+ ### cloneTemplateScoped(root, name, componentTag)
201
+
202
+ Clone a `<template>` with scoped lookup — searches inside `root` first, falls back to global `cloneTemplate`.
203
+
204
+ ```js
205
+ const frag = cloneTemplateScoped(this.dom, 'column-filter', 'ln-table');
206
+ ```
207
+
208
+ - First searches for `<template data-ln-template="name">` inside `root`
209
+ - If not found inside `root`, falls back to global `cloneTemplate(name, componentTag)`
210
+ - Returns `DocumentFragment` (same as `cloneTemplate`)
211
+ - Use when a component supports locally-scoped templates that can be overridden per-instance
212
+
213
+ ### buildDict(root, selector)
214
+
215
+ Build a plain object from hidden dictionary elements. Reads all `[selector]` elements once at init, extracts `key → textContent`, removes them from DOM. Returns a plain object for O(1) lookups.
216
+
217
+ ```html
218
+ <ul hidden>
219
+ <li data-ln-upload-dict="remove">{{ __('Remove') }}</li>
220
+ <li data-ln-upload-dict="error">{{ __('Error') }}</li>
221
+ </ul>
222
+ ```
223
+
224
+ ```js
225
+ const dict = buildDict(container, 'data-ln-upload-dict');
226
+ dict['remove'] // 'Remove'
227
+ dict['error'] // 'Error'
228
+ ```
229
+
230
+ - One-shot: reads once, removes elements, returns object
231
+ - Missing keys return `undefined` — caller provides fallback: `dict['key'] || 'default text'`
232
+ - Convention: `data-{component}-dict="key"` on `<li>` elements inside a single `<ul hidden>`
233
+ - Server (Blade, Twig, etc.) translates the text — JS never contains display strings
234
+
235
+ ### isVisible(el)
236
+
237
+ Boolean — true if the element has non-zero layout box (any of
238
+ `offsetWidth`, `offsetHeight`, or `getClientRects().length`).
239
+
240
+ ```js
241
+ if (!isVisible(panel)) return;
242
+ ```
243
+
244
+ - Cheap layout-time check — does NOT compute styles.
245
+ - Returns `false` for elements with `display: none`, detached nodes,
246
+ and zero-sized elements.
247
+
248
+ ### serializeForm(form, opts?)
249
+
250
+ Walk `form.elements`, return a plain object keyed by element `name`.
251
+
252
+ ```js
253
+ const data = serializeForm(this.dom);
254
+ // { username: 'alice', roles: ['admin', 'editor'], country: 'mk' }
255
+
256
+ // Opt-in typed coercion:
257
+ const typed = serializeForm(this.dom, { typed: true });
258
+ // { active: true, count: 5, range: null }
259
+ ```
260
+
261
+ - Skips disabled fields, file inputs, submit / button inputs, and
262
+ unnamed elements.
263
+ - Checkboxes collect as `string[]` under the shared `name`.
264
+ - Radios collect as a single `string` (winning value).
265
+ - `<select multiple>` collects as `string[]`.
266
+ - Everything else collects as the raw `el.value`.
267
+
268
+ **`opts.typed = true` — typed coercion rules:**
269
+
270
+ | Input type | Default (no opts) | With `typed: true` |
271
+ |---|---|---|
272
+ | Single checkbox (unique name in form) | `[]` / `['on']` | `true` / `false` |
273
+ | Checkbox group (multiple same name) | array of checked values | array of checked values |
274
+ | `type="number"` / `type="range"` | `"42"` (string) | `42` (Number), or `null` if empty / NaN |
275
+ | `type="hidden"` | string | string — **never coerced** |
276
+ | Everything else | string | string (unchanged) |
277
+
278
+ The `hidden` branch is explicit to protect `ln-number`'s raw-value
279
+ contract: `ln-number` writes the numeric string to a hidden input, and
280
+ typed mode must not convert it silently.
281
+
282
+ ### populateForm(form, data)
283
+
284
+ Inverse of `serializeForm`. Walks `form.elements`, assigns from `data`
285
+ keyed by `name`. Returns the array of populated elements (caller can
286
+ re-validate them).
287
+
288
+ ```js
289
+ const populated = populateForm(this.dom, { username: 'alice', roles: ['admin'] });
290
+ populated.forEach(function (el) { dispatch(el, 'input'); });
291
+ ```
292
+
293
+ - Skips elements not present as keys in `data`, file inputs, submit /
294
+ button inputs.
295
+ - **Decoupled fill key (`data-ln-fill-as`)** — match key for the fill direction is
296
+ `el.getAttribute('data-ln-fill-as') || el.name`. When `data-ln-fill-as` is set,
297
+ the record key is the fill-as value and `name` stays as the form submission key.
298
+ Source: `populateForm` in `js/ln-core/helpers.js`.
299
+ - **Checkbox string coercion** — a single checkbox (one element with that `name` in
300
+ the form) whose fill value is a string is coerced via `_coerceBool`: `"false"`,
301
+ `"0"`, `""`, `"off"`, `"no"` (case-insensitive, trimmed) → unchecked; anything
302
+ else → checked. This handles `data-ln-fill-*` values which always arrive as
303
+ strings. Source: `_coerceBool` + `populateForm` in `js/ln-core/helpers.js`.
304
+ - Checkbox + array → `checked` if `el.value` is in the array.
305
+ - Checkbox group (same `name`, 2+ elements) + scalar → treated as comma-separated list (`"admin,editor"` → membership check).
306
+ - Single checkbox + scalar → boolean coercion: `"false"/"0"/"off"/"no"/""` → unchecked; anything else → checked.
307
+ - Radio → `checked` if value matches.
308
+ - `<select multiple>` + array → marks matching options selected.
309
+
310
+ ### getLocale(el)
311
+
312
+ Resolve the active locale for an element. Walks ancestors for `[lang]`,
313
+ falls back to `navigator.language`.
314
+
315
+ ```js
316
+ const locale = getLocale(this.dom); // 'mk', 'en-US', ...
317
+ ```
318
+
319
+ - Used by date / number / collator-driven components.
320
+ - Walks via `el.closest('[lang]')` — first ancestor with a `lang`
321
+ attribute wins.
322
+
323
+ ### readValue(el)
324
+
325
+ Read the raw machine value behind a formatted cell/item display. The single
326
+ read path for value-based sort/filter across components.
327
+
328
+ ```js
329
+ const raw = readValue(td); // '1250.50' from data-ln-value, else trimmed text
330
+ ```
331
+
332
+ - Returns `data-ln-value` attribute value if the element has it.
333
+ - Otherwise returns `el.textContent.trim()`.
334
+ - Used by `ln-table` for sort/filter.
335
+
336
+ ### registerComponent(selector, attribute, ComponentFn, componentTag, options)
337
+
338
+ End-to-end component registration. Replaces the hand-rolled IIFE
339
+ boilerplate of `findElements` + `MutationObserver` + `guardBody` +
340
+ DOMContentLoaded + `window[attribute] =` registration with one call.
341
+
342
+ ```js
343
+ import { registerComponent } from '../ln-core';
344
+
345
+ function _component(dom) { this.dom = dom; /* ... */ }
346
+
347
+ registerComponent('data-ln-example', 'lnExample', _component, 'ln-example', {
348
+ extraAttributes: ['data-ln-example-state'],
349
+ onAttributeChange: function (target, name) { /* attribute → state bridge */ },
350
+ onInit: function (root) { /* post-init hook, per added subtree */ }
351
+ });
352
+ ```
353
+
354
+ - `selector` — attribute name (`'data-ln-foo'`) OR a full CSS selector
355
+ if it contains `[`, `.`, or `#` (e.g. `'[data-ln-foo]:not([disabled])'`).
356
+ - `attribute` — JS-side key used both as `window[attribute]` (the
357
+ constructor function) and `el[attribute]` (the per-element instance).
358
+ - `options.extraAttributes` — additional attribute names to include in
359
+ the MutationObserver's `attributeFilter` (e.g. state attributes set
360
+ by coordinator).
361
+
362
+ > **Coverage rule:** `extraAttributes` must include every attribute your `onAttributeChange` handler — and any helper it calls synchronously — reads from the element as an input to what it renders or derives. The observed set is *primary attribute + `extraAttributes`*; an attribute read but not observed will not trigger a re-sync when it changes. Behaviour flags checked only at a transition (e.g. `data-ln-persist`, read at open/close to decide a side-effect) are exempt — a mid-life change to them is not meant to re-render.
363
+ - `options.onAttributeChange(target, attrName)` — called when a
364
+ filtered attribute changes on an already-initialized element. The
365
+ attribute → state bridge hook.
366
+ - `options.onInit(root)` — called after `findElements` per subtree
367
+ (initial DOM, added childList nodes, attribute-mutated subtrees).
368
+ - Returns the constructor function (also stored at `window[attribute]`).
369
+
370
+ ---
371
+
372
+ ## reactive.js
373
+
374
+ ### reactiveState(initial, onChange)
375
+
376
+ Shallow `Proxy` — fires `onChange(prop, value, old)` on every property set. No-op if value hasn't changed.
377
+
378
+ ```js
379
+ const state = reactiveState({ searchTerm: '', sortCol: -1 }, function (prop, value, old) {
380
+ batchRender();
381
+ });
382
+ ```
383
+
384
+ - Returns a Proxy wrapping a shallow copy of `initial`
385
+ - Only tracks direct property assignments (not nested objects)
386
+ - Used by `ln-filter` for flat state
387
+
388
+ ### deepReactive(obj, onChange)
389
+
390
+ Deep `Proxy` — recursively wraps nested objects and arrays. Fires `onChange()` (no args) on any nested mutation.
391
+
392
+ ```js
393
+ const state = deepReactive({ toasts: [], files: [] }, function () {
394
+ batchRender();
395
+ });
396
+ state.toasts.push({ title: 'Hello' }); // triggers onChange
397
+ ```
398
+
399
+ - Wraps existing nested objects on creation
400
+ - Auto-wraps newly assigned objects/arrays
401
+ - `deleteProperty` also triggers `onChange`
402
+ - Internal `Symbol('deepReactive')` flag prevents double-wrapping
403
+ - Used by `ln-toast`, `ln-upload` for complex state
404
+
405
+ ### createBatcher(renderFn, afterRender)
406
+
407
+ Coalesce multiple synchronous state changes into a single `renderFn` call via `queueMicrotask`.
408
+
409
+ ```js
410
+ const batchRender = createBatcher(
411
+ function () { _render(); },
412
+ function () { dispatch(dom, 'ln-component:rendered'); }
413
+ );
414
+ ```
415
+
416
+ - Multiple calls to `schedule()` within the same microtask result in one `renderFn` call
417
+ - Optional `afterRender` callback fires after each render
418
+ - Pattern: `deepReactive(state, batchRender)` — every state mutation schedules a batched render
419
+
420
+ ---
421
+
422
+ ## Reactive Rendering Pattern
423
+
424
+ The three reactive primitives (`reactiveState` / `deepReactive` +
425
+ `createBatcher` + `fill`) are designed to be used together. They are
426
+ the canonical ln-ashlar render loop:
427
+
428
+ 1. **State mutation.** Component code assigns to a property on a
429
+ reactive proxy (`this.state.value = 'x'`). The proxy fires its
430
+ onChange callback.
431
+ 2. **Microtask queue.** The onChange is wired to a `createBatcher`
432
+ schedule function. Any number of synchronous assignments in the
433
+ same sync block schedule a single microtask.
434
+ 3. **One render.** When the microtask fires, the batcher calls
435
+ `renderFn` exactly once. `renderFn` typically calls `fill(this.dom,
436
+ this.state)` (or `renderList` for keyed collections) to project
437
+ state into the DOM, then the optional `afterRender` callback fires
438
+ (commonly used to dispatch a `ln-{component}:rendered` event).
439
+
440
+ Two assignments on the same tick produce one DOM update. External
441
+ callers that need to set several properties at once do not need a
442
+ `batch()` helper — the batcher handles it.
443
+
444
+ The pattern is: **state mutation → microtask queue → one render**.
445
+ Never wire a reactive onChange directly to `_render()` without a
446
+ batcher between them — you will get one render per assignment, and
447
+ the DOM will thrash.
448
+
449
+ For the full decision matrix (`reactiveState` vs `deepReactive`, when
450
+ to use this pattern at all, anti-patterns), see
451
+ [Reactive Rendering Pattern in js/COMPONENTS.md](../../js/COMPONENTS.md#reactive-rendering-pattern).
452
+
453
+ ---
454
+
455
+ ## window-cache.js
456
+
457
+ ### createWindowCache(config)
458
+
459
+ DOM-agnostic sliding-window data engine for server-side virtualization. Owns a sparse cache keyed by logical index, logical/grand totals, request dedup, a stale-response guard (`queryGen`), LRU eviction to a bounded cap, and a debounced fetch trigger.
460
+
461
+ ```js
462
+ const cache = createWindowCache({
463
+ windowSize: 1000,
464
+ pageSize: 200,
465
+ fetchDebounce: 120,
466
+ overscan: 15,
467
+ requestPage: function (query, offset, limit) {
468
+ dispatch(dom, 'ln-table:request-data', {
469
+ table: self.name, sort: query.sort, filters: query.filters,
470
+ search: query.search, offset: offset, limit: limit,
471
+ queryGen: cache.queryGen
472
+ });
473
+ },
474
+ onChange: function () { self._render(); }
475
+ });
476
+
477
+ cache.requestInitial({ sort: null, filters: {}, search: '' });
478
+ ```
479
+
480
+ **Config:**
481
+
482
+ | Key | Default | Purpose |
483
+ |---|---|---|
484
+ | `windowSize` | `1000` | Resident-row cap. LRU-evicted once exceeded. |
485
+ | `pageSize` | `200` | Rows fetched per page. |
486
+ | `fetchDebounce` | `120` | Milliseconds to coalesce `ensure()` calls before firing a fetch. |
487
+ | `overscan` | `15` | Rows padded onto the fetch range (not the render range) on either side. |
488
+ | `requestPage(query, offset, limit)` | no-op | Callback — kick a fetch over any transport. |
489
+ | `onChange()` | no-op | Callback — cache mutated, re-render. |
490
+
491
+ **Returns:**
492
+
493
+ | Method | Signature | Purpose |
494
+ |---|---|---|
495
+ | `get` | `(i) → record \| undefined` | Read a resident row by logical index. |
496
+ | `has` | `(i) → Boolean` | Whether a logical index is resident. |
497
+ | `peek` | `() → record \| undefined` | Any one resident record — lets the render client measure row height before geometry is known. |
498
+ | `logicalTotal` *(getter)* | `Number` | Filtered/queried row count — spacer geometry basis. |
499
+ | `grandTotal` *(getter)* | `Number` | Unfiltered dataset count. |
500
+ | `queryGen` *(getter)* | `Number` | Current query generation — bumped by `invalidate()`. |
501
+ | `size` *(getter)* | `Number` | Resident row count. |
502
+ | `ensure` | `(startRow, endRow)` | Render client hands its visible logical range; stamps in-range resident rows as freshly touched, debounce-fires a fetch for whatever is missing (padded by `overscan`). |
503
+ | `ingest` | `(detail)` | Splice a fetched page into the cache. Drops responses whose `detail.queryGen` doesn't match the current generation. On the first response of a new generation (a pending `invalidate()`/`revalidate()` swap), clears the stale cache contents before splicing. Triggers `onChange()`. |
504
+ | `requestInitial` | `(query)` | First load — fetches page 0 at the current generation (no bump). |
505
+ | `invalidate` | `(query)` | Query change — bumps `queryGen`, refetches page 0. Stale rows and totals stay resident until the new generation's first page lands in `ingest()` (stale-while-revalidate). |
506
+ | `revalidate` | `()` | Post-mutation refresh — bumps `queryGen`, re-requests the page containing the last `ensure()`-reported visible range instead of jumping back to page 0. Same stale-while-revalidate swap as `invalidate()`. |
507
+ | `release` | `(offset)` | Drops `offset` from the in-flight set after a failed page fetch, so the next `ensure()` can re-request it. No re-render, no auto-retry. |
508
+ | `configure` | `(partial)` | Live-mutate any subset of `{windowSize, pageSize, threshold, fetchDebounce}`. Shrinking `windowSize` runs `evict()` then `onChange()`; `pageSize`/`threshold`/`fetchDebounce` apply to the next `ensure()` with no immediate render. |
509
+ | `setGrandTotal` | `(n)` | Sets `grandTotal`; moves `logicalTotal` to `n` too, but only when no `search`/`filters` are active on the current query (a filtered total is server-owned via `ingest`). Fires `onChange()`. |
510
+ | `destroy` | `()` | Clears timers and cache contents. |
511
+
512
+ - LRU eviction drops the least-recently-touched rows first once `map.size` exceeds `windowSize` — a pure data policy, not a viewport-distance heuristic.
513
+ - `queryGen` is the stale-response guard: `ingest()` silently drops any page whose `detail.queryGen` doesn't match the cache's current generation, so an out-of-order response from a superseded query never corrupts the window.
514
+ - Pages splice at their own `offset`, so out-of-order arrivals are safe — order is irrelevant.
515
+ - Pure function core, no DOM — shared by the `ln-table` and `ln-list` windowed paths.
516
+ - `configure`/`setGrandTotal` make the cache's own config live-observable; the owning component (`ln-table`/`ln-list`) wires them to `data-ln-*-window-page` / `-window-threshold` / `-count` attribute changes so no re-init is required.
517
+
518
+ ---
519
+
520
+ ## positioning.js
521
+
522
+ ### computePlacement(anchorRect, floatingSize, preferred, offset)
523
+
524
+ Compute viewport coordinates for a floating element (popover, tooltip,
525
+ dropdown) relative to an anchor rectangle.
526
+
527
+ ```js
528
+ import { computePlacement } from '../ln-core';
529
+
530
+ const rect = trigger.getBoundingClientRect();
531
+ const size = measureHidden(panel);
532
+ const { top, left, placement } = computePlacement(rect, size, 'bottom-end', 8);
533
+ panel.style.top = top + 'px';
534
+ panel.style.left = left + 'px';
535
+ panel.setAttribute('data-ln-placement', placement);
536
+ ```
537
+
538
+ - `anchorRect` — `DOMRect` or any rect-shaped object.
539
+ - `floatingSize` — `{ width, height }`. Use `measureHidden` when the
540
+ panel is currently hidden.
541
+ - `preferred` — `'top' | 'bottom' | 'left' | 'right'` with optional
542
+ `-start` / `-end` alignment (floating-ui-style). Default `'bottom'`.
543
+ - `offset` — gap in pixels between anchor and floating element.
544
+ Default `4`.
545
+ - Returns `{ top, left, placement }`. `placement` is the side that
546
+ won (alignment suffix is preserved internally but not reported in
547
+ the return value).
548
+ - Fallback chain: tries preferred side → opposite side → perpendicular
549
+ pair → clamps to viewport edge if nothing fits.
550
+ - Pure function. No DOM side effects.
551
+
552
+ ### teleportToBody(el)
553
+
554
+ Move an element into `<body>`, leaving a comment placeholder so it can
555
+ be restored to its original parent.
556
+
557
+ ```js
558
+ const restore = teleportToBody(panel);
559
+ // ... later
560
+ restore();
561
+ ```
562
+
563
+ - Returns a cleanup function that restores the element to its origin.
564
+ - No-op + no-op cleanup if the element is already in `<body>` or
565
+ parent is missing.
566
+ - Used for floating UI that needs to escape `overflow: hidden`
567
+ ancestors (popovers, dropdowns).
568
+ - Does NOT set inline styles — the caller's CSS rule (e.g.
569
+ `[data-ln-popover] { position: fixed }`) is responsible for
570
+ positioning context.
571
+
572
+ ### measureHidden(el)
573
+
574
+ Read `offsetWidth` / `offsetHeight` of an element that may currently
575
+ be hidden via `display: none`.
576
+
577
+ ```js
578
+ const { width, height } = measureHidden(panel);
579
+ ```
580
+
581
+ - Temporarily applies `visibility: hidden; display: block; position:
582
+ fixed` to force layout, reads dimensions, then restores the inline
583
+ style values.
584
+ - Returns `{ width: 0, height: 0 }` for a falsy element.
585
+ - Brief inline-style mutation is restored before the function returns
586
+ — the visible DOM never reflects the temporary state.
587
+
588
+ ---
589
+
590
+ ## persist.js
591
+
592
+ ### persistGet(component, el)
593
+
594
+ Read persisted value for an element. Returns the parsed JSON value or `null` (not found, or storage unavailable).
595
+
596
+ - `component` — component name string (e.g. `'toggle'`, `'tabs'`, `'filter'`)
597
+ - `el` — DOM element with `data-ln-persist` attribute
598
+
599
+ Storage key resolved from `el`: uses `data-ln-persist` value if non-empty, otherwise element's `id`. If neither exists, emits `console.warn` and returns `null`.
600
+
601
+ ```js
602
+ import { persistGet } from '../ln-core';
603
+ const saved = persistGet('toggle', el); // null | 'open' | 'close'
604
+ ```
605
+
606
+ ### persistSet(component, el, value)
607
+
608
+ Write a value to localStorage. Value is JSON-serialized. Silently no-ops if localStorage is unavailable or full.
609
+
610
+ ```js
611
+ import { persistSet } from '../ln-core';
612
+ persistSet('toggle', el, 'open');
613
+ persistSet('sort', el, { field: 'name', column: null, direction: 'desc' });
614
+ persistSet('filter', el, null); // explicitly clears
615
+ ```
616
+
617
+ ### persistRemove(component, el)
618
+
619
+ Remove a single persisted value for a specific element.
620
+
621
+ ```js
622
+ import { persistRemove } from '../ln-core';
623
+ persistRemove('tabs', el);
624
+ ```
625
+
626
+ ### persistClear(component)
627
+
628
+ Remove ALL persisted values for a given component type. Scans all localStorage keys matching `ln:{component}:*`.
629
+
630
+ ```js
631
+ import { persistClear } from '../ln-core';
632
+ persistClear('toggle'); // removes all ln:toggle:* keys
633
+ persistClear('sort'); // removes all ln:sort:* keys
634
+ ```
635
+
636
+ Useful for "reset to defaults" functionality.
637
+
638
+ ### Storage key format
639
+
640
+ ```
641
+ ln:{component}:{pagePath}:{id}
642
+ ```
643
+
644
+ - `pagePath` — `location.pathname`, lowercase, trailing slash stripped, or `/` for root
645
+ - `id` — element's `id` attribute, or the explicit `data-ln-persist="custom-key"` value
646
+
647
+ Examples:
648
+ ```
649
+ ln:toggle:/admin/users:sidebar
650
+ ln:tabs:/settings:settings-tabs
651
+ ln:sort:/admin/orders:orders-table-name
652
+ ln:filter:/admin/users:status-filter
653
+ ```
654
+
655
+ ### Opt-in HTML attribute
656
+
657
+ ```html
658
+ <!-- Uses element id as storage key -->
659
+ <section id="sidebar" data-ln-toggle="close" data-ln-persist>
660
+
661
+ <!-- Explicit key (no id needed) -->
662
+ <section data-ln-toggle="close" data-ln-persist="sidebar-section">
663
+ ```
664
+
665
+ Persistence is always opt-in. Elements without `data-ln-persist` are never touched.
666
+
667
+ Components that support `data-ln-persist` document the stored value
668
+ shape in their own READMEs (ln-toggle, ln-accordion, ln-tabs,
669
+ ln-sort, ln-filter).
670
+
671
+ ### Graceful degradation
672
+
673
+ - localStorage disabled (private browsing) → silent no-op, components work normally without persistence
674
+ - localStorage full → silent no-op on write, existing data preserved
675
+ - Stale data (panel removed from DOM, column index out of range) → orphan key ignored gracefully
676
+ - Missing `id` + no explicit key → `console.warn` once, persistence skipped for that element
677
+
678
+ ---
679
+
680
+ ## hash.js
681
+
682
+ Shared URL-fragment codec imported by ln-tabs and ln-modal. All three
683
+ functions are exposed at `window.lnCore.{hashParse, hashGet, hashSet}` for
684
+ coordinators.
685
+
686
+ ### Grammar
687
+
688
+ Fragments use the format `#nsA:valA&nsB:valB` — one namespace per component
689
+ instance, `&`-separated. Foreign segments (belonging to other components) are
690
+ preserved on every write. A bare `#ns` (no `:`) encodes an empty value.
691
+
692
+ ### hashParse(str = location.hash) → { ns: value }
693
+
694
+ Parse a hash string into a plain object of `{ namespace: value }` pairs.
695
+
696
+ - Strips the leading `#` before processing.
697
+ - Tolerates empty or malformed input (returns `{}`).
698
+ - Each value is `decodeURIComponent`-decoded.
699
+ - Bare `#ns` (no `:`) → `{ ns: '' }` (empty string, not `null`).
700
+
701
+ ### hashGet(ns) → string | null
702
+
703
+ Read the current value for a namespace from `location.hash`. Returns the
704
+ decoded string value if the segment is present (including `''` for bare `#ns`),
705
+ or `null` if the namespace is absent.
706
+
707
+ ### hashSet(ns, value)
708
+
709
+ Read-modify-write `location.hash`, updating ONLY `ns` and preserving all other
710
+ segments. Uses `location.hash =` (not `pushState`) so the browser adds a history
711
+ entry and fires `hashchange`.
712
+
713
+ THREE-STATE write:
714
+
715
+ | `value` | Effect |
716
+ |---|---|
717
+ | `null` | Removes the `ns` segment entirely |
718
+ | `''` (empty string) | Writes a bare `#ns` (no `:`) |
719
+ | any other string | Writes `#ns:encodeURIComponent(value)` |
720
+
721
+ **Identical value is a no-op** — if the resulting hash string equals the
722
+ current `location.hash`, the assignment is skipped. This prevents spurious
723
+ `hashchange` events and the loops they could cause.
724
+
725
+ ### Usage note
726
+
727
+ Both ln-tabs (anchor-trigger groups) and ln-modal (hash-bound modals) use this
728
+ codec. Because `hashSet` is a read-modify-write that preserves foreign segments,
729
+ switching a tab never clobbers an open modal's hash segment and vice versa —
730
+ codec isolation is guaranteed by design.
731
+
732
+ See also: [Hash-state doctrine](../../docs/architecture/hash-state.md) — the five rules governing namespace ownership, foreign-segment preservation, anchor interception, coordinator wiring, and the router fragment guard.
733
+
734
+ ---
735
+
736
+ ## crypto.js
737
+
738
+ Built-in cryptographic utilities using the native browser **Web Crypto API** (AES-GCM 256-bit envelope encryption). These are transparently integrated into the data store for encryption at rest.
739
+
740
+ ### setCryptoKey(secretString)
741
+
742
+ Derive a 256-bit cryptographically strong key from a password or token and activate it framework-wide.
743
+
744
+ ```js
745
+ import { setCryptoKey } from '../ln-core';
746
+
747
+ // Typically called on user login or application bootstrap
748
+ await setCryptoKey('my-super-secret-user-passphrase-or-session-token');
749
+ ```
750
+
751
+ - **`secretString`** — raw string value. Passing `null`, `undefined`, or an empty string deactivates encryption.
752
+ - **Returns** `Promise<void>`. Resolves when the key is successfully derived and imported.
753
+
754
+ ### getCryptoKey()
755
+
756
+ Retrieve the currently active `CryptoKey` object instance.
757
+
758
+ ```js
759
+ import { getCryptoKey } from '../ln-core';
760
+
761
+ const key = getCryptoKey(); // null or CryptoKey
762
+ ```
763
+
764
+ - **Returns** `CryptoKey` or `null` if no key has been derived yet.
765
+
766
+ ### encryptData(plainData, key = null)
767
+
768
+ Encrypt any serializable value (objects/arrays are converted to JSON strings).
769
+
770
+ ```js
771
+ import { encryptData } from '../ln-core';
772
+
773
+ const result = await encryptData({ text: 'Sensitive message' });
774
+ // {
775
+ // encrypted: true,
776
+ // iv: 'base64-iv-string...',
777
+ // data: 'base64-ciphertext...'
778
+ // }
779
+ ```
780
+
781
+ - **`plainData`** — any primitive value, array, or object.
782
+ - **`key`** — optional custom `CryptoKey`. Defaults to the active global key.
783
+ - **Returns** `Promise<Object>` containing `encrypted: true`, base64 `iv`, and base64 `data`. Returns raw `plainData` if no active key is available.
784
+
785
+ ### decryptData(encryptedObject, key = null)
786
+
787
+ Decrypt an encrypted object back into its original form (automatically parsing JSON strings back to objects).
788
+
789
+ ```js
790
+ import { decryptData } from '../ln-core';
791
+
792
+ const decrypted = await decryptData(result);
793
+ console.log(decrypted.text); // 'Sensitive message'
794
+ ```
795
+
796
+ - **`encryptedObject`** — an object generated by `encryptData`.
797
+ - **`key`** — optional custom `CryptoKey`. Defaults to the active global key.
798
+ - **Returns** `Promise<any>`. The original plain data, or `{ ...encryptedObject, decryptionError: true }` if decryption failed (e.g., incorrect key).
799
+
800
+ ---
801
+
802
+ ## Typical Usage Pattern
803
+
804
+ ```js
805
+ import { dispatch, dispatchCancelable, guardBody, findElements, deepReactive, createBatcher, fill, renderList } from '../ln-core';
806
+
807
+ (function () {
808
+ const DOM_SELECTOR = 'data-ln-example';
809
+ const DOM_ATTRIBUTE = 'lnExample';
810
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
811
+
812
+ function constructor(domRoot) {
813
+ findElements(domRoot, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
814
+ }
815
+
816
+ function _component(dom) {
817
+ this.dom = dom;
818
+ const self = this;
819
+
820
+ // Reactive state + batched rendering
821
+ const batchRender = createBatcher(function () { self._render(); });
822
+ this.state = deepReactive({ items: [] }, batchRender);
823
+
824
+ return this;
825
+ }
826
+
827
+ _component.prototype._render = function () {
828
+ renderList(this.dom, this.state.items, 'example-item',
829
+ function (item) { return item.id; },
830
+ function (el, item) { fill(el, item); },
831
+ 'ln-example'
832
+ );
833
+ };
834
+
835
+ // ... observer, init ...
836
+ window[DOM_ATTRIBUTE] = constructor;
837
+ })();
838
+ ```
839
+
840
+ ---
841
+
842
+ ## The `data-ln-value` Primitive
843
+
844
+ A locale-formatted cell/item shows a human-readable string; the raw machine
845
+ value lives in `data-ln-value`. Sort and filter operate on the raw value —
846
+ **never** on the formatted text.
847
+
848
+ ```html
849
+ <td data-ln-value="1250.50">$1,250.50</td>
850
+ <td data-ln-value="1700000000">15 Nov 2023</td>
851
+ ```
852
+
853
+ For details on using `data-ln-value` alongside semantic HTML5 elements like `<data>` and `<time>`, see the [HTML Markup Rules](file:///c:/laragon/www/ln-ashlar/docs-mcp/doctrine/html-markup-rules.md#L65-L79).
854
+
855
+ ### Raw format
856
+
857
+ - **Amounts** — plain number, dot decimal, NO thousands grouping: `1250.50`.
858
+ - **Dates** — numeric Unix timestamp, consistent per column (demo uses seconds,
859
+ e.g. `1700000000`).
860
+
861
+ ### Why (the gotcha)
862
+
863
+ Sort coerces with `parseFloat(raw) || 0`. Formatted display text breaks it:
864
+
865
+ | Formatted text | `parseFloat` sees | Correct raw |
866
+ |----------------|-------------------|-------------|
867
+ | `1.250` (EU grouping) | `1.25` ❌ | `1250` |
868
+ | `15.11.2023` | `15.11` ❌ | `1700000000` (timestamp) |
869
+ | `2026-04-12` | `2026` ❌ | timestamp |
870
+
871
+ ### The split
872
+
873
+ - `data-ln-value` — the VALUE. Universal. Read by `ln-core.readValue`.
874
+ - **Type is inferred, not declared.** `ln-core.detectValueType` scans the current value set once
875
+ per sort operation — no `data-ln-*-sort` type attribute exists anymore. All non-empty values
876
+ finite numbers → numeric compare; otherwise `ln-core.compareValues` + `Intl.Collator` string
877
+ compare. See `js/ln-sort/README.md`.
878
+
879
+ ### The reader
880
+
881
+ `ln-core.readValue(el)` is the single extraction path — that is what makes the
882
+ attribute cross-component. `ln-table` reads through it today.
883
+
884
+ ### Formatting responsibility
885
+
886
+ The client never re-formats — it only reorders/filters by raw. Formatting is
887
+ the server's job: Blade/PHP in SSR mode, or the API payload in data-driven mode.
888
+ `<html lang>` drives `Intl.Collator` for string sort.
889
+
890
+ ### Per mode
891
+
892
+ - **SSR** — the raw value lives in `data-ln-value` on the element. `readValue`
893
+ pulls it during `_parseRows`.
894
+ - **Data-driven** — the raw value lives in the record field (API payload); the
895
+ rendered element may also carry `data-ln-value`. Sorting runs on the record
896
+ field. Never sort formatted text.