@livenetworks/ashlar 2.0.7 → 2.0.8

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 (207) hide show
  1. package/LICENSE +641 -641
  2. package/components/COMPONENTS.md +1116 -1116
  3. package/components/index.js +81 -81
  4. package/components/ln-accordion/ln-accordion-dev.scss +8 -8
  5. package/components/ln-accordion/ln-accordion.js +1 -1
  6. package/components/ln-accordion/ln-accordion.schema.json +30 -30
  7. package/components/ln-ajax/ln-ajax-dev.scss +8 -8
  8. package/components/ln-ajax/ln-ajax.js +1 -1
  9. package/components/ln-ajax/ln-ajax.schema.json +30 -30
  10. package/components/ln-api-connector/ln-api-connector.js +1 -1
  11. package/components/ln-api-connector/ln-api-connector.schema.json +96 -96
  12. package/components/ln-api-connector/src/ln-api-connector.js +1 -0
  13. package/components/ln-api-queue/ln-api-queue.js +1 -1
  14. package/components/ln-api-queue/ln-api-queue.schema.json +34 -34
  15. package/components/ln-api-queue/src/ln-api-queue.js +3 -13
  16. package/components/ln-autoresize/ln-autoresize-dev.scss +8 -8
  17. package/components/ln-autoresize/ln-autoresize.js +1 -1
  18. package/components/ln-autoresize/ln-autoresize.schema.json +22 -22
  19. package/components/ln-autosave/ln-autosave-dev.scss +13 -13
  20. package/components/ln-autosave/ln-autosave.js +1 -1
  21. package/components/ln-autosave/ln-autosave.schema.json +48 -48
  22. package/components/ln-autosave/src/autosave-model.js +34 -34
  23. package/components/ln-chart/ln-chart.js +1 -1
  24. package/components/ln-chart/ln-chart.schema.json +146 -146
  25. package/components/ln-chart/src/chart-model.js +154 -154
  26. package/components/ln-circular-progress/ln-circular-progress.js +1 -1
  27. package/components/ln-circular-progress/ln-circular-progress.schema.json +42 -42
  28. package/components/ln-confirm/ln-confirm-dev.scss +24 -24
  29. package/components/ln-confirm/ln-confirm.js +1 -1
  30. package/components/ln-confirm/ln-confirm.schema.json +66 -66
  31. package/components/ln-confirm/src/ln-confirm.js +178 -178
  32. package/components/ln-core/crypto.js +286 -265
  33. package/components/ln-core/dom.js +115 -115
  34. package/components/ln-core/form.js +136 -136
  35. package/components/ln-core/helpers.js +33 -33
  36. package/components/ln-core/http.js +48 -48
  37. package/components/ln-core/index.js +1 -1
  38. package/components/ln-core/ln-core.schema.json +109 -109
  39. package/components/ln-core/locale.js +55 -55
  40. package/components/ln-core/matching.js +78 -78
  41. package/components/ln-core/number.js +125 -125
  42. package/components/ln-core/template.js +206 -206
  43. package/components/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
  44. package/components/ln-couchdb-connector/ln-couchdb-connector.schema.json +50 -50
  45. package/components/ln-couchdb-connector/src/ln-couchdb-connector.js +1 -0
  46. package/components/ln-data-coordinator/README.md +9 -38
  47. package/components/ln-data-coordinator/ln-data-coordinator-dev.scss +8 -8
  48. package/components/ln-data-coordinator/ln-data-coordinator.js +1 -1
  49. package/components/ln-data-coordinator/ln-data-coordinator.schema.json +16 -16
  50. package/components/ln-data-coordinator/src/data-read-policy.js +0 -7
  51. package/components/ln-data-coordinator/src/ln-data-coordinator.js +428 -1308
  52. package/components/ln-data-store/ln-data-store-dev.scss +13 -13
  53. package/components/ln-data-store/ln-data-store.js +1 -1
  54. package/components/ln-data-store/ln-data-store.schema.json +84 -84
  55. package/components/ln-data-store/src/data-store-model.js +184 -184
  56. package/components/ln-data-store/src/ln-data-store.js +1 -1
  57. package/components/ln-date/ln-date.js +1 -1
  58. package/components/ln-date/ln-date.schema.json +92 -92
  59. package/components/ln-date/src/date-model.js +186 -186
  60. package/components/ln-debug/ln-debug.js +1 -1
  61. package/components/ln-debug/ln-debug.schema.json +28 -28
  62. package/components/ln-debug/src/console-sink.js +19 -19
  63. package/components/ln-debug/src/gate.js +95 -95
  64. package/components/ln-debug/src/generated-attributes.js +2 -2
  65. package/components/ln-dropdown/ln-dropdown-dev.scss +13 -13
  66. package/components/ln-dropdown/ln-dropdown.js +1 -1
  67. package/components/ln-dropdown/ln-dropdown.schema.json +90 -90
  68. package/components/ln-editor/ln-editor.js +1 -1
  69. package/components/ln-editor/ln-editor.schema.json +58 -58
  70. package/components/ln-external-links/ln-external-links.js +1 -1
  71. package/components/ln-external-links/ln-external-links.schema.json +18 -18
  72. package/components/ln-fill/ln-fill.js +1 -1
  73. package/components/ln-fill/ln-fill.schema.json +23 -23
  74. package/components/ln-filter/ln-filter.js +1 -1
  75. package/components/ln-filter/ln-filter.schema.json +153 -153
  76. package/components/ln-filter/src/filter-model.js +185 -185
  77. package/components/ln-form/ln-form-dev.scss +8 -8
  78. package/components/ln-form/ln-form.js +1 -1
  79. package/components/ln-form/ln-form.schema.json +45 -45
  80. package/components/ln-http/ln-http.js +1 -1
  81. package/components/ln-http/ln-http.schema.json +6 -6
  82. package/components/ln-icon/ln-icon.schema.json +6 -6
  83. package/components/ln-include/ln-include.js +1 -1
  84. package/components/ln-include/ln-include.schema.json +23 -23
  85. package/components/ln-key/ln-key-dev.scss +18 -18
  86. package/components/ln-key/ln-key.js +1 -1
  87. package/components/ln-key/ln-key.schema.json +56 -56
  88. package/components/ln-key/src/ln-key.js +10 -10
  89. package/components/ln-link/ln-link-dev.scss +8 -8
  90. package/components/ln-link/ln-link.js +1 -1
  91. package/components/ln-link/ln-link.schema.json +22 -22
  92. package/components/ln-list/README.md +152 -152
  93. package/components/ln-list/ln-list-dev.scss +8 -8
  94. package/components/ln-list/ln-list.js +1 -1
  95. package/components/ln-list/ln-list.schema.json +166 -166
  96. package/components/ln-modal/ln-modal.js +1 -1
  97. package/components/ln-modal/ln-modal.schema.json +65 -65
  98. package/components/ln-nav/ln-nav.js +1 -1
  99. package/components/ln-nav/ln-nav.schema.json +25 -25
  100. package/components/ln-number/ln-number.js +1 -1
  101. package/components/ln-number/ln-number.schema.json +63 -63
  102. package/components/ln-number/src/number-model.js +31 -31
  103. package/components/ln-obfuscator/README.md +60 -60
  104. package/components/ln-obfuscator/ln-obfuscator.js +1 -1
  105. package/components/ln-obfuscator/ln-obfuscator.schema.json +49 -49
  106. package/components/ln-obfuscator/src/ln-obfuscator.js +159 -159
  107. package/components/ln-obfuscator/src/obfuscator-model.js +250 -250
  108. package/components/ln-options/ln-options.js +1 -1
  109. package/components/ln-options/ln-options.schema.json +41 -41
  110. package/components/ln-persist/README.md +151 -151
  111. package/components/ln-persist/ln-persist.js +1 -1
  112. package/components/ln-persist/ln-persist.schema.json +28 -28
  113. package/components/ln-persist/src/ln-persist.js +83 -83
  114. package/components/ln-picklist/README.md +230 -230
  115. package/components/ln-picklist/ln-picklist-dev.scss +22 -22
  116. package/components/ln-picklist/ln-picklist.js +1 -1
  117. package/components/ln-picklist/ln-picklist.schema.json +49 -49
  118. package/components/ln-picklist/src/ln-picklist.js +228 -228
  119. package/components/ln-popover/ln-popover-dev.scss +13 -13
  120. package/components/ln-popover/ln-popover.js +1 -1
  121. package/components/ln-popover/ln-popover.schema.json +82 -82
  122. package/components/ln-progress/ln-progress-dev.scss +8 -8
  123. package/components/ln-progress/ln-progress.js +1 -1
  124. package/components/ln-progress/ln-progress.schema.json +40 -40
  125. package/components/ln-router/ln-router.js +1 -1
  126. package/components/ln-router/ln-router.schema.json +76 -76
  127. package/components/ln-scroll/README.md +45 -45
  128. package/components/ln-scroll/ln-scroll.js +1 -1
  129. package/components/ln-scroll/ln-scroll.schema.json +85 -85
  130. package/components/ln-scroll/src/ln-scroll.js +150 -150
  131. package/components/ln-search/README.md +188 -188
  132. package/components/ln-search/ln-search.js +1 -1
  133. package/components/ln-search/ln-search.schema.json +103 -103
  134. package/components/ln-slug/ln-slug.js +1 -1
  135. package/components/ln-slug/ln-slug.schema.json +23 -23
  136. package/components/ln-sort/ln-sort.js +1 -1
  137. package/components/ln-sort/ln-sort.schema.json +104 -104
  138. package/components/ln-sort/src/ln-sort.js +321 -321
  139. package/components/ln-sort/src/sort-model.js +117 -117
  140. package/components/ln-sortable/ln-sortable-dev.scss +13 -13
  141. package/components/ln-sortable/ln-sortable.js +1 -1
  142. package/components/ln-sortable/ln-sortable.schema.json +36 -36
  143. package/components/ln-stat/ln-stat-dev.scss +8 -8
  144. package/components/ln-stat/ln-stat.js +1 -1
  145. package/components/ln-stat/ln-stat.schema.json +32 -32
  146. package/components/ln-stat/src/stat-model.js +28 -28
  147. package/components/ln-table/ln-table.js +1 -1
  148. package/components/ln-table/ln-table.schema.json +211 -211
  149. package/components/ln-table/src/table-model.js +98 -98
  150. package/components/ln-table-coordinator/ln-table-coordinator.js +1 -1
  151. package/components/ln-table-coordinator/ln-table-coordinator.schema.json +93 -93
  152. package/components/ln-tabs/ln-tabs.js +1 -1
  153. package/components/ln-tabs/ln-tabs.schema.json +75 -75
  154. package/components/ln-tabs/src/ln-tabs.js +208 -208
  155. package/components/ln-tabs/src/tabs-model.js +82 -82
  156. package/components/ln-time/ln-time-dev.scss +13 -13
  157. package/components/ln-time/ln-time.js +1 -1
  158. package/components/ln-time/ln-time.schema.json +38 -38
  159. package/components/ln-toast/ln-toast.js +1 -1
  160. package/components/ln-toast/ln-toast.schema.json +65 -65
  161. package/components/ln-toggle/ln-toggle.js +1 -1
  162. package/components/ln-toggle/ln-toggle.schema.json +52 -52
  163. package/components/ln-tooltip/ln-tooltip.js +1 -1
  164. package/components/ln-tooltip/ln-tooltip.schema.json +86 -86
  165. package/components/ln-translations/ln-translations.js +1 -1
  166. package/components/ln-translations/ln-translations.schema.json +120 -120
  167. package/components/ln-ui-coordinator/README.md +62 -62
  168. package/components/ln-ui-coordinator/ln-ui-coordinator.js +1 -1
  169. package/components/ln-ui-coordinator/ln-ui-coordinator.schema.json +28 -28
  170. package/components/ln-ui-coordinator/src/ln-ui-coordinator.js +140 -140
  171. package/components/ln-upload/ln-upload.js +1 -1
  172. package/components/ln-upload/ln-upload.schema.json +193 -193
  173. package/components/ln-upload/src/upload-model.js +78 -78
  174. package/components/ln-validate/ln-validate.js +1 -1
  175. package/components/ln-validate/ln-validate.schema.json +50 -50
  176. package/components/ln-validate/src/validate-model.js +64 -64
  177. package/components/ln-websocket-connector/README.md +166 -166
  178. package/components/ln-websocket-connector/ln-websocket-connector.js +1 -1
  179. package/components/ln-websocket-connector/ln-websocket-connector.schema.json +29 -29
  180. package/components/ln-websocket-connector/src/ln-websocket-connector.js +283 -279
  181. package/ln-ashlar.html-data.json +7 -8
  182. package/package.json +84 -84
  183. package/theme/brand.css +73 -73
  184. package/theme/components/_confirm.scss +6 -6
  185. package/theme/components/_picklist.scss +3 -3
  186. package/theme/config/_palette.scss +274 -274
  187. package/theme/config/_theme.scss +292 -292
  188. package/theme/config/_tokens.scss +285 -285
  189. package/theme/config/mixins/_index.scss +62 -62
  190. package/theme/config/mixins/_picklist.scss +66 -66
  191. package/theme/ln-ashlar-dev.scss +66 -66
  192. package/theme/ln-ashlar-landing.scss +56 -56
  193. package/theme/ln-ashlar-theme.scss +63 -63
  194. package/theme/presentation/_bento.scss +151 -151
  195. package/theme/presentation/_cta.scss +111 -111
  196. package/theme/presentation/_effects.scss +216 -216
  197. package/theme/presentation/_features.scss +108 -108
  198. package/theme/presentation/_footer.scss +129 -129
  199. package/theme/presentation/_hero.scss +158 -158
  200. package/theme/presentation/_layout.scss +170 -170
  201. package/theme/presentation/_navbar.scss +140 -140
  202. package/theme/presentation/_pricing.scss +262 -262
  203. package/theme/presentation/_stats.scss +87 -87
  204. package/theme/presentation/_testimonials.scss +124 -124
  205. package/theme/presentation/_tokens.scss +39 -39
  206. package/theme/presentation/_typography.scss +163 -163
  207. package/web-types.json +12 -12
@@ -1,1116 +1,1116 @@
1
- # JS Components — Conventions and Patterns
2
-
3
- ## ln-ashlar Project Architecture (mandatory)
4
-
5
- Every project using ln-ashlar JS components **MUST** follow three layers:
6
-
7
- ```
8
- ┌─────────────────────────────────────────────────────┐
9
- │ Coordinator (project-specific) │
10
- │ Catches UI actions → dispatches request events → │
11
- │ reacts to notification events with UI feedback │
12
- ├─────────────────────────────────────────────────────┤
13
- │ Components (reusable) │
14
- │ State + CRUD + request listeners + notifications │
15
- ├─────────────────────────────────────────────────────┤
16
- │ ln-ashlar (library) │
17
- │ ln-toggle, ln-accordion, ln-modal, ln-toast... │
18
- └─────────────────────────────────────────────────────┘
19
- ```
20
-
21
- ### Three Rules
22
-
23
- 1. **Component = data layer.** Manages state, CRUD, its own DOM. Does NOT open modals, does NOT show toast, does NOT read external forms.
24
- 2. **Coordinator = UI wiring.** Catches buttons/forms, dispatches request events to components, reacts to notification events with UI feedback (toast, modal, highlight).
25
- 3. **Commands → request events. Queries → direct API.** The coordinator NEVER calls prototype methods for state mutations (`el.lnProfile.create()`). ALWAYS dispatches a request event (`ln-profile:request-create`). Reading state directly is allowed (`el.lnProfile.currentId`).
26
-
27
- ### Event flow
28
-
29
- ```
30
- [User clicks button]
31
- ↓
32
- [Coordinator] catches click on [data-ln-action="new-profile"]
33
- ↓
34
- [Coordinator] reads input, dispatches request event:
35
- nav.dispatchEvent('ln-profile:request-create', { detail: { name } })
36
- ↓
37
- [Component ln-profile] listens for request-create, calls self.create(name)
38
- ↓
39
- [Component] changes state, renders DOM, dispatches notification:
40
- _dispatch(dom, 'ln-profile:created', { profileId, profile })
41
- ↓
42
- [Coordinator] listens for ln-profile:created → shows toast, closes modal
43
- ```
44
-
45
- ### Workflow for new functionality
46
-
47
- 1. **Component**: add prototype method (clean — accept params, change state, dispatch notification event)
48
- 2. **Component**: add request listener in `_bindEvents` (calls the same prototype method)
49
- 3. **Coordinator**: add UI trigger (click / form submit → dispatch request event to component)
50
- 4. **Coordinator**: add UI reaction (listen to notification event → toast / modal / highlight)
51
-
52
- ### Test: "Is it a component or coordinator?"
53
-
54
- | Question | If YES → | If NO → |
55
- |---------|----------|----------|
56
- | Changes its own state (CRUD)? | component | coordinator |
57
- | Renders its own DOM (list, buttons)? | component | coordinator |
58
- | Opens modal / shows toast? | coordinator | component |
59
- | Reads input from form? | coordinator | component |
60
- | Listens to `[data-ln-action="..."]` click? | coordinator | component |
61
- | Listens to click on **its own child** element? | component | coordinator |
62
- | Bridges two components (A:event → B:attribute)? | coordinator | — |
63
-
64
- ---
65
-
66
- ## IIFE Pattern (mandatory)
67
-
68
- Every component is an IIFE (Immediately Invoked Function Expression). Shared helpers are imported from `ln-core`; the IIFE body has no exports.
69
-
70
- ```javascript
71
- import { dispatch } from '../ln-core';
72
- import { deepReactive, createBatcher } from '../ln-core';
73
-
74
- (function () {
75
- const DOM_SELECTOR = 'data-ln-{name}';
76
- const DOM_ATTRIBUTE = 'ln{Name}';
77
-
78
- // Guard against double loading
79
- if (window[DOM_ATTRIBUTE] !== undefined) return;
80
-
81
- // ... component ...
82
-
83
- window[DOM_ATTRIBUTE] = constructor;
84
- })();
85
- ```
86
-
87
- ### ln-core shared helpers
88
-
89
- | Import | Purpose |
90
- |--------|---------|
91
- | `findElements(root, selector, attr, Constructor)` | Find all `[selector]` under root, skip initialized, instantiate |
92
- | `dispatch(el, name, detail)` | Fire non-cancelable CustomEvent |
93
- | `dispatchCancelable(el, name, detail)` | Fire cancelable CustomEvent, returns event |
94
- | `cloneTemplate(name, tag)` | Clone `<template data-ln-template="name">`, cached |
95
- | `fill(root, data)` | Declarative DOM binding via `data-ln-field`, `data-ln-attr`, `data-ln-show`, `data-ln-class` |
96
- | `fillTemplate(clone, data)` | Replace `{{ key }}` text-node placeholders in cloned template with `data[key]` values |
97
- | `buildDict(root, selector)` | Read hidden i18n elements once at init, return plain object, remove from DOM |
98
- | `renderList(container, items, tpl, keyFn, fillFn, tag)` | Keyed list rendering with DOM reuse |
99
- | `reactiveState(initial, onChange)` | Shallow Proxy — onChange(prop, value, old) per set |
100
- | `deepReactive(obj, onChange)` | Deep Proxy — onChange() on any nested change |
101
- | `createBatcher(renderFn, afterRender)` | Coalesce multiple sync state changes into one render |
102
-
103
- Import only what the component needs. Vite tree-shakes unused exports.
104
-
105
- ---
106
-
107
- ## Instance-based Pattern (recommended)
108
-
109
- The component is **attached to a DOM element**. The API lives on the element, NOT on `window`.
110
-
111
- ```javascript
112
- import { findElements } from '../ln-core';
113
-
114
- // window[DOM_ATTRIBUTE] is just the constructor function
115
- function constructor(domRoot) {
116
- findElements(domRoot, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
117
- }
118
-
119
- function _component(dom) {
120
- this.dom = dom;
121
- // ... init ...
122
- }
123
-
124
- // Prototype methods = public API
125
- _component.prototype.open = function () { ... };
126
- _component.prototype.close = function () { ... };
127
- ```
128
-
129
- **Usage:**
130
- ```javascript
131
- // Attribute is the contract — write data-ln-toggle to change state
132
- document.getElementById('sidebar').setAttribute('data-ln-toggle', 'open');
133
- document.getElementById('sidebar').setAttribute('data-ln-toggle', 'close');
134
-
135
- // Constructor — only for non-standard cases (Shadow DOM, iframe)
136
- // Dynamic AJAX HTML does NOT require manual init — MutationObserver handles it automatically
137
- window.lnToggle(container);
138
- ```
139
-
140
- ---
141
-
142
- ## Attribute Bridge (mandatory)
143
-
144
- Instance methods that change component state **must only call `setAttribute`**. The MutationObserver observes the attribute change and applies the actual state. Direct DOM manipulation inside instance methods is forbidden.
145
-
146
- ```javascript
147
- // CORRECT — method is a thin setAttribute wrapper
148
- _component.prototype.open = function () {
149
- if (this.isOpen) return;
150
- this.dom.setAttribute(DOM_SELECTOR, 'open');
151
- // MutationObserver fires → _syncAttribute() applies state
152
- };
153
-
154
- // WRONG — method bypasses the attribute and manipulates state directly
155
- _component.prototype.open = function () {
156
- this.isOpen = true;
157
- this.dom.classList.add('open');
158
- document.addEventListener('keydown', this._onEscape);
159
- dispatch(this.dom, 'ln-component:open', { target: this.dom });
160
- };
161
- ```
162
-
163
- **Why:** The attribute is the single source of truth. Any external code can trigger the same state change with `el.setAttribute(...)` and get identical behaviour. The component never has two code paths to the same state.
164
-
165
- **Rule:** If a prototype method changes state, its entire body is `this.dom.setAttribute(...)`. All state logic lives in `_syncAttribute()` (or equivalent), called only by the MutationObserver.
166
-
167
- ### Off-Doctrine: Checkbox State (The "Checkbox Hack")
168
-
169
- While utilizing an `<input type="checkbox">` and the CSS sibling selector (`:checked ~ .menu`) is a common "pure CSS" pattern for toggling dropdowns or modals, it is **off-doctrine** for `ln-ashlar` components and must not be used:
170
-
171
- 1. **Non-Observable Programmatic Updates:** Changing `.checked = true` programmatically via JavaScript **does not** dispatch native browser `'change'` or `'input'` events. It also **does not** trigger the `MutationObserver` (which only watches attribute modifications in the HTML markup). This completely breaks the reactive attribute-driven bridge.
172
- 2. **Broken Encapsulation:** A checkbox lives inside or beside the component. To read or write state, external coordinators or other components would have to query the internal DOM structure (e.g., `modal.querySelector('input[type="checkbox"]').checked`), exposing private details.
173
- 3. **Accessibility (ARIA) Semantic Mismatch:** Modals, dropdown triggers, and popovers require specific ARIA roles (e.g., `role="dialog"`, `aria-haspopup="menu"`, `aria-expanded`). A checkbox natively announces itself as a checkbox, which confuses screen readers and causes accessibility regressions.
174
-
175
- ---
176
-
177
- ## MutationObserver (mandatory)
178
-
179
- Every component must watch for **two types** of changes:
180
-
181
- 1. **`childList`** — new element added to DOM (AJAX, `innerHTML`, `appendChild`)
182
- 2. **`attributes`** — `data-ln-*` attribute added to existing element (Inspector, `setAttribute`)
183
-
184
- ```javascript
185
- function _domObserver() {
186
- const observer = new MutationObserver(function (mutations) {
187
- for (const mutation of mutations) {
188
- if (mutation.type === 'childList') {
189
- for (const node of mutation.addedNodes) {
190
- if (node.nodeType === 1) {
191
- findElements(node, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
192
- }
193
- }
194
- } else if (mutation.type === 'attributes') {
195
- findElements(mutation.target, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
196
- }
197
- }
198
- });
199
-
200
- observer.observe(document.body, {
201
- childList: true,
202
- subtree: true,
203
- attributes: true,
204
- attributeFilter: [DOM_SELECTOR]
205
- });
206
- }
207
- ```
208
-
209
- **Rules:**
210
- - `attributeFilter` always includes `DOM_SELECTOR` (and optionally trigger attributes like `'data-ln-toggle-for'`)
211
- - `attributeFilter` is mandatory — without it the observer fires on EVERY attribute change (performance issue)
212
- - On `attributes` mutation, `mutation.target` is the element whose attribute changed — call `findElements` directly on it
213
-
214
- ---
215
-
216
- ## Reactive Rendering Pattern
217
-
218
- When a component has **multiple internal state properties** that together drive DOM updates, use `reactiveState` + `createBatcher` + `fill()` from ln-core. This eliminates manual querySelector chains and coalesces multiple synchronous state changes into a single DOM update.
219
-
220
- ### The three primitives
221
-
222
- | Helper | Role |
223
- |--------|------|
224
- | `reactiveState(initial, onChange)` | Proxy — calls `onChange(prop, value, old)` on every top-level property set |
225
- | `createBatcher(renderFn, afterRender)` | Coalesces sync state changes into one `renderFn` call via `queueMicrotask` |
226
- | `fill(root, data)` | Writes state to DOM via `data-ln-field`, `data-ln-attr`, `data-ln-show`, `data-ln-class` |
227
-
228
- ### Pattern
229
-
230
- ```javascript
231
- import { reactiveState, createBatcher, fill, dispatch } from '../ln-core';
232
-
233
- function _component(dom) {
234
- this.dom = dom;
235
- const self = this;
236
-
237
- const queueRender = createBatcher(
238
- function () { self._render(); }, // runs after current sync block
239
- function () { self._afterRender(); } // runs after render — dispatch events here
240
- );
241
-
242
- this.state = reactiveState({ key: null, value: null }, queueRender);
243
- }
244
-
245
- _component.prototype._render = function () {
246
- fill(this.dom, this.state);
247
- // additional DOM logic derived from state
248
- };
249
-
250
- _component.prototype._afterRender = function () {
251
- dispatch(this.dom, 'ln-component:changed', { key: this.state.key });
252
- };
253
- ```
254
-
255
- When multiple properties change in the same sync block:
256
-
257
- ```javascript
258
- this.state.key = 'status'; // queues render
259
- this.state.value = 'active'; // queues again — same microtask, no duplicate
260
- // → microtask fires → _render() once → _afterRender() once
261
- ```
262
-
263
- ### `reactiveState` vs `deepReactive`
264
-
265
- | | `reactiveState` | `deepReactive` |
266
- |---|---|---|
267
- | **Depth** | Shallow — top-level sets only | Deep — nested object/array mutations |
268
- | **onChange args** | `(prop, value, old)` | `()` — no args |
269
- | **Use when** | Flat state with primitive values | State contains nested objects or arrays |
270
-
271
- ### `fill()` — limitations
272
-
273
- `fill()` sets `textContent` for `data-ln-field` — it does **not** set `.value` on `<input>`, `<textarea>`, or `<select>`. For form elements, set `.value` manually after `fill()`.
274
-
275
- ### When to use
276
-
277
- Use reactive rendering when a component has **2+ state properties** that together drive DOM, and multiple changes happen in the same sync block.
278
-
279
- **Don't use it for:**
280
- - Single boolean state — `this.isOpen = true; dom.classList.toggle(...)` is simpler
281
- - Event-driven rendering — components that receive data via `set-data` events (ln-table)
282
- - One-shot renders — clone template + fill once, no ongoing state
283
-
284
- ### Used in
285
-
286
- *No library component currently uses this pattern. It remains documented for project-level components that need multi-property reactive state.*
287
-
288
- ---
289
-
290
- ## CustomEvent Communication
291
-
292
- Components do NOT know about each other. Communication ONLY via CustomEvent.
293
-
294
- ```javascript
295
- import { dispatch } from '../ln-core';
296
-
297
- // Dispatch
298
- dispatch(this.dom, 'ln-toggle:open', { target: this.dom });
299
-
300
- // Listen (in another component or integration code)
301
- document.addEventListener('ln-toggle:open', function (e) {
302
- console.log('Opened:', e.detail.target);
303
- });
304
- ```
305
-
306
- ---
307
-
308
- ## Lifecycle Events
309
-
310
- Every component with actions must emit **paired events**: `before-{action}` (cancelable) + `{action}` (post).
311
-
312
- ```javascript
313
- import { dispatch, dispatchCancelable } from '../ln-core';
314
-
315
- _component.prototype.open = function () {
316
- if (this.isOpen) return;
317
- const before = dispatchCancelable(this.dom, 'ln-component:before-open', { target: this.dom });
318
- if (before.defaultPrevented) return; // external code can cancel
319
- this.isOpen = true;
320
- this.dom.classList.add('open');
321
- dispatch(this.dom, 'ln-component:open', { target: this.dom });
322
- };
323
- ```
324
-
325
- **Rules:**
326
- - `before-{action}` — `cancelable: true`, fires **before** state change
327
- - `{action}` — `cancelable: false`, fires **after** state change (fact, not prediction)
328
- - Naming: `ln-{component}:before-{action}` and `ln-{component}:{action}`
329
- - `detail` always contains `{ target: HTMLElement }` — the element where the action occurred
330
-
331
- **Usage:**
332
- ```javascript
333
- // Cancel conditionally
334
- element.addEventListener('ln-toggle:before-open', function (e) {
335
- if (!userHasPermission()) e.preventDefault();
336
- });
337
-
338
- // React after the fact
339
- document.addEventListener('ln-toggle:open', function (e) {
340
- analytics.track('panel-opened', e.detail.target.id);
341
- });
342
- ```
343
-
344
- ### CustomEvent detail null checks
345
-
346
- Components that dispatch their own events control the `detail` payload — accessing
347
- `e.detail.x` directly is correct because `detail` is always set by the dispatcher.
348
-
349
- When listening to events where `detail` might be absent (external events, request
350
- events where callers may omit detail), use the guard pattern:
351
-
352
- ```javascript
353
- // Guarded access — when detail may be null/undefined
354
- const loading = e.detail && e.detail.x;
355
-
356
- // Direct access — when this component dispatched the event
357
- const target = e.detail.target;
358
- ```
359
-
360
- The `e.detail && e.detail.x` pattern is the project convention. Do not use
361
- `(e.detail || {}).x` or optional chaining (`e.detail?.x`).
362
-
363
- ---
364
-
365
- ## Trigger Re-init Guard
366
-
367
- When a component listens for click events on trigger elements, it must set a guard to prevent duplicate listeners on repeated DOM scans (MutationObserver):
368
-
369
- ```javascript
370
- function _attachTriggers(root) {
371
- const triggers = Array.from(root.querySelectorAll('[data-ln-{name}-for]'));
372
- triggers.forEach(function (btn) {
373
- if (btn[DOM_ATTRIBUTE + 'Trigger']) return; // already initialized
374
- const handler = function (e) {
375
- if (e.ctrlKey || e.metaKey || e.button === 1) return; // allow browser shortcuts
376
- e.preventDefault();
377
- // ...
378
- };
379
- btn.addEventListener('click', handler);
380
- btn[DOM_ATTRIBUTE + 'Trigger'] = handler; // store handler ref for later removeEventListener
381
- });
382
- }
383
- ```
384
-
385
- In `destroy()`, remove the trigger listeners using the stored handler reference:
386
-
387
- ```javascript
388
- _component.prototype.destroy = function () {
389
- // ... other cleanup ...
390
- const triggers = document.querySelectorAll('[data-ln-{name}-for="' + this.dom.id + '"]');
391
- for (const btn of triggers) {
392
- if (btn[DOM_ATTRIBUTE + 'Trigger']) {
393
- btn.removeEventListener('click', btn[DOM_ATTRIBUTE + 'Trigger']);
394
- delete btn[DOM_ATTRIBUTE + 'Trigger'];
395
- }
396
- }
397
- };
398
- ```
399
-
400
- **Rules:**
401
- - Guard: `btn[DOM_ATTRIBUTE + 'Trigger']` — truthy check works for both old boolean (`true`) and new handler reference (function)
402
- - Store the handler function (not `true`) so `destroy()` can call `removeEventListener` with the exact reference
403
- - Always allow ctrl/meta/middle-click before `e.preventDefault()`
404
-
405
- ---
406
-
407
- ## Component Dependencies
408
-
409
- When a component depends on another (e.g. ln-accordion → ln-toggle):
410
-
411
- 1. **Listen only to post-action events** (`ln-toggle:open`) — not before-events, unless you need to cancel
412
- 2. **Communicate only via events or attributes** — dispatch `request-*` events to the target element, or write the target's `data-ln-*` attribute. NEVER reach into another component's instance to mutate state.
413
- 3. **Emit your own events** for your own actions (`ln-accordion:change`)
414
- 4. **Never import** another component — CustomEvent communication only
415
-
416
- ```javascript
417
- // Correct — listens to post-action, sets attribute on siblings, emits own event
418
- dom.addEventListener('ln-toggle:open', function (e) {
419
- dom.querySelectorAll('[data-ln-toggle]').forEach(function (el) {
420
- if (el !== e.detail.target && el.getAttribute('data-ln-toggle') === 'open') {
421
- el.setAttribute('data-ln-toggle', 'close');
422
- }
423
- });
424
- _dispatch(dom, 'ln-accordion:change', { target: e.detail.target }); // own event
425
- });
426
- ```
427
-
428
- ---
429
-
430
- ## Coordinator/Mediator Pattern — Canonical Example
431
-
432
- The architecture follows the **Mediator pattern** (GoF): components do not communicate with each other. The coordinator mediates all cross-component interactions.
433
-
434
- ### Canonical Example: ln-accordion / ln-toggle
435
-
436
- The ln-ashlar library already implements this:
437
-
438
- - **ln-toggle** is a component (state layer): `data-ln-toggle` attribute is the single source of truth. MutationObserver detects attribute changes → applies `.open` class, emits `ln-toggle:open` / `ln-toggle:close`. The component exposes no state-mutating method; consumers write the attribute.
439
- - **ln-accordion** is a coordinator (mediator): listens to `ln-toggle:open` from children, sets `data-ln-toggle="close"` on siblings. **Never** reaches into a sibling's instance — writes the attribute and lets each toggle's own observer run its own close pipeline. Emits its own `ln-accordion:change`.
440
-
441
- ```
442
- [Toggle A opens — attribute set to "open"]
443
- ↓
444
- MutationObserver → .open class + ln-toggle:open event (bubbles up)
445
- ↓
446
- [Accordion] catches it, sets data-ln-toggle="close" on B and C
447
- ↓
448
- [Toggle B] observer detects attribute change → if was open → closes
449
- [Toggle C] observer detects attribute change → already closed → no-op
450
- ```
451
-
452
- Toggle **doesn't know** that other toggles exist. Accordion **doesn't know** about toggle's internal state. Communication = attribute changes + events only.
453
-
454
- ### Scaling to Project Level
455
-
456
- The same pattern scales from library to application:
457
-
458
- | ln-ashlar (library) | Project (application) | Role |
459
- |---|---|---|
460
- | ln-toggle | ln-profile, ln-playlist, ln-deck | Component (state + events) |
461
- | ln-accordion | ln-mixer (coordinator) | Mediator (event wiring) |
462
- | `ln-toggle:open` | `ln-profile:switched` | Notification event (fact) |
463
- | `setAttribute('data-ln-toggle', 'close')` | `ln-deck:request-load` | Attribute change / Request event (command) |
464
- | `ln-accordion:change` | toast / modal close | Coordinator reaction |
465
-
466
- ### Isolation Rules
467
-
468
- 1. **Component → sibling component: FORBIDDEN.** A component NEVER queries another component (`lnSettings.getApiUrl()`, `nav.lnProfile.getProfile()`). Only the coordinator knows about all of them.
469
- 2. **Component → storage/DB: FORBIDDEN.** A component does NOT call `lnDb.put()` or any storage backend. The coordinator decides which storage backend to call.
470
- 3. **Coordinator → component query: ALLOWED.** The coordinator reads state directly (`el.lnProfile.currentId`).
471
- 4. **Coordinator → component command: ONLY request events.** The coordinator dispatches `request-*` events, the component decides independently.
472
-
473
- **Why?** Components become storage-agnostic and sibling-agnostic. Changing the backend (IndexedDB → API → localStorage) requires changes ONLY in the coordinator. Adding a new component requires changes ONLY in the coordinator.
474
-
475
- ---
476
-
477
- ## Auto-init on DOMContentLoaded
478
-
479
- ```javascript
480
- window[DOM_ATTRIBUTE] = constructor;
481
- _domObserver();
482
-
483
- if (document.readyState === 'loading') {
484
- document.addEventListener('DOMContentLoaded', function () {
485
- constructor(document.body);
486
- });
487
- } else {
488
- constructor(document.body);
489
- }
490
- ```
491
-
492
- ---
493
-
494
- ## API Export Patterns
495
-
496
- Components expose their API in one of three patterns, chosen based on how the component is used.
497
-
498
- ### 1. Global Constructor (default)
499
-
500
- Most components: the constructor is registered on `window`. Instances live on DOM elements.
501
-
502
- ```javascript
503
- window[DOM_ATTRIBUTE] = constructor; // window.lnToggle, window.lnModal, ...
504
- // Instance: el.lnToggle.destroy(), el.lnModal.destroy()
505
- ```
506
-
507
- Use when: component has DOM instances, each element gets its own API.
508
-
509
- ### 2. Element API (per-instance closure)
510
-
511
- ln-upload: a plain object with methods is attached to the container element. The closure captures instance-specific state (file map, DOM references).
512
-
513
- ```javascript
514
- container.lnUploadAPI = { getFileIds, getFiles, clear, destroy };
515
- ```
516
-
517
- Use when: instance state is complex (closures, Maps) and doesn't fit the prototype pattern. Multiple instances on the same page each get their own API object.
518
-
519
- ### 3. Global event listener (singleton)
520
-
521
- ln-toast: no window registration. Pure event listener — the contract is two window events (`ln-toast:enqueue`, `ln-toast:clear`). Project code dispatches; ln-toast listens.
522
-
523
- ```javascript
524
- // In ln-toast.js — no window[DOM_ATTRIBUTE] = api anywhere
525
- window.addEventListener('ln-toast:enqueue', _onEnqueue);
526
- window.addEventListener('ln-toast:clear', _onClear);
527
-
528
- // Usage from any component
529
- window.dispatchEvent(new CustomEvent('ln-toast:enqueue', {
530
- detail: { type: 'success', message: 'Saved' }
531
- }));
532
- ```
533
-
534
- Use when: the component is a global service that other components should be able to invoke without having a reference to it. Pure event dispatch is portable across iframes, web components, and any script-loading order.
535
-
536
- ### Choosing a pattern
537
-
538
- | Situation | Pattern |
539
- |-----------|---------|
540
- | Multiple instances, each with own DOM + state | Global constructor |
541
- | Multiple instances, complex closure state | Element API |
542
- | Single global service, no per-element instances | Global event listener |
543
-
544
- Do NOT standardize to one pattern — each exists for architectural reasons.
545
-
546
- ---
547
-
548
- ## Naming
549
-
550
- | Element | Convention | Example |
551
- |---------|-----------|--------|
552
- | Data attribute | `data-ln-{name}` | `data-ln-toggle` |
553
- | Window constructor | `window.ln{Name}` | `window.lnToggle` |
554
- | DOM instance | `element.ln{Name}` | `el.lnToggle` |
555
- | Custom event | `ln-{name}:{action}` | `ln-toggle:open` |
556
- | CSS class | `.ln-{name}__{element}` | `.ln-toggle__backdrop` |
557
- | Initialized flag | `element.ln{Name}` | `el.lnToggle` |
558
- | Private function | `_functionName` | `_render`, `_attachTriggers` |
559
-
560
- ---
561
-
562
- ## Co-located SCSS
563
-
564
- If a component needs CSS, create `components/ln-{name}/ln-{name}.scss`:
565
-
566
- ```scss
567
- @use '../../theme/config/mixins' as *;
568
-
569
- // Use @include mixins and var(--token) values. Any transition
570
- // that animates transform/opacity/width/height MUST be wrapped
571
- // in @include motion-safe { ... }.
572
- .ln-{name}__element {
573
- @include fixed;
574
- z-index: var(--z-overlay);
575
- }
576
- ```
577
-
578
- Add it to `components/index.js`:
579
- ```javascript
580
- import './ln-{name}/ln-{name}.js';
581
- import './ln-{name}/ln-{name}.scss';
582
- ```
583
-
584
- ---
585
-
586
- ## Request Events — Details
587
-
588
- > Architecture is defined in [ln-ashlar Project Architecture](#ln-ashlar-project-architecture-mandatory). This section covers technical details only.
589
-
590
- ### Implementation in Component
591
-
592
- ```javascript
593
- // In _bindEvents — listen for request events on self
594
- this.dom.addEventListener('ln-profile:request-create', function (e) {
595
- self.create(e.detail.name); // calls the same prototype method
596
- });
597
- this.dom.addEventListener('ln-profile:request-remove', function (e) {
598
- self.remove(e.detail.id);
599
- });
600
- ```
601
-
602
- ### Dispatching from Coordinator
603
-
604
- ```javascript
605
- // Coordinator — dispatches request event (NOT direct API call)
606
- nav.dispatchEvent(new CustomEvent('ln-profile:request-create', {
607
- detail: { name: 'My Profile' }
608
- }));
609
- ```
610
-
611
- ### Naming
612
-
613
- | Type | Format | Example | Bubbles |
614
- |-----|--------|--------|---------|
615
- | Request (incoming) | `ln-{name}:request-{action}` | `ln-profile:request-create` | `false` |
616
- | Notification (outgoing) | `ln-{name}:{past-tense}` | `ln-profile:created` | `true` |
617
- | Lifecycle before | `ln-{name}:before-{action}` | `ln-toggle:before-open` | `true`, cancelable |
618
- | Lifecycle after | `ln-{name}:{action}` | `ln-toggle:open` | `true` |
619
-
620
- ### Commands vs Queries
621
-
622
- | Type | Mechanism | Example |
623
- |-----|-----------|--------|
624
- | **Command** (changes state) | request event | `nav.dispatchEvent(new CustomEvent('ln-profile:request-remove', { detail: { id } }))` |
625
- | **Query** (reads state) | direct access | `nav.lnProfile.currentId`, `sidebar.lnPlaylist.getTrack(idx)` |
626
-
627
- ### Request-Reply / Collector Pattern (Sync Gathering)
628
-
629
- For commands that require synchronous feedback or list gathering (such as batch validation before submit):
630
- - **Format:** `ln-{name}:request-{action}` with `e.detail` containing a mutable data structure (like an array).
631
- - **Example:** `ln-validate:request-validate` with `detail: { invalidFields: [] }`.
632
- - **Mechanism:** The caller dispatches the event on a common ancestor (like `<form>`). Listeners catch it synchronously, evaluate their state, and push results/references directly to the mutable array in `e.detail`.
633
- - **Bubbling:** `true` (to allow components/listeners attached to form or ancestor nodes to catch it).
634
-
635
- ### Form Scoping and Validation Rules
636
- - **Novalidate:** `ln-validate` injects `novalidate` on its host `<form>` automatically — the moment a form contains at least one `data-ln-validate` field, native browser bubbles are silenced in favor of inline error lists. Authors never write `novalidate` by hand; a form with zero `data-ln-validate` fields keeps native browser validation as the default.
637
-
638
- ---
639
-
640
- ## Coordinator — Full Example
641
-
642
- > The coordinator is a thin IIFE, project-specific. No own state, no own DOM. Just wiring.
643
-
644
- ```javascript
645
- (function () {
646
- 'use strict';
647
- if (window.myCoordinator !== undefined) return;
648
- window.myCoordinator = true;
649
-
650
- function _getNav() { return document.querySelector('[data-ln-profile]'); }
651
- function _getSidebar() { return document.querySelector('[data-ln-playlist]'); }
652
-
653
- function _init() {
654
- // 1. UI trigger → request event
655
- document.addEventListener('click', function (e) {
656
- if (e.target.closest('[data-ln-action="delete-profile"]')) {
657
- const nav = _getNav();
658
- if (nav && nav.lnProfile) {
659
- nav.dispatchEvent(new CustomEvent('ln-profile:request-remove', {
660
- detail: { id: nav.lnProfile.currentId } // query is OK
661
- }));
662
- }
663
- }
664
- });
665
-
666
- // 2. Local-first write form → ln-data-coordinator claims the native
667
- // submit (preventDefault); react to the store outcome instead.
668
- // <form data-ln-form data-ln-data-coordinator-scope="profiles" method="post" action="/api/profiles">
669
- document.addEventListener('ln-data-store:created', function (e) {
670
- if (e.detail.store !== 'profiles') return;
671
- document.getElementById('modal-new-profile').setAttribute('data-ln-modal', 'close');
672
- });
673
-
674
- // 3. Notification → UI feedback
675
- document.addEventListener('ln-profile:created', function () {
676
- window.dispatchEvent(new CustomEvent('ln-toast:enqueue', {
677
- detail: { type: 'success', message: 'Profile created' }
678
- }));
679
- });
680
-
681
- // 4. Bridge: component A event → component B attribute
682
- document.addEventListener('ln-profile:switched', function (e) {
683
- const sidebar = _getSidebar();
684
- if (sidebar) {
685
- sidebar.setAttribute('data-ln-playlist-profile', e.detail.profileId);
686
- }
687
- });
688
- }
689
-
690
- if (document.readyState === 'loading') {
691
- document.addEventListener('DOMContentLoaded', _init);
692
- } else {
693
- _init();
694
- }
695
- })();
696
- ```
697
-
698
- **The four jobs of a coordinator:**
699
- 1. **UI trigger → request event** — click/submit → dispatch request to component
700
- 2. **Form processing** — read input, validate, clear, close modal
701
- 3. **Notification → UI feedback** — toast, modal close, highlight
702
- 4. **Bridge** — event from component A → attribute/request on component B
703
-
704
- ---
705
-
706
- ## Template System — DOM Structure in HTML, Not in JS
707
-
708
- **NEVER** build DOM structure with `createElement` chains in JS. Use the native HTML `<template>` element.
709
-
710
- ### Principle
711
-
712
- DOM structure is an **HTML decision**, not a JS decision. The component only fills in the data.
713
-
714
- ### Zero Display Text in JS (mandatory)
715
-
716
- JS components **NEVER** contain hardcoded strings intended for user display — no labels, messages, button text, status text, relative time words, or any translatable content. All display text lives in HTML templates where the server (Blade, backend) can translate it.
717
-
718
- ```
719
- WRONG: el.textContent = 'No results found';
720
- WRONG: el.textContent = '3 minutes ago';
721
- WRONG: const label = count === 1 ? 'item' : 'items';
722
-
723
- RIGHT: text comes from <template> → cloneTemplate → fill
724
- RIGHT: text comes from hidden dict elements → buildDict (error messages, labels)
725
- RIGHT: text comes from data-ln-* attribute set by server
726
- RIGHT: text comes from Intl API (dates, numbers — browser-native i18n)
727
- ```
728
-
729
- **Exceptions:**
730
- - `Intl` APIs and standardized measurement unit fallbacks are defined in [DOCTRINE.md](../DOCTRINE.md#4-html-template-system--zero-js-display-text).
731
-
732
- If a component needs display text that Intl or universal unit symbols cannot provide, the text must come from HTML (template or dictionary).
733
-
734
- ```
735
- HTML: <template> defines structures (inert, not rendered)
736
- HTML: <ul hidden> with <li data-{component}-dict="key"> defines translatable strings
737
- JS: clone → fill (structures) / buildDict (strings)
738
- ```
739
-
740
- ### Dictionary pattern (i18n strings)
741
-
742
- For error messages, labels, and other translatable strings that aren't part of a template structure, use `buildDict` from ln-core:
743
-
744
- ```html
745
- <!-- Hidden dict elements — server translates, JS reads once at init -->
746
- <ul hidden>
747
- <li data-ln-upload-dict="remove">{{ __('Remove') }}</li>
748
- <li data-ln-upload-dict="error">{{ __('Error') }}</li>
749
- <li data-ln-upload-dict="invalid-type">{{ __('This file type is not allowed') }}</li>
750
- </ul>
751
- ```
752
-
753
- ```js
754
- import { buildDict } from '../ln-core';
755
-
756
- // Init — reads all elements, builds object, removes from DOM
757
- const dict = buildDict(container, 'data-ln-upload-dict');
758
-
759
- // Usage — O(1) property access
760
- dict['remove'] // 'Remove'
761
- dict['invalid-type'] // 'This file type is not allowed'
762
- ```
763
-
764
- **Convention:** `data-{component}-dict="key"` on `<li>` elements inside a single `<ul hidden>` in the component root. The `hidden` attribute on the `<ul>` hides the entire group. `buildDict` removes the elements after reading. Attribute name matches the component's naming pattern.
765
-
766
- ### HTML — define templates at the end of `<body>`, before `<script>` tags
767
-
768
- ```html
769
- <!-- Templates -->
770
- <template data-ln-template="track-item">
771
- <li data-ln-track>
772
- <span class="track-number" data-ln-drag-handle></span>
773
- <article class="track-info">
774
- <p class="track-name"></p>
775
- <p class="track-artist"></p>
776
- </article>
777
- <nav class="track-actions">
778
- <button type="button" data-ln-load-to="a">A</button>
779
- <button type="button" data-ln-load-to="b">B</button>
780
- </nav>
781
- </li>
782
- </template>
783
-
784
- <template data-ln-template="profile-btn">
785
- <button type="button" class="profile-btn" data-ln-profile-id></button>
786
- </template>
787
-
788
- <script src="..."></script>
789
- ```
790
-
791
- ### JS — `cloneTemplate` + `fill` / `fillTemplate` from ln-core
792
-
793
- ```javascript
794
- import { cloneTemplate, fill, fillTemplate } from '../ln-core';
795
- ```
796
-
797
- `cloneTemplate(name, componentTag)` caches the lookup and returns `tmpl.content.cloneNode(true)`.
798
-
799
- ### Usage — clone + fill (declarative)
800
-
801
- Use `data-ln-field` for text, `data-ln-attr` for attributes:
802
-
803
- ```html
804
- <template data-ln-template="track-item">
805
- <li data-ln-track>
806
- <span class="track-number" data-ln-field="number"></span>
807
- <article class="track-info">
808
- <p data-ln-field="title"></p>
809
- <p data-ln-field="artist"></p>
810
- </article>
811
- </li>
812
- </template>
813
- ```
814
-
815
- ```javascript
816
- _component.prototype._buildTrackItem = function (track, idx) {
817
- const frag = cloneTemplate('track-item', 'ln-playlist');
818
- const li = frag.querySelector('[data-ln-track]');
819
- fill(li, { number: idx + 1, title: track.title, artist: track.artist });
820
- return li;
821
- };
822
- ```
823
-
824
- ### Advanced `fill()` — attributes and state classes
825
-
826
- Beyond text, `fill()` binds attributes (`data-ln-attr`) and toggles classes (`data-ln-class`). ln-upload exercises all three in a single call:
827
-
828
- ```html
829
- <template data-ln-template="ln-upload-item">
830
- <li class="ln-upload__item"
831
- data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting">
832
- <svg class="ln-icon" aria-hidden="true">
833
- <use data-ln-attr="href:iconHref" href="#ln-icon-file"></use>
834
- </svg>
835
- <span class="ln-upload__name" data-ln-field="name"></span>
836
- <span class="ln-upload__size" data-ln-field="sizeText"></span>
837
- <button type="button" class="ln-upload__remove"
838
- data-ln-upload-action="remove"
839
- data-ln-attr="aria-label:removeLabel, title:removeLabel">
840
- <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-x"></use></svg>
841
- </button>
842
- <div class="ln-upload__progress">
843
- <div class="ln-upload__progress-bar"></div>
844
- </div>
845
- </li>
846
- </template>
847
- ```
848
-
849
- ```javascript
850
- const fragment = cloneTemplateScoped(container, 'ln-upload-item', 'ln-upload');
851
- const li = fragment.firstElementChild;
852
- li.setAttribute('data-file-id', localId);
853
- fill(li, {
854
- name: file.name,
855
- sizeText: '0%',
856
- iconHref: '#' + iconId, // → <use href="#ln-icon-custom-file-pdf">
857
- removeLabel: dict.remove, // → aria-label + title on button
858
- uploading: true, // → adds ln-upload__item--uploading
859
- error: false,
860
- deleting: false
861
- });
862
- list.appendChild(li);
863
-
864
- // Later, on XHR progress — update only what changed:
865
- fill(li, { sizeText: percent + '%' });
866
-
867
- // On upload success — flip state classes:
868
- fill(li, { sizeText: formatSize(size), uploading: false });
869
-
870
- // On upload error:
871
- fill(li, { sizeText: dict.error, uploading: false, error: true });
872
- ```
873
-
874
- One `fill()` call handles all three binding types at once:
875
-
876
- - **`data-ln-field="name"`** → `textContent` assignment
877
- - **`data-ln-attr="href:iconHref"`** → one or more attributes per element (`href` on `<use>`, `aria-label` + `title` on the button)
878
- - **`data-ln-class="ln-upload__item--uploading:uploading, ..."`** → conditional class toggles driven by booleans
879
-
880
- State transitions are just successive `fill()` calls with different values — no imperative `classList.add/remove`, no manual `setAttribute`. The icon swap via `data-ln-attr="href:iconHref"` on a `<use>` element works because `ln-icon` runs a MutationObserver on `<use href>` changes and auto-fetches the new sprite — the component never touches the icon loader directly.
881
-
882
- **Behavioral hooks live on attributes, not classes** — `data-ln-upload-action="remove"` is a JS query hook, not a fill slot. Delegated click handlers on `.ln-upload__list` use `e.target.closest('[data-ln-upload-action="remove"]')` to locate the button, which also matches when the click lands on a nested `<svg>` / `<use>`. Keeping behavioral hooks off CSS class names means a project can rename or restyle classes without breaking JS.
883
-
884
- **Imperative escape hatches** — two things `fill()` can't express cleanly:
885
-
886
- 1. `removeBtn.disabled = !uploaded` — the `disabled` boolean attribute doesn't round-trip through `data-ln-attr` (setting vs. removing).
887
- 2. `progressBar.style.width = percent + '%'` — a continuous animation value, not discrete state. Same precedent as `ln-table` virtual-scroll spacer heights.
888
-
889
- Both are one-line assignments right after the `fill()` call and are the only non-declarative touches in the render path.
890
-
891
- ### Scoped templates — per-instance override
892
-
893
- `cloneTemplate(name)` looks up a single global `<template>` at document root. For customizable components, use `cloneTemplateScoped(root, name, componentTag)` instead — it checks inside `root` first, then falls back to the global lookup. This lets projects override the layout per instance without forking the component:
894
-
895
- ```html
896
- <!-- Default instance — uses the global or auto-injected template -->
897
- <div data-ln-upload="/files/upload"></div>
898
-
899
- <!-- Customized instance — scoped <template> inside the container -->
900
- <div data-ln-upload="/files/upload">
901
- <template data-ln-template="ln-upload-item">
902
- <li class="ln-upload__item"
903
- data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting">
904
- <svg class="ln-icon ln-icon--lg" aria-hidden="true">
905
- <use data-ln-attr="href:iconHref" href="#ln-icon-file"></use>
906
- </svg>
907
- <article>
908
- <p class="ln-upload__name" data-ln-field="name"></p>
909
- <small class="ln-upload__size" data-ln-field="sizeText"></small>
910
- </article>
911
- <button type="button" class="ln-upload__remove"
912
- data-ln-upload-action="remove"
913
- data-ln-attr="aria-label:removeLabel, title:removeLabel">
914
- <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-x"></use></svg>
915
- </button>
916
- <div class="ln-upload__progress"><div class="ln-upload__progress-bar"></div></div>
917
- </li>
918
- </template>
919
- <div class="ln-upload__zone"><p>Drop files</p></div>
920
- <ul class="ln-upload__list"></ul>
921
- </div>
922
- ```
923
-
924
- Lookup order for `cloneTemplateScoped`:
925
-
926
- 1. **Scoped** — `<template>` inside the container element (per-instance)
927
- 2. **Global** — `<template>` anywhere at document root (shared default)
928
- 3. **Auto-injected default** *(optional)* — if the component ships a built-in default, inject it into `<body>` on first init when neither scoped nor global is present. This keeps zero-config usage working without forcing every project to copy-paste markup.
929
-
930
- ln-upload implements all three tiers. The auto-inject runs once per init via a helper:
931
-
932
- ```javascript
933
- const DEFAULT_ITEM_TEMPLATE_HTML =
934
- '<template data-ln-template="ln-upload-item">' +
935
- /* ...default <li> markup... */
936
- '</template>';
937
-
938
- function _ensureDefaultItemTemplate() {
939
- if (document.querySelector('[data-ln-template="ln-upload-item"]')) return;
940
- const wrapper = document.createElement('div');
941
- wrapper.innerHTML = DEFAULT_ITEM_TEMPLATE_HTML;
942
- document.body.appendChild(wrapper.firstElementChild);
943
- }
944
- ```
945
-
946
- The component calls `_ensureDefaultItemTemplate()` at the top of `_initUpload()`, before any `cloneTemplateScoped` call. `cloneTemplate`'s internal cache only stores truthy lookups, so inserting a template after a previous `null` lookup is safe — the next call picks it up.
947
-
948
- **When to use which:**
949
-
950
- | Component shape | Use |
951
- |---|---|
952
- | Single template reused across all instances, never customized | `cloneTemplate(name, tag)` |
953
- | Instance layout may vary per project or per page | `cloneTemplateScoped(container, name, tag)` |
954
- | Zero-config out of the box + optional per-instance override | scoped lookup + auto-injected default |
955
-
956
- ### Components using templates
957
-
958
- | Component | Template(s) | Lookup | Notes |
959
- |---|---|---|---|
960
- | **ln-upload** | `ln-upload-item` | `cloneTemplateScoped` | Scoped → global → auto-injected default on first init |
961
- | **ln-table** | `{name}-row`, `{name}-empty`, `{name}-empty-filtered`, `column-filter` (also falls back to `{name}-column-filter`) | `cloneTemplateScoped` | Scoped-first per table instance |
962
- | **ln-translations** | `ln-translations-menu-item`, `ln-translations-badge` | `cloneTemplate` | Single global template, no per-instance override |
963
-
964
- ### Rules
965
-
966
- 1. **`<template data-ln-template="name">`** — naming convention
967
- 2. **Placement:** at the end of `<body>`, before `<script>` tags, in a `TEMPLATES` comment block
968
- 3. **`content.cloneNode(true)`** returns a DocumentFragment — query the root element with `querySelector`
969
- 4. **JS only fills:** `textContent`, `setAttribute`, `classList` — does NOT create structure
970
- 5. **Conditions:** small conditional elements (1-2 spans for indicators) are OK as `createElement`
971
- 6. **One template, one function:** if the same structure is created in 2+ places, it must be a `<template>` + shared function
972
-
973
- ### Why
974
-
975
- | createElement chains | `<template>` |
976
- |---------------------|--------------|
977
- | 60+ lines of JS for one `<li>` | 10 lines HTML + 8 lines JS |
978
- | Structure hidden in JS | Structure visible in HTML |
979
- | Duplication between methods | One definition, one function |
980
- | Hard for designers | HTML — easy to change |
981
-
982
- ---
983
-
984
- ## Components (reference)
985
-
986
- | Component | Pattern | Data Attr | Description |
987
- |-----------|---------|-----------|------|
988
- | ln-core | Shared module | — | cloneTemplate, cloneTemplateScoped, dispatch, dispatchCancelable, fill, fillTemplate, renderList, buildDict, guardBody, findElements, reactiveState, deepReactive, createBatcher, createWindowCache |
989
- | ln-toggle | Instance | `data-ln-toggle` | Generic toggle (sidebar, collapse) |
990
- | ln-accordion | Instance | `data-ln-accordion` | Wrapper — only one toggle open at a time |
991
- | ln-tabs | Instance | `data-ln-tabs` | Tab navigation — URL hash (anchor triggers) or localStorage persist (button triggers) |
992
- | ln-nav | Instance | `data-ln-nav` | Active link highlighter |
993
- | ln-modal | Instance | `data-ln-modal` | Modal dialog |
994
- | ln-ui-coordinator | Coordinator | `data-ln-ui-coordinator` | UI mediator — modals, AJAX, forms, triggers, and toast notifications |
995
- | ln-toast | Functional | `data-ln-toast` | Toast notifications |
996
- | ln-upload | Functional | `data-ln-upload` | File upload |
997
- | ln-ajax | Functional | `data-ln-ajax` | AJAX navigation |
998
- | ln-progress | Functional | `data-ln-progress` | Progress bar |
999
- | ln-circular-progress | Instance | `data-ln-circular-progress` | Circular (ring) progress indicator |
1000
- | ln-search | Instance | `data-ln-search` | Generic search (textContent filter) |
1001
- | ln-filter | Instance | `data-ln-filter` | Generic filter (data attribute filter) |
1002
- | ln-sort | Instance | `data-ln-sort` | Sort control primitive — 3-trigger cycle (none→asc→desc→none), search-precedent cancelable event; targets ln-table, ln-list, or plain lists |
1003
- | ln-table | Instance | `data-ln-table` | Data table (search, filter, sort, virtual scroll; opt-in server-side windowed virtualization via `data-ln-table-window`) |
1004
- | ln-chart | Instance | `data-ln-chart` | SVG line/area dataset renderer with `ln-data-coordinator` binding |
1005
- | ln-sortable | Instance | `data-ln-sortable` | Drag & drop reorder |
1006
- | ln-picklist | Instance | `data-ln-picklist` | Two-list checkbox selection |
1007
- | ln-dropdown | Instance | `data-ln-dropdown` | Positioned dropdown menu (wraps ln-toggle) |
1008
- | ln-popover | Instance | `data-ln-popover` | Rich popover with viewport-aware positioning and ESC-stack management |
1009
- | ln-key | Instance | `data-ln-key` | Keyboard shortcut primitive that triggers native click or focus behavior |
1010
- | ln-link | Instance | `data-ln-link` | Clickable rows/containers |
1011
- | ln-confirm | Instance | `data-ln-confirm` | Two-click confirmation for destructive actions |
1012
- | ln-autosave | Instance | `data-ln-autosave` | Auto-save form to localStorage on blur/change |
1013
- | ln-autoresize | Instance | `data-ln-autoresize` | Auto-resize textarea height |
1014
- | ln-date | Instance | `data-ln-date` | Locale-aware date formatter with native picker and typing support |
1015
- | ln-number | Instance | `data-ln-number` | Locale-aware number formatter (decimal, currency, percent) |
1016
- | ln-time | Instance | `data-ln-time` | Relative and absolute time formatter, auto-updates on shared 60s tick |
1017
- | ln-translations | Instance | `data-ln-translations` | Multi-language form field management |
1018
- | ln-external-links | Utility | — | External links handler |
1019
- | ln-http | Global service | — | Event-driven JSON fetch with abort support |
1020
-
1021
- ---
1022
-
1023
- ## Component Relationships
1024
-
1025
- Components do not import each other. Every relationship below is
1026
- through one of two channels:
1027
-
1028
- - **Events** — one component dispatches `ln-{a}:{action}`, another
1029
- listens for it (`ln-accordion` listens to `ln-toggle:open`).
1030
- - **Shared DOM state** — one component sets a `data-ln-*` attribute,
1031
- another's MutationObserver reacts (ln-accordion setting
1032
- `data-ln-toggle="close"` on sibling toggles).
1033
-
1034
- No component ever does `import '../ln-toggle'` or reaches into
1035
- `otherEl.lnToggle` to mutate its state. If you catch yourself reaching
1036
- for an import or for a sibling's instance, re-read the Mediator section
1037
- above — write the attribute instead.
1038
-
1039
- ### ln-toggle — the state primitive
1040
-
1041
- `ln-toggle` is the only open/close state primitive. Components that
1042
- need the "coordinator collapses siblings" pattern build on it:
1043
-
1044
- ```
1045
- ln-toggle (state: data-ln-toggle attribute, ln-toggle:open/close events)
1046
- ├── ln-accordion coordinator — listens to ln-toggle:open on children,
1047
- │ sets data-ln-toggle="close" on siblings
1048
- └── ln-dropdown wraps a child [data-ln-toggle] and adds viewport-aware
1049
- positioning on ln-toggle:open
1050
- ```
1051
-
1052
- `ln-modal`, `ln-popover`, and `ln-tabs` are **not** built on `ln-toggle`.
1053
- They each own an independent open/close state machine backed by their
1054
- own attribute (`data-ln-modal`, `data-ln-popover`, `data-ln-tabs-active`).
1055
- The "attribute is the single source of truth" pattern is shared; the
1056
- specific attribute is not.
1057
-
1058
- ### Form family — ln-validate owns the submit gate
1059
-
1060
- ```
1061
- ln-validate (first field per form attaches the submit gate;
1062
- every field self-registers its own request-validate
1063
- listener)
1064
-
1065
- ln-form (populate + RESTful action routing only — no submit
1066
- listener, no validation awareness)
1067
-
1068
- ln-autosave stores form state in localStorage; writes on blur/change,
1069
- independently of both ln-form and ln-validate
1070
- ```
1071
-
1072
- None of the three import each other or call cross-instance methods.
1073
- `ln-form.fill()` dispatches synthetic `input`/`change` on populated
1074
- fields; `ln-validate`'s own field listeners react to those exactly as
1075
- they would to real user input. `ln-autosave` does the same on restore.
1076
-
1077
- ### Data family — ln-table ↔ ln-store through the coordinator
1078
-
1079
- ```
1080
- ln-table ln-store
1081
- │ │
1082
- │ ln-table:request-data ──────────────▶│
1083
- │ (sort, filters, search) │ (reads IndexedDB)
1084
- │ │
1085
- │◀─────── ln-table:set-data │
1086
- │ (rows + totals) │
1087
- │ │
1088
- │◀─────── ln-data-store:synced │
1089
- │ (coordinator re-queries) │
1090
- ▲
1091
- │
1092
- project coordinator
1093
- ```
1094
-
1095
- `ln-table` never imports or queries `ln-store`. It emits a request
1096
- event; the project coordinator catches it, calls `storeEl.lnStore.getAll(...)`,
1097
- and dispatches `ln-table:set-data` back. See
1098
- [docs/architecture/component-guide.md](../docs/architecture/component-guide.md#data-flow-with-ln-store)
1099
- for the full handshake.
1100
-
1101
- `ln-list` and `ln-chart` use the same request/set-data handshake. None of
1102
- the renderers import or query the store directly; `ln-data-coordinator` owns source selection and refresh.
1103
-
1104
- ### Rule
1105
-
1106
- > **Relationships are through events, never imports.**
1107
-
1108
- If two components need to know about each other's state, introduce a
1109
- coordinator between them. Components stay storage-agnostic and
1110
- sibling-agnostic; the coordinator is the only piece that knows the
1111
- whole stack.
1112
-
1113
- ---
1114
-
1115
- > For the latest component skeleton and checklist →
1116
- > see [docs/architecture/component-guide.md](../docs/architecture/component-guide.md)
1
+ # JS Components — Conventions and Patterns
2
+
3
+ ## ln-ashlar Project Architecture (mandatory)
4
+
5
+ Every project using ln-ashlar JS components **MUST** follow three layers:
6
+
7
+ ```
8
+ ┌─────────────────────────────────────────────────────┐
9
+ │ Coordinator (project-specific) │
10
+ │ Catches UI actions → dispatches request events → │
11
+ │ reacts to notification events with UI feedback │
12
+ ├─────────────────────────────────────────────────────┤
13
+ │ Components (reusable) │
14
+ │ State + CRUD + request listeners + notifications │
15
+ ├─────────────────────────────────────────────────────┤
16
+ │ ln-ashlar (library) │
17
+ │ ln-toggle, ln-accordion, ln-modal, ln-toast... │
18
+ └─────────────────────────────────────────────────────┘
19
+ ```
20
+
21
+ ### Three Rules
22
+
23
+ 1. **Component = data layer.** Manages state, CRUD, its own DOM. Does NOT open modals, does NOT show toast, does NOT read external forms.
24
+ 2. **Coordinator = UI wiring.** Catches buttons/forms, dispatches request events to components, reacts to notification events with UI feedback (toast, modal, highlight).
25
+ 3. **Commands → request events. Queries → direct API.** The coordinator NEVER calls prototype methods for state mutations (`el.lnProfile.create()`). ALWAYS dispatches a request event (`ln-profile:request-create`). Reading state directly is allowed (`el.lnProfile.currentId`).
26
+
27
+ ### Event flow
28
+
29
+ ```
30
+ [User clicks button]
31
+ ↓
32
+ [Coordinator] catches click on [data-ln-action="new-profile"]
33
+ ↓
34
+ [Coordinator] reads input, dispatches request event:
35
+ nav.dispatchEvent('ln-profile:request-create', { detail: { name } })
36
+ ↓
37
+ [Component ln-profile] listens for request-create, calls self.create(name)
38
+ ↓
39
+ [Component] changes state, renders DOM, dispatches notification:
40
+ _dispatch(dom, 'ln-profile:created', { profileId, profile })
41
+ ↓
42
+ [Coordinator] listens for ln-profile:created → shows toast, closes modal
43
+ ```
44
+
45
+ ### Workflow for new functionality
46
+
47
+ 1. **Component**: add prototype method (clean — accept params, change state, dispatch notification event)
48
+ 2. **Component**: add request listener in `_bindEvents` (calls the same prototype method)
49
+ 3. **Coordinator**: add UI trigger (click / form submit → dispatch request event to component)
50
+ 4. **Coordinator**: add UI reaction (listen to notification event → toast / modal / highlight)
51
+
52
+ ### Test: "Is it a component or coordinator?"
53
+
54
+ | Question | If YES → | If NO → |
55
+ |---------|----------|----------|
56
+ | Changes its own state (CRUD)? | component | coordinator |
57
+ | Renders its own DOM (list, buttons)? | component | coordinator |
58
+ | Opens modal / shows toast? | coordinator | component |
59
+ | Reads input from form? | coordinator | component |
60
+ | Listens to `[data-ln-action="..."]` click? | coordinator | component |
61
+ | Listens to click on **its own child** element? | component | coordinator |
62
+ | Bridges two components (A:event → B:attribute)? | coordinator | — |
63
+
64
+ ---
65
+
66
+ ## IIFE Pattern (mandatory)
67
+
68
+ Every component is an IIFE (Immediately Invoked Function Expression). Shared helpers are imported from `ln-core`; the IIFE body has no exports.
69
+
70
+ ```javascript
71
+ import { dispatch } from '../ln-core';
72
+ import { deepReactive, createBatcher } from '../ln-core';
73
+
74
+ (function () {
75
+ const DOM_SELECTOR = 'data-ln-{name}';
76
+ const DOM_ATTRIBUTE = 'ln{Name}';
77
+
78
+ // Guard against double loading
79
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
80
+
81
+ // ... component ...
82
+
83
+ window[DOM_ATTRIBUTE] = constructor;
84
+ })();
85
+ ```
86
+
87
+ ### ln-core shared helpers
88
+
89
+ | Import | Purpose |
90
+ |--------|---------|
91
+ | `findElements(root, selector, attr, Constructor)` | Find all `[selector]` under root, skip initialized, instantiate |
92
+ | `dispatch(el, name, detail)` | Fire non-cancelable CustomEvent |
93
+ | `dispatchCancelable(el, name, detail)` | Fire cancelable CustomEvent, returns event |
94
+ | `cloneTemplate(name, tag)` | Clone `<template data-ln-template="name">`, cached |
95
+ | `fill(root, data)` | Declarative DOM binding via `data-ln-field`, `data-ln-attr`, `data-ln-show`, `data-ln-class` |
96
+ | `fillTemplate(clone, data)` | Replace `{{ key }}` text-node placeholders in cloned template with `data[key]` values |
97
+ | `buildDict(root, selector)` | Read hidden i18n elements once at init, return plain object, remove from DOM |
98
+ | `renderList(container, items, tpl, keyFn, fillFn, tag)` | Keyed list rendering with DOM reuse |
99
+ | `reactiveState(initial, onChange)` | Shallow Proxy — onChange(prop, value, old) per set |
100
+ | `deepReactive(obj, onChange)` | Deep Proxy — onChange() on any nested change |
101
+ | `createBatcher(renderFn, afterRender)` | Coalesce multiple sync state changes into one render |
102
+
103
+ Import only what the component needs. Vite tree-shakes unused exports.
104
+
105
+ ---
106
+
107
+ ## Instance-based Pattern (recommended)
108
+
109
+ The component is **attached to a DOM element**. The API lives on the element, NOT on `window`.
110
+
111
+ ```javascript
112
+ import { findElements } from '../ln-core';
113
+
114
+ // window[DOM_ATTRIBUTE] is just the constructor function
115
+ function constructor(domRoot) {
116
+ findElements(domRoot, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
117
+ }
118
+
119
+ function _component(dom) {
120
+ this.dom = dom;
121
+ // ... init ...
122
+ }
123
+
124
+ // Prototype methods = public API
125
+ _component.prototype.open = function () { ... };
126
+ _component.prototype.close = function () { ... };
127
+ ```
128
+
129
+ **Usage:**
130
+ ```javascript
131
+ // Attribute is the contract — write data-ln-toggle to change state
132
+ document.getElementById('sidebar').setAttribute('data-ln-toggle', 'open');
133
+ document.getElementById('sidebar').setAttribute('data-ln-toggle', 'close');
134
+
135
+ // Constructor — only for non-standard cases (Shadow DOM, iframe)
136
+ // Dynamic AJAX HTML does NOT require manual init — MutationObserver handles it automatically
137
+ window.lnToggle(container);
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Attribute Bridge (mandatory)
143
+
144
+ Instance methods that change component state **must only call `setAttribute`**. The MutationObserver observes the attribute change and applies the actual state. Direct DOM manipulation inside instance methods is forbidden.
145
+
146
+ ```javascript
147
+ // CORRECT — method is a thin setAttribute wrapper
148
+ _component.prototype.open = function () {
149
+ if (this.isOpen) return;
150
+ this.dom.setAttribute(DOM_SELECTOR, 'open');
151
+ // MutationObserver fires → _syncAttribute() applies state
152
+ };
153
+
154
+ // WRONG — method bypasses the attribute and manipulates state directly
155
+ _component.prototype.open = function () {
156
+ this.isOpen = true;
157
+ this.dom.classList.add('open');
158
+ document.addEventListener('keydown', this._onEscape);
159
+ dispatch(this.dom, 'ln-component:open', { target: this.dom });
160
+ };
161
+ ```
162
+
163
+ **Why:** The attribute is the single source of truth. Any external code can trigger the same state change with `el.setAttribute(...)` and get identical behaviour. The component never has two code paths to the same state.
164
+
165
+ **Rule:** If a prototype method changes state, its entire body is `this.dom.setAttribute(...)`. All state logic lives in `_syncAttribute()` (or equivalent), called only by the MutationObserver.
166
+
167
+ ### Off-Doctrine: Checkbox State (The "Checkbox Hack")
168
+
169
+ While utilizing an `<input type="checkbox">` and the CSS sibling selector (`:checked ~ .menu`) is a common "pure CSS" pattern for toggling dropdowns or modals, it is **off-doctrine** for `ln-ashlar` components and must not be used:
170
+
171
+ 1. **Non-Observable Programmatic Updates:** Changing `.checked = true` programmatically via JavaScript **does not** dispatch native browser `'change'` or `'input'` events. It also **does not** trigger the `MutationObserver` (which only watches attribute modifications in the HTML markup). This completely breaks the reactive attribute-driven bridge.
172
+ 2. **Broken Encapsulation:** A checkbox lives inside or beside the component. To read or write state, external coordinators or other components would have to query the internal DOM structure (e.g., `modal.querySelector('input[type="checkbox"]').checked`), exposing private details.
173
+ 3. **Accessibility (ARIA) Semantic Mismatch:** Modals, dropdown triggers, and popovers require specific ARIA roles (e.g., `role="dialog"`, `aria-haspopup="menu"`, `aria-expanded`). A checkbox natively announces itself as a checkbox, which confuses screen readers and causes accessibility regressions.
174
+
175
+ ---
176
+
177
+ ## MutationObserver (mandatory)
178
+
179
+ Every component must watch for **two types** of changes:
180
+
181
+ 1. **`childList`** — new element added to DOM (AJAX, `innerHTML`, `appendChild`)
182
+ 2. **`attributes`** — `data-ln-*` attribute added to existing element (Inspector, `setAttribute`)
183
+
184
+ ```javascript
185
+ function _domObserver() {
186
+ const observer = new MutationObserver(function (mutations) {
187
+ for (const mutation of mutations) {
188
+ if (mutation.type === 'childList') {
189
+ for (const node of mutation.addedNodes) {
190
+ if (node.nodeType === 1) {
191
+ findElements(node, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
192
+ }
193
+ }
194
+ } else if (mutation.type === 'attributes') {
195
+ findElements(mutation.target, DOM_SELECTOR, DOM_ATTRIBUTE, _component);
196
+ }
197
+ }
198
+ });
199
+
200
+ observer.observe(document.body, {
201
+ childList: true,
202
+ subtree: true,
203
+ attributes: true,
204
+ attributeFilter: [DOM_SELECTOR]
205
+ });
206
+ }
207
+ ```
208
+
209
+ **Rules:**
210
+ - `attributeFilter` always includes `DOM_SELECTOR` (and optionally trigger attributes like `'data-ln-toggle-for'`)
211
+ - `attributeFilter` is mandatory — without it the observer fires on EVERY attribute change (performance issue)
212
+ - On `attributes` mutation, `mutation.target` is the element whose attribute changed — call `findElements` directly on it
213
+
214
+ ---
215
+
216
+ ## Reactive Rendering Pattern
217
+
218
+ When a component has **multiple internal state properties** that together drive DOM updates, use `reactiveState` + `createBatcher` + `fill()` from ln-core. This eliminates manual querySelector chains and coalesces multiple synchronous state changes into a single DOM update.
219
+
220
+ ### The three primitives
221
+
222
+ | Helper | Role |
223
+ |--------|------|
224
+ | `reactiveState(initial, onChange)` | Proxy — calls `onChange(prop, value, old)` on every top-level property set |
225
+ | `createBatcher(renderFn, afterRender)` | Coalesces sync state changes into one `renderFn` call via `queueMicrotask` |
226
+ | `fill(root, data)` | Writes state to DOM via `data-ln-field`, `data-ln-attr`, `data-ln-show`, `data-ln-class` |
227
+
228
+ ### Pattern
229
+
230
+ ```javascript
231
+ import { reactiveState, createBatcher, fill, dispatch } from '../ln-core';
232
+
233
+ function _component(dom) {
234
+ this.dom = dom;
235
+ const self = this;
236
+
237
+ const queueRender = createBatcher(
238
+ function () { self._render(); }, // runs after current sync block
239
+ function () { self._afterRender(); } // runs after render — dispatch events here
240
+ );
241
+
242
+ this.state = reactiveState({ key: null, value: null }, queueRender);
243
+ }
244
+
245
+ _component.prototype._render = function () {
246
+ fill(this.dom, this.state);
247
+ // additional DOM logic derived from state
248
+ };
249
+
250
+ _component.prototype._afterRender = function () {
251
+ dispatch(this.dom, 'ln-component:changed', { key: this.state.key });
252
+ };
253
+ ```
254
+
255
+ When multiple properties change in the same sync block:
256
+
257
+ ```javascript
258
+ this.state.key = 'status'; // queues render
259
+ this.state.value = 'active'; // queues again — same microtask, no duplicate
260
+ // → microtask fires → _render() once → _afterRender() once
261
+ ```
262
+
263
+ ### `reactiveState` vs `deepReactive`
264
+
265
+ | | `reactiveState` | `deepReactive` |
266
+ |---|---|---|
267
+ | **Depth** | Shallow — top-level sets only | Deep — nested object/array mutations |
268
+ | **onChange args** | `(prop, value, old)` | `()` — no args |
269
+ | **Use when** | Flat state with primitive values | State contains nested objects or arrays |
270
+
271
+ ### `fill()` — limitations
272
+
273
+ `fill()` sets `textContent` for `data-ln-field` — it does **not** set `.value` on `<input>`, `<textarea>`, or `<select>`. For form elements, set `.value` manually after `fill()`.
274
+
275
+ ### When to use
276
+
277
+ Use reactive rendering when a component has **2+ state properties** that together drive DOM, and multiple changes happen in the same sync block.
278
+
279
+ **Don't use it for:**
280
+ - Single boolean state — `this.isOpen = true; dom.classList.toggle(...)` is simpler
281
+ - Event-driven rendering — components that receive data via `set-data` events (ln-table)
282
+ - One-shot renders — clone template + fill once, no ongoing state
283
+
284
+ ### Used in
285
+
286
+ *No library component currently uses this pattern. It remains documented for project-level components that need multi-property reactive state.*
287
+
288
+ ---
289
+
290
+ ## CustomEvent Communication
291
+
292
+ Components do NOT know about each other. Communication ONLY via CustomEvent.
293
+
294
+ ```javascript
295
+ import { dispatch } from '../ln-core';
296
+
297
+ // Dispatch
298
+ dispatch(this.dom, 'ln-toggle:open', { target: this.dom });
299
+
300
+ // Listen (in another component or integration code)
301
+ document.addEventListener('ln-toggle:open', function (e) {
302
+ console.log('Opened:', e.detail.target);
303
+ });
304
+ ```
305
+
306
+ ---
307
+
308
+ ## Lifecycle Events
309
+
310
+ Every component with actions must emit **paired events**: `before-{action}` (cancelable) + `{action}` (post).
311
+
312
+ ```javascript
313
+ import { dispatch, dispatchCancelable } from '../ln-core';
314
+
315
+ _component.prototype.open = function () {
316
+ if (this.isOpen) return;
317
+ const before = dispatchCancelable(this.dom, 'ln-component:before-open', { target: this.dom });
318
+ if (before.defaultPrevented) return; // external code can cancel
319
+ this.isOpen = true;
320
+ this.dom.classList.add('open');
321
+ dispatch(this.dom, 'ln-component:open', { target: this.dom });
322
+ };
323
+ ```
324
+
325
+ **Rules:**
326
+ - `before-{action}` — `cancelable: true`, fires **before** state change
327
+ - `{action}` — `cancelable: false`, fires **after** state change (fact, not prediction)
328
+ - Naming: `ln-{component}:before-{action}` and `ln-{component}:{action}`
329
+ - `detail` always contains `{ target: HTMLElement }` — the element where the action occurred
330
+
331
+ **Usage:**
332
+ ```javascript
333
+ // Cancel conditionally
334
+ element.addEventListener('ln-toggle:before-open', function (e) {
335
+ if (!userHasPermission()) e.preventDefault();
336
+ });
337
+
338
+ // React after the fact
339
+ document.addEventListener('ln-toggle:open', function (e) {
340
+ analytics.track('panel-opened', e.detail.target.id);
341
+ });
342
+ ```
343
+
344
+ ### CustomEvent detail null checks
345
+
346
+ Components that dispatch their own events control the `detail` payload — accessing
347
+ `e.detail.x` directly is correct because `detail` is always set by the dispatcher.
348
+
349
+ When listening to events where `detail` might be absent (external events, request
350
+ events where callers may omit detail), use the guard pattern:
351
+
352
+ ```javascript
353
+ // Guarded access — when detail may be null/undefined
354
+ const loading = e.detail && e.detail.x;
355
+
356
+ // Direct access — when this component dispatched the event
357
+ const target = e.detail.target;
358
+ ```
359
+
360
+ The `e.detail && e.detail.x` pattern is the project convention. Do not use
361
+ `(e.detail || {}).x` or optional chaining (`e.detail?.x`).
362
+
363
+ ---
364
+
365
+ ## Trigger Re-init Guard
366
+
367
+ When a component listens for click events on trigger elements, it must set a guard to prevent duplicate listeners on repeated DOM scans (MutationObserver):
368
+
369
+ ```javascript
370
+ function _attachTriggers(root) {
371
+ const triggers = Array.from(root.querySelectorAll('[data-ln-{name}-for]'));
372
+ triggers.forEach(function (btn) {
373
+ if (btn[DOM_ATTRIBUTE + 'Trigger']) return; // already initialized
374
+ const handler = function (e) {
375
+ if (e.ctrlKey || e.metaKey || e.button === 1) return; // allow browser shortcuts
376
+ e.preventDefault();
377
+ // ...
378
+ };
379
+ btn.addEventListener('click', handler);
380
+ btn[DOM_ATTRIBUTE + 'Trigger'] = handler; // store handler ref for later removeEventListener
381
+ });
382
+ }
383
+ ```
384
+
385
+ In `destroy()`, remove the trigger listeners using the stored handler reference:
386
+
387
+ ```javascript
388
+ _component.prototype.destroy = function () {
389
+ // ... other cleanup ...
390
+ const triggers = document.querySelectorAll('[data-ln-{name}-for="' + this.dom.id + '"]');
391
+ for (const btn of triggers) {
392
+ if (btn[DOM_ATTRIBUTE + 'Trigger']) {
393
+ btn.removeEventListener('click', btn[DOM_ATTRIBUTE + 'Trigger']);
394
+ delete btn[DOM_ATTRIBUTE + 'Trigger'];
395
+ }
396
+ }
397
+ };
398
+ ```
399
+
400
+ **Rules:**
401
+ - Guard: `btn[DOM_ATTRIBUTE + 'Trigger']` — truthy check works for both old boolean (`true`) and new handler reference (function)
402
+ - Store the handler function (not `true`) so `destroy()` can call `removeEventListener` with the exact reference
403
+ - Always allow ctrl/meta/middle-click before `e.preventDefault()`
404
+
405
+ ---
406
+
407
+ ## Component Dependencies
408
+
409
+ When a component depends on another (e.g. ln-accordion → ln-toggle):
410
+
411
+ 1. **Listen only to post-action events** (`ln-toggle:open`) — not before-events, unless you need to cancel
412
+ 2. **Communicate only via events or attributes** — dispatch `request-*` events to the target element, or write the target's `data-ln-*` attribute. NEVER reach into another component's instance to mutate state.
413
+ 3. **Emit your own events** for your own actions (`ln-accordion:change`)
414
+ 4. **Never import** another component — CustomEvent communication only
415
+
416
+ ```javascript
417
+ // Correct — listens to post-action, sets attribute on siblings, emits own event
418
+ dom.addEventListener('ln-toggle:open', function (e) {
419
+ dom.querySelectorAll('[data-ln-toggle]').forEach(function (el) {
420
+ if (el !== e.detail.target && el.getAttribute('data-ln-toggle') === 'open') {
421
+ el.setAttribute('data-ln-toggle', 'close');
422
+ }
423
+ });
424
+ _dispatch(dom, 'ln-accordion:change', { target: e.detail.target }); // own event
425
+ });
426
+ ```
427
+
428
+ ---
429
+
430
+ ## Coordinator/Mediator Pattern — Canonical Example
431
+
432
+ The architecture follows the **Mediator pattern** (GoF): components do not communicate with each other. The coordinator mediates all cross-component interactions.
433
+
434
+ ### Canonical Example: ln-accordion / ln-toggle
435
+
436
+ The ln-ashlar library already implements this:
437
+
438
+ - **ln-toggle** is a component (state layer): `data-ln-toggle` attribute is the single source of truth. MutationObserver detects attribute changes → applies `.open` class, emits `ln-toggle:open` / `ln-toggle:close`. The component exposes no state-mutating method; consumers write the attribute.
439
+ - **ln-accordion** is a coordinator (mediator): listens to `ln-toggle:open` from children, sets `data-ln-toggle="close"` on siblings. **Never** reaches into a sibling's instance — writes the attribute and lets each toggle's own observer run its own close pipeline. Emits its own `ln-accordion:change`.
440
+
441
+ ```
442
+ [Toggle A opens — attribute set to "open"]
443
+ ↓
444
+ MutationObserver → .open class + ln-toggle:open event (bubbles up)
445
+ ↓
446
+ [Accordion] catches it, sets data-ln-toggle="close" on B and C
447
+ ↓
448
+ [Toggle B] observer detects attribute change → if was open → closes
449
+ [Toggle C] observer detects attribute change → already closed → no-op
450
+ ```
451
+
452
+ Toggle **doesn't know** that other toggles exist. Accordion **doesn't know** about toggle's internal state. Communication = attribute changes + events only.
453
+
454
+ ### Scaling to Project Level
455
+
456
+ The same pattern scales from library to application:
457
+
458
+ | ln-ashlar (library) | Project (application) | Role |
459
+ |---|---|---|
460
+ | ln-toggle | ln-profile, ln-playlist, ln-deck | Component (state + events) |
461
+ | ln-accordion | ln-mixer (coordinator) | Mediator (event wiring) |
462
+ | `ln-toggle:open` | `ln-profile:switched` | Notification event (fact) |
463
+ | `setAttribute('data-ln-toggle', 'close')` | `ln-deck:request-load` | Attribute change / Request event (command) |
464
+ | `ln-accordion:change` | toast / modal close | Coordinator reaction |
465
+
466
+ ### Isolation Rules
467
+
468
+ 1. **Component → sibling component: FORBIDDEN.** A component NEVER queries another component (`lnSettings.getApiUrl()`, `nav.lnProfile.getProfile()`). Only the coordinator knows about all of them.
469
+ 2. **Component → storage/DB: FORBIDDEN.** A component does NOT call `lnDb.put()` or any storage backend. The coordinator decides which storage backend to call.
470
+ 3. **Coordinator → component query: ALLOWED.** The coordinator reads state directly (`el.lnProfile.currentId`).
471
+ 4. **Coordinator → component command: ONLY request events.** The coordinator dispatches `request-*` events, the component decides independently.
472
+
473
+ **Why?** Components become storage-agnostic and sibling-agnostic. Changing the backend (IndexedDB → API → localStorage) requires changes ONLY in the coordinator. Adding a new component requires changes ONLY in the coordinator.
474
+
475
+ ---
476
+
477
+ ## Auto-init on DOMContentLoaded
478
+
479
+ ```javascript
480
+ window[DOM_ATTRIBUTE] = constructor;
481
+ _domObserver();
482
+
483
+ if (document.readyState === 'loading') {
484
+ document.addEventListener('DOMContentLoaded', function () {
485
+ constructor(document.body);
486
+ });
487
+ } else {
488
+ constructor(document.body);
489
+ }
490
+ ```
491
+
492
+ ---
493
+
494
+ ## API Export Patterns
495
+
496
+ Components expose their API in one of three patterns, chosen based on how the component is used.
497
+
498
+ ### 1. Global Constructor (default)
499
+
500
+ Most components: the constructor is registered on `window`. Instances live on DOM elements.
501
+
502
+ ```javascript
503
+ window[DOM_ATTRIBUTE] = constructor; // window.lnToggle, window.lnModal, ...
504
+ // Instance: el.lnToggle.destroy(), el.lnModal.destroy()
505
+ ```
506
+
507
+ Use when: component has DOM instances, each element gets its own API.
508
+
509
+ ### 2. Element API (per-instance closure)
510
+
511
+ ln-upload: a plain object with methods is attached to the container element. The closure captures instance-specific state (file map, DOM references).
512
+
513
+ ```javascript
514
+ container.lnUploadAPI = { getFileIds, getFiles, clear, destroy };
515
+ ```
516
+
517
+ Use when: instance state is complex (closures, Maps) and doesn't fit the prototype pattern. Multiple instances on the same page each get their own API object.
518
+
519
+ ### 3. Global event listener (singleton)
520
+
521
+ ln-toast: no window registration. Pure event listener — the contract is two window events (`ln-toast:enqueue`, `ln-toast:clear`). Project code dispatches; ln-toast listens.
522
+
523
+ ```javascript
524
+ // In ln-toast.js — no window[DOM_ATTRIBUTE] = api anywhere
525
+ window.addEventListener('ln-toast:enqueue', _onEnqueue);
526
+ window.addEventListener('ln-toast:clear', _onClear);
527
+
528
+ // Usage from any component
529
+ window.dispatchEvent(new CustomEvent('ln-toast:enqueue', {
530
+ detail: { type: 'success', message: 'Saved' }
531
+ }));
532
+ ```
533
+
534
+ Use when: the component is a global service that other components should be able to invoke without having a reference to it. Pure event dispatch is portable across iframes, web components, and any script-loading order.
535
+
536
+ ### Choosing a pattern
537
+
538
+ | Situation | Pattern |
539
+ |-----------|---------|
540
+ | Multiple instances, each with own DOM + state | Global constructor |
541
+ | Multiple instances, complex closure state | Element API |
542
+ | Single global service, no per-element instances | Global event listener |
543
+
544
+ Do NOT standardize to one pattern — each exists for architectural reasons.
545
+
546
+ ---
547
+
548
+ ## Naming
549
+
550
+ | Element | Convention | Example |
551
+ |---------|-----------|--------|
552
+ | Data attribute | `data-ln-{name}` | `data-ln-toggle` |
553
+ | Window constructor | `window.ln{Name}` | `window.lnToggle` |
554
+ | DOM instance | `element.ln{Name}` | `el.lnToggle` |
555
+ | Custom event | `ln-{name}:{action}` | `ln-toggle:open` |
556
+ | CSS class | `.ln-{name}__{element}` | `.ln-toggle__backdrop` |
557
+ | Initialized flag | `element.ln{Name}` | `el.lnToggle` |
558
+ | Private function | `_functionName` | `_render`, `_attachTriggers` |
559
+
560
+ ---
561
+
562
+ ## Co-located SCSS
563
+
564
+ If a component needs CSS, create `components/ln-{name}/ln-{name}.scss`:
565
+
566
+ ```scss
567
+ @use '../../theme/config/mixins' as *;
568
+
569
+ // Use @include mixins and var(--token) values. Any transition
570
+ // that animates transform/opacity/width/height MUST be wrapped
571
+ // in @include motion-safe { ... }.
572
+ .ln-{name}__element {
573
+ @include fixed;
574
+ z-index: var(--z-overlay);
575
+ }
576
+ ```
577
+
578
+ Add it to `components/index.js`:
579
+ ```javascript
580
+ import './ln-{name}/ln-{name}.js';
581
+ import './ln-{name}/ln-{name}.scss';
582
+ ```
583
+
584
+ ---
585
+
586
+ ## Request Events — Details
587
+
588
+ > Architecture is defined in [ln-ashlar Project Architecture](#ln-ashlar-project-architecture-mandatory). This section covers technical details only.
589
+
590
+ ### Implementation in Component
591
+
592
+ ```javascript
593
+ // In _bindEvents — listen for request events on self
594
+ this.dom.addEventListener('ln-profile:request-create', function (e) {
595
+ self.create(e.detail.name); // calls the same prototype method
596
+ });
597
+ this.dom.addEventListener('ln-profile:request-remove', function (e) {
598
+ self.remove(e.detail.id);
599
+ });
600
+ ```
601
+
602
+ ### Dispatching from Coordinator
603
+
604
+ ```javascript
605
+ // Coordinator — dispatches request event (NOT direct API call)
606
+ nav.dispatchEvent(new CustomEvent('ln-profile:request-create', {
607
+ detail: { name: 'My Profile' }
608
+ }));
609
+ ```
610
+
611
+ ### Naming
612
+
613
+ | Type | Format | Example | Bubbles |
614
+ |-----|--------|--------|---------|
615
+ | Request (incoming) | `ln-{name}:request-{action}` | `ln-profile:request-create` | `false` |
616
+ | Notification (outgoing) | `ln-{name}:{past-tense}` | `ln-profile:created` | `true` |
617
+ | Lifecycle before | `ln-{name}:before-{action}` | `ln-toggle:before-open` | `true`, cancelable |
618
+ | Lifecycle after | `ln-{name}:{action}` | `ln-toggle:open` | `true` |
619
+
620
+ ### Commands vs Queries
621
+
622
+ | Type | Mechanism | Example |
623
+ |-----|-----------|--------|
624
+ | **Command** (changes state) | request event | `nav.dispatchEvent(new CustomEvent('ln-profile:request-remove', { detail: { id } }))` |
625
+ | **Query** (reads state) | direct access | `nav.lnProfile.currentId`, `sidebar.lnPlaylist.getTrack(idx)` |
626
+
627
+ ### Request-Reply / Collector Pattern (Sync Gathering)
628
+
629
+ For commands that require synchronous feedback or list gathering (such as batch validation before submit):
630
+ - **Format:** `ln-{name}:request-{action}` with `e.detail` containing a mutable data structure (like an array).
631
+ - **Example:** `ln-validate:request-validate` with `detail: { invalidFields: [] }`.
632
+ - **Mechanism:** The caller dispatches the event on a common ancestor (like `<form>`). Listeners catch it synchronously, evaluate their state, and push results/references directly to the mutable array in `e.detail`.
633
+ - **Bubbling:** `true` (to allow components/listeners attached to form or ancestor nodes to catch it).
634
+
635
+ ### Form Scoping and Validation Rules
636
+ - **Novalidate:** `ln-validate` injects `novalidate` on its host `<form>` automatically — the moment a form contains at least one `data-ln-validate` field, native browser bubbles are silenced in favor of inline error lists. Authors never write `novalidate` by hand; a form with zero `data-ln-validate` fields keeps native browser validation as the default.
637
+
638
+ ---
639
+
640
+ ## Coordinator — Full Example
641
+
642
+ > The coordinator is a thin IIFE, project-specific. No own state, no own DOM. Just wiring.
643
+
644
+ ```javascript
645
+ (function () {
646
+ 'use strict';
647
+ if (window.myCoordinator !== undefined) return;
648
+ window.myCoordinator = true;
649
+
650
+ function _getNav() { return document.querySelector('[data-ln-profile]'); }
651
+ function _getSidebar() { return document.querySelector('[data-ln-playlist]'); }
652
+
653
+ function _init() {
654
+ // 1. UI trigger → request event
655
+ document.addEventListener('click', function (e) {
656
+ if (e.target.closest('[data-ln-action="delete-profile"]')) {
657
+ const nav = _getNav();
658
+ if (nav && nav.lnProfile) {
659
+ nav.dispatchEvent(new CustomEvent('ln-profile:request-remove', {
660
+ detail: { id: nav.lnProfile.currentId } // query is OK
661
+ }));
662
+ }
663
+ }
664
+ });
665
+
666
+ // 2. Local-first write form → ln-data-coordinator claims the native
667
+ // submit (preventDefault); react to the store outcome instead.
668
+ // <form data-ln-form data-ln-data-coordinator-scope="profiles" method="post" action="/api/profiles">
669
+ document.addEventListener('ln-data-store:created', function (e) {
670
+ if (e.detail.store !== 'profiles') return;
671
+ document.getElementById('modal-new-profile').setAttribute('data-ln-modal', 'close');
672
+ });
673
+
674
+ // 3. Notification → UI feedback
675
+ document.addEventListener('ln-profile:created', function () {
676
+ window.dispatchEvent(new CustomEvent('ln-toast:enqueue', {
677
+ detail: { type: 'success', message: 'Profile created' }
678
+ }));
679
+ });
680
+
681
+ // 4. Bridge: component A event → component B attribute
682
+ document.addEventListener('ln-profile:switched', function (e) {
683
+ const sidebar = _getSidebar();
684
+ if (sidebar) {
685
+ sidebar.setAttribute('data-ln-playlist-profile', e.detail.profileId);
686
+ }
687
+ });
688
+ }
689
+
690
+ if (document.readyState === 'loading') {
691
+ document.addEventListener('DOMContentLoaded', _init);
692
+ } else {
693
+ _init();
694
+ }
695
+ })();
696
+ ```
697
+
698
+ **The four jobs of a coordinator:**
699
+ 1. **UI trigger → request event** — click/submit → dispatch request to component
700
+ 2. **Form processing** — read input, validate, clear, close modal
701
+ 3. **Notification → UI feedback** — toast, modal close, highlight
702
+ 4. **Bridge** — event from component A → attribute/request on component B
703
+
704
+ ---
705
+
706
+ ## Template System — DOM Structure in HTML, Not in JS
707
+
708
+ **NEVER** build DOM structure with `createElement` chains in JS. Use the native HTML `<template>` element.
709
+
710
+ ### Principle
711
+
712
+ DOM structure is an **HTML decision**, not a JS decision. The component only fills in the data.
713
+
714
+ ### Zero Display Text in JS (mandatory)
715
+
716
+ JS components **NEVER** contain hardcoded strings intended for user display — no labels, messages, button text, status text, relative time words, or any translatable content. All display text lives in HTML templates where the server (Blade, backend) can translate it.
717
+
718
+ ```
719
+ WRONG: el.textContent = 'No results found';
720
+ WRONG: el.textContent = '3 minutes ago';
721
+ WRONG: const label = count === 1 ? 'item' : 'items';
722
+
723
+ RIGHT: text comes from <template> → cloneTemplate → fill
724
+ RIGHT: text comes from hidden dict elements → buildDict (error messages, labels)
725
+ RIGHT: text comes from data-ln-* attribute set by server
726
+ RIGHT: text comes from Intl API (dates, numbers — browser-native i18n)
727
+ ```
728
+
729
+ **Exceptions:**
730
+ - `Intl` APIs and standardized measurement unit fallbacks are defined in [DOCTRINE.md](../DOCTRINE.md#4-html-template-system--zero-js-display-text).
731
+
732
+ If a component needs display text that Intl or universal unit symbols cannot provide, the text must come from HTML (template or dictionary).
733
+
734
+ ```
735
+ HTML: <template> defines structures (inert, not rendered)
736
+ HTML: <ul hidden> with <li data-{component}-dict="key"> defines translatable strings
737
+ JS: clone → fill (structures) / buildDict (strings)
738
+ ```
739
+
740
+ ### Dictionary pattern (i18n strings)
741
+
742
+ For error messages, labels, and other translatable strings that aren't part of a template structure, use `buildDict` from ln-core:
743
+
744
+ ```html
745
+ <!-- Hidden dict elements — server translates, JS reads once at init -->
746
+ <ul hidden>
747
+ <li data-ln-upload-dict="remove">{{ __('Remove') }}</li>
748
+ <li data-ln-upload-dict="error">{{ __('Error') }}</li>
749
+ <li data-ln-upload-dict="invalid-type">{{ __('This file type is not allowed') }}</li>
750
+ </ul>
751
+ ```
752
+
753
+ ```js
754
+ import { buildDict } from '../ln-core';
755
+
756
+ // Init — reads all elements, builds object, removes from DOM
757
+ const dict = buildDict(container, 'data-ln-upload-dict');
758
+
759
+ // Usage — O(1) property access
760
+ dict['remove'] // 'Remove'
761
+ dict['invalid-type'] // 'This file type is not allowed'
762
+ ```
763
+
764
+ **Convention:** `data-{component}-dict="key"` on `<li>` elements inside a single `<ul hidden>` in the component root. The `hidden` attribute on the `<ul>` hides the entire group. `buildDict` removes the elements after reading. Attribute name matches the component's naming pattern.
765
+
766
+ ### HTML — define templates at the end of `<body>`, before `<script>` tags
767
+
768
+ ```html
769
+ <!-- Templates -->
770
+ <template data-ln-template="track-item">
771
+ <li data-ln-track>
772
+ <span class="track-number" data-ln-drag-handle></span>
773
+ <article class="track-info">
774
+ <p class="track-name"></p>
775
+ <p class="track-artist"></p>
776
+ </article>
777
+ <nav class="track-actions">
778
+ <button type="button" data-ln-load-to="a">A</button>
779
+ <button type="button" data-ln-load-to="b">B</button>
780
+ </nav>
781
+ </li>
782
+ </template>
783
+
784
+ <template data-ln-template="profile-btn">
785
+ <button type="button" class="profile-btn" data-ln-profile-id></button>
786
+ </template>
787
+
788
+ <script src="..."></script>
789
+ ```
790
+
791
+ ### JS — `cloneTemplate` + `fill` / `fillTemplate` from ln-core
792
+
793
+ ```javascript
794
+ import { cloneTemplate, fill, fillTemplate } from '../ln-core';
795
+ ```
796
+
797
+ `cloneTemplate(name, componentTag)` caches the lookup and returns `tmpl.content.cloneNode(true)`.
798
+
799
+ ### Usage — clone + fill (declarative)
800
+
801
+ Use `data-ln-field` for text, `data-ln-attr` for attributes:
802
+
803
+ ```html
804
+ <template data-ln-template="track-item">
805
+ <li data-ln-track>
806
+ <span class="track-number" data-ln-field="number"></span>
807
+ <article class="track-info">
808
+ <p data-ln-field="title"></p>
809
+ <p data-ln-field="artist"></p>
810
+ </article>
811
+ </li>
812
+ </template>
813
+ ```
814
+
815
+ ```javascript
816
+ _component.prototype._buildTrackItem = function (track, idx) {
817
+ const frag = cloneTemplate('track-item', 'ln-playlist');
818
+ const li = frag.querySelector('[data-ln-track]');
819
+ fill(li, { number: idx + 1, title: track.title, artist: track.artist });
820
+ return li;
821
+ };
822
+ ```
823
+
824
+ ### Advanced `fill()` — attributes and state classes
825
+
826
+ Beyond text, `fill()` binds attributes (`data-ln-attr`) and toggles classes (`data-ln-class`). ln-upload exercises all three in a single call:
827
+
828
+ ```html
829
+ <template data-ln-template="ln-upload-item">
830
+ <li class="ln-upload__item"
831
+ data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting">
832
+ <svg class="ln-icon" aria-hidden="true">
833
+ <use data-ln-attr="href:iconHref" href="#ln-icon-file"></use>
834
+ </svg>
835
+ <span class="ln-upload__name" data-ln-field="name"></span>
836
+ <span class="ln-upload__size" data-ln-field="sizeText"></span>
837
+ <button type="button" class="ln-upload__remove"
838
+ data-ln-upload-action="remove"
839
+ data-ln-attr="aria-label:removeLabel, title:removeLabel">
840
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-x"></use></svg>
841
+ </button>
842
+ <div class="ln-upload__progress">
843
+ <div class="ln-upload__progress-bar"></div>
844
+ </div>
845
+ </li>
846
+ </template>
847
+ ```
848
+
849
+ ```javascript
850
+ const fragment = cloneTemplateScoped(container, 'ln-upload-item', 'ln-upload');
851
+ const li = fragment.firstElementChild;
852
+ li.setAttribute('data-file-id', localId);
853
+ fill(li, {
854
+ name: file.name,
855
+ sizeText: '0%',
856
+ iconHref: '#' + iconId, // → <use href="#ln-icon-custom-file-pdf">
857
+ removeLabel: dict.remove, // → aria-label + title on button
858
+ uploading: true, // → adds ln-upload__item--uploading
859
+ error: false,
860
+ deleting: false
861
+ });
862
+ list.appendChild(li);
863
+
864
+ // Later, on XHR progress — update only what changed:
865
+ fill(li, { sizeText: percent + '%' });
866
+
867
+ // On upload success — flip state classes:
868
+ fill(li, { sizeText: formatSize(size), uploading: false });
869
+
870
+ // On upload error:
871
+ fill(li, { sizeText: dict.error, uploading: false, error: true });
872
+ ```
873
+
874
+ One `fill()` call handles all three binding types at once:
875
+
876
+ - **`data-ln-field="name"`** → `textContent` assignment
877
+ - **`data-ln-attr="href:iconHref"`** → one or more attributes per element (`href` on `<use>`, `aria-label` + `title` on the button)
878
+ - **`data-ln-class="ln-upload__item--uploading:uploading, ..."`** → conditional class toggles driven by booleans
879
+
880
+ State transitions are just successive `fill()` calls with different values — no imperative `classList.add/remove`, no manual `setAttribute`. The icon swap via `data-ln-attr="href:iconHref"` on a `<use>` element works because `ln-icon` runs a MutationObserver on `<use href>` changes and auto-fetches the new sprite — the component never touches the icon loader directly.
881
+
882
+ **Behavioral hooks live on attributes, not classes** — `data-ln-upload-action="remove"` is a JS query hook, not a fill slot. Delegated click handlers on `.ln-upload__list` use `e.target.closest('[data-ln-upload-action="remove"]')` to locate the button, which also matches when the click lands on a nested `<svg>` / `<use>`. Keeping behavioral hooks off CSS class names means a project can rename or restyle classes without breaking JS.
883
+
884
+ **Imperative escape hatches** — two things `fill()` can't express cleanly:
885
+
886
+ 1. `removeBtn.disabled = !uploaded` — the `disabled` boolean attribute doesn't round-trip through `data-ln-attr` (setting vs. removing).
887
+ 2. `progressBar.style.width = percent + '%'` — a continuous animation value, not discrete state. Same precedent as `ln-table` virtual-scroll spacer heights.
888
+
889
+ Both are one-line assignments right after the `fill()` call and are the only non-declarative touches in the render path.
890
+
891
+ ### Scoped templates — per-instance override
892
+
893
+ `cloneTemplate(name)` looks up a single global `<template>` at document root. For customizable components, use `cloneTemplateScoped(root, name, componentTag)` instead — it checks inside `root` first, then falls back to the global lookup. This lets projects override the layout per instance without forking the component:
894
+
895
+ ```html
896
+ <!-- Default instance — uses the global or auto-injected template -->
897
+ <div data-ln-upload="/files/upload"></div>
898
+
899
+ <!-- Customized instance — scoped <template> inside the container -->
900
+ <div data-ln-upload="/files/upload">
901
+ <template data-ln-template="ln-upload-item">
902
+ <li class="ln-upload__item"
903
+ data-ln-class="ln-upload__item--uploading:uploading, ln-upload__item--error:error, ln-upload__item--deleting:deleting">
904
+ <svg class="ln-icon ln-icon--lg" aria-hidden="true">
905
+ <use data-ln-attr="href:iconHref" href="#ln-icon-file"></use>
906
+ </svg>
907
+ <article>
908
+ <p class="ln-upload__name" data-ln-field="name"></p>
909
+ <small class="ln-upload__size" data-ln-field="sizeText"></small>
910
+ </article>
911
+ <button type="button" class="ln-upload__remove"
912
+ data-ln-upload-action="remove"
913
+ data-ln-attr="aria-label:removeLabel, title:removeLabel">
914
+ <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-x"></use></svg>
915
+ </button>
916
+ <div class="ln-upload__progress"><div class="ln-upload__progress-bar"></div></div>
917
+ </li>
918
+ </template>
919
+ <div class="ln-upload__zone"><p>Drop files</p></div>
920
+ <ul class="ln-upload__list"></ul>
921
+ </div>
922
+ ```
923
+
924
+ Lookup order for `cloneTemplateScoped`:
925
+
926
+ 1. **Scoped** — `<template>` inside the container element (per-instance)
927
+ 2. **Global** — `<template>` anywhere at document root (shared default)
928
+ 3. **Auto-injected default** *(optional)* — if the component ships a built-in default, inject it into `<body>` on first init when neither scoped nor global is present. This keeps zero-config usage working without forcing every project to copy-paste markup.
929
+
930
+ ln-upload implements all three tiers. The auto-inject runs once per init via a helper:
931
+
932
+ ```javascript
933
+ const DEFAULT_ITEM_TEMPLATE_HTML =
934
+ '<template data-ln-template="ln-upload-item">' +
935
+ /* ...default <li> markup... */
936
+ '</template>';
937
+
938
+ function _ensureDefaultItemTemplate() {
939
+ if (document.querySelector('[data-ln-template="ln-upload-item"]')) return;
940
+ const wrapper = document.createElement('div');
941
+ wrapper.innerHTML = DEFAULT_ITEM_TEMPLATE_HTML;
942
+ document.body.appendChild(wrapper.firstElementChild);
943
+ }
944
+ ```
945
+
946
+ The component calls `_ensureDefaultItemTemplate()` at the top of `_initUpload()`, before any `cloneTemplateScoped` call. `cloneTemplate`'s internal cache only stores truthy lookups, so inserting a template after a previous `null` lookup is safe — the next call picks it up.
947
+
948
+ **When to use which:**
949
+
950
+ | Component shape | Use |
951
+ |---|---|
952
+ | Single template reused across all instances, never customized | `cloneTemplate(name, tag)` |
953
+ | Instance layout may vary per project or per page | `cloneTemplateScoped(container, name, tag)` |
954
+ | Zero-config out of the box + optional per-instance override | scoped lookup + auto-injected default |
955
+
956
+ ### Components using templates
957
+
958
+ | Component | Template(s) | Lookup | Notes |
959
+ |---|---|---|---|
960
+ | **ln-upload** | `ln-upload-item` | `cloneTemplateScoped` | Scoped → global → auto-injected default on first init |
961
+ | **ln-table** | `{name}-row`, `{name}-empty`, `{name}-empty-filtered`, `column-filter` (also falls back to `{name}-column-filter`) | `cloneTemplateScoped` | Scoped-first per table instance |
962
+ | **ln-translations** | `ln-translations-menu-item`, `ln-translations-badge` | `cloneTemplate` | Single global template, no per-instance override |
963
+
964
+ ### Rules
965
+
966
+ 1. **`<template data-ln-template="name">`** — naming convention
967
+ 2. **Placement:** at the end of `<body>`, before `<script>` tags, in a `TEMPLATES` comment block
968
+ 3. **`content.cloneNode(true)`** returns a DocumentFragment — query the root element with `querySelector`
969
+ 4. **JS only fills:** `textContent`, `setAttribute`, `classList` — does NOT create structure
970
+ 5. **Conditions:** small conditional elements (1-2 spans for indicators) are OK as `createElement`
971
+ 6. **One template, one function:** if the same structure is created in 2+ places, it must be a `<template>` + shared function
972
+
973
+ ### Why
974
+
975
+ | createElement chains | `<template>` |
976
+ |---------------------|--------------|
977
+ | 60+ lines of JS for one `<li>` | 10 lines HTML + 8 lines JS |
978
+ | Structure hidden in JS | Structure visible in HTML |
979
+ | Duplication between methods | One definition, one function |
980
+ | Hard for designers | HTML — easy to change |
981
+
982
+ ---
983
+
984
+ ## Components (reference)
985
+
986
+ | Component | Pattern | Data Attr | Description |
987
+ |-----------|---------|-----------|------|
988
+ | ln-core | Shared module | — | cloneTemplate, cloneTemplateScoped, dispatch, dispatchCancelable, fill, fillTemplate, renderList, buildDict, guardBody, findElements, reactiveState, deepReactive, createBatcher, createWindowCache |
989
+ | ln-toggle | Instance | `data-ln-toggle` | Generic toggle (sidebar, collapse) |
990
+ | ln-accordion | Instance | `data-ln-accordion` | Wrapper — only one toggle open at a time |
991
+ | ln-tabs | Instance | `data-ln-tabs` | Tab navigation — URL hash (anchor triggers) or localStorage persist (button triggers) |
992
+ | ln-nav | Instance | `data-ln-nav` | Active link highlighter |
993
+ | ln-modal | Instance | `data-ln-modal` | Modal dialog |
994
+ | ln-ui-coordinator | Coordinator | `data-ln-ui-coordinator` | UI mediator — modals, AJAX, forms, triggers, and toast notifications |
995
+ | ln-toast | Functional | `data-ln-toast` | Toast notifications |
996
+ | ln-upload | Functional | `data-ln-upload` | File upload |
997
+ | ln-ajax | Functional | `data-ln-ajax` | AJAX navigation |
998
+ | ln-progress | Functional | `data-ln-progress` | Progress bar |
999
+ | ln-circular-progress | Instance | `data-ln-circular-progress` | Circular (ring) progress indicator |
1000
+ | ln-search | Instance | `data-ln-search` | Generic search (textContent filter) |
1001
+ | ln-filter | Instance | `data-ln-filter` | Generic filter (data attribute filter) |
1002
+ | ln-sort | Instance | `data-ln-sort` | Sort control primitive — 3-trigger cycle (none→asc→desc→none), search-precedent cancelable event; targets ln-table, ln-list, or plain lists |
1003
+ | ln-table | Instance | `data-ln-table` | Data table (search, filter, sort, virtual scroll; opt-in server-side windowed virtualization via `data-ln-table-window`) |
1004
+ | ln-chart | Instance | `data-ln-chart` | SVG line/area dataset renderer with `ln-data-coordinator` binding |
1005
+ | ln-sortable | Instance | `data-ln-sortable` | Drag & drop reorder |
1006
+ | ln-picklist | Instance | `data-ln-picklist` | Two-list checkbox selection |
1007
+ | ln-dropdown | Instance | `data-ln-dropdown` | Positioned dropdown menu (wraps ln-toggle) |
1008
+ | ln-popover | Instance | `data-ln-popover` | Rich popover with viewport-aware positioning and ESC-stack management |
1009
+ | ln-key | Instance | `data-ln-key` | Keyboard shortcut primitive that triggers native click or focus behavior |
1010
+ | ln-link | Instance | `data-ln-link` | Clickable rows/containers |
1011
+ | ln-confirm | Instance | `data-ln-confirm` | Two-click confirmation for destructive actions |
1012
+ | ln-autosave | Instance | `data-ln-autosave` | Auto-save form to localStorage on blur/change |
1013
+ | ln-autoresize | Instance | `data-ln-autoresize` | Auto-resize textarea height |
1014
+ | ln-date | Instance | `data-ln-date` | Locale-aware date formatter with native picker and typing support |
1015
+ | ln-number | Instance | `data-ln-number` | Locale-aware number formatter (decimal, currency, percent) |
1016
+ | ln-time | Instance | `data-ln-time` | Relative and absolute time formatter, auto-updates on shared 60s tick |
1017
+ | ln-translations | Instance | `data-ln-translations` | Multi-language form field management |
1018
+ | ln-external-links | Utility | — | External links handler |
1019
+ | ln-http | Global service | — | Event-driven JSON fetch with abort support |
1020
+
1021
+ ---
1022
+
1023
+ ## Component Relationships
1024
+
1025
+ Components do not import each other. Every relationship below is
1026
+ through one of two channels:
1027
+
1028
+ - **Events** — one component dispatches `ln-{a}:{action}`, another
1029
+ listens for it (`ln-accordion` listens to `ln-toggle:open`).
1030
+ - **Shared DOM state** — one component sets a `data-ln-*` attribute,
1031
+ another's MutationObserver reacts (ln-accordion setting
1032
+ `data-ln-toggle="close"` on sibling toggles).
1033
+
1034
+ No component ever does `import '../ln-toggle'` or reaches into
1035
+ `otherEl.lnToggle` to mutate its state. If you catch yourself reaching
1036
+ for an import or for a sibling's instance, re-read the Mediator section
1037
+ above — write the attribute instead.
1038
+
1039
+ ### ln-toggle — the state primitive
1040
+
1041
+ `ln-toggle` is the only open/close state primitive. Components that
1042
+ need the "coordinator collapses siblings" pattern build on it:
1043
+
1044
+ ```
1045
+ ln-toggle (state: data-ln-toggle attribute, ln-toggle:open/close events)
1046
+ ├── ln-accordion coordinator — listens to ln-toggle:open on children,
1047
+ │ sets data-ln-toggle="close" on siblings
1048
+ └── ln-dropdown wraps a child [data-ln-toggle] and adds viewport-aware
1049
+ positioning on ln-toggle:open
1050
+ ```
1051
+
1052
+ `ln-modal`, `ln-popover`, and `ln-tabs` are **not** built on `ln-toggle`.
1053
+ They each own an independent open/close state machine backed by their
1054
+ own attribute (`data-ln-modal`, `data-ln-popover`, `data-ln-tabs-active`).
1055
+ The "attribute is the single source of truth" pattern is shared; the
1056
+ specific attribute is not.
1057
+
1058
+ ### Form family — ln-validate owns the submit gate
1059
+
1060
+ ```
1061
+ ln-validate (first field per form attaches the submit gate;
1062
+ every field self-registers its own request-validate
1063
+ listener)
1064
+
1065
+ ln-form (populate + RESTful action routing only — no submit
1066
+ listener, no validation awareness)
1067
+
1068
+ ln-autosave stores form state in localStorage; writes on blur/change,
1069
+ independently of both ln-form and ln-validate
1070
+ ```
1071
+
1072
+ None of the three import each other or call cross-instance methods.
1073
+ `ln-form.fill()` dispatches synthetic `input`/`change` on populated
1074
+ fields; `ln-validate`'s own field listeners react to those exactly as
1075
+ they would to real user input. `ln-autosave` does the same on restore.
1076
+
1077
+ ### Data family — ln-table ↔ ln-store through the coordinator
1078
+
1079
+ ```
1080
+ ln-table ln-store
1081
+ │ │
1082
+ │ ln-table:request-data ──────────────▶│
1083
+ │ (sort, filters, search) │ (reads IndexedDB)
1084
+ │ │
1085
+ │◀─────── ln-table:set-data │
1086
+ │ (rows + totals) │
1087
+ │ │
1088
+ │◀─────── ln-data-store:synced │
1089
+ │ (coordinator re-queries) │
1090
+ ▲
1091
+ │
1092
+ project coordinator
1093
+ ```
1094
+
1095
+ `ln-table` never imports or queries `ln-store`. It emits a request
1096
+ event; the project coordinator catches it, calls `storeEl.lnStore.getAll(...)`,
1097
+ and dispatches `ln-table:set-data` back. See
1098
+ [docs/architecture/component-guide.md](../docs/architecture/component-guide.md#data-flow-with-ln-store)
1099
+ for the full handshake.
1100
+
1101
+ `ln-list` and `ln-chart` use the same request/set-data handshake. None of
1102
+ the renderers import or query the store directly; `ln-data-coordinator` owns source selection and refresh.
1103
+
1104
+ ### Rule
1105
+
1106
+ > **Relationships are through events, never imports.**
1107
+
1108
+ If two components need to know about each other's state, introduce a
1109
+ coordinator between them. Components stay storage-agnostic and
1110
+ sibling-agnostic; the coordinator is the only piece that knows the
1111
+ whole stack.
1112
+
1113
+ ---
1114
+
1115
+ > For the latest component skeleton and checklist →
1116
+ > see [docs/architecture/component-guide.md](../docs/architecture/component-guide.md)