@worca/app 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -0,0 +1,624 @@
1
+ // ui/public/ask/widgets-input.mjs
2
+ // The INPUT half of the ask widget catalog v1 (ask-forms design §6.1). Every
3
+ // entry is `(item, ctx) => Element`; `ctx` is the object form-renderer.mjs builds.
4
+ // Agent strings reach the DOM through textContent only. No fetch, no listener
5
+ // outside the returned tree, no colour literal.
6
+ import { h, icon, ICON_CHECK, ICON_UP, ICON_DOWN, fmtBytes, extOf, toneOf, own } from './dom.mjs';
7
+ import { resolvePath } from '../../../src/shared/forms/paths.mjs';
8
+
9
+ /** '', null and undefined are "no value". `false` and `0` are values. */
10
+ export const EMPTY = (v) => v === undefined || v === null || v === '';
11
+
12
+ /**
13
+ * Label + optional agent `help` + control + the role=alert error slot.
14
+ * `group: true` draws the label as a <div id="<id>-l"> instead of a <label for>,
15
+ * for a choice set whose control is a role=group of buttons.
16
+ */
17
+ export function fieldShell(item, ctx, control, { group = false, noLabel = false } = {}) {
18
+ const { doc } = ctx;
19
+ const s = ctx.schemaOf(item.field);
20
+ const id = ctx.idFor(item.field);
21
+ const wrap = h(doc, 'div', 'af-fld');
22
+ wrap.dataset.field = item.field;
23
+ if (!noLabel) {
24
+ const label = h(doc, group ? 'div' : 'label', 'af-label', item.label || s.title || item.field);
25
+ label.id = `${id}-l`;
26
+ if (!group) label.setAttribute('for', id);
27
+ if (ctx.isRequired(item.field)) {
28
+ const star = h(doc, 'span', 'af-req', '*');
29
+ star.setAttribute('aria-hidden', 'true');
30
+ label.appendChild(star);
31
+ }
32
+ wrap.appendChild(label);
33
+ }
34
+ // `help` is AGENT content (the one prose the house rule allows here).
35
+ const help = item.help || s.description;
36
+ if (help) wrap.appendChild(h(doc, 'p', 'af-help', help));
37
+ for (const node of Array.isArray(control) ? control : [control]) if (node) wrap.appendChild(node);
38
+ wrap.appendChild(ctx.errSlot(item.field));
39
+ return wrap;
40
+ }
41
+
42
+ /** Mark a control as required/disabled the same way everywhere. */
43
+ export function prepControl(node, item, ctx) {
44
+ node.id = ctx.idFor(item.field);
45
+ if (ctx.isRequired(item.field)) node.setAttribute('aria-required', 'true');
46
+ if (ctx.readonly) node.disabled = true;
47
+ return node;
48
+ }
49
+
50
+ /** The white tick a picked choice carries. Built fresh per call (one node, one place). */
51
+ function checkMark(ctx) {
52
+ const m = h(ctx.doc, 'span', 'af-mark');
53
+ const svg = icon(ctx.doc, ICON_CHECK, 12);
54
+ svg.setAttribute('stroke-width', '3.4');
55
+ m.appendChild(svg);
56
+ return m;
57
+ }
58
+
59
+ /**
60
+ * The option list for a choice widget:
61
+ * - `item.options = { from, value, label, description }` reads rows out of the
62
+ * ask DATA through the shared path language;
63
+ * - otherwise the schema's own `enum` (or `items.enum` for an array), relabelled
64
+ * by `item.labels` / `item.descriptions`.
65
+ */
66
+ export function optionsFor(item, s, ctx) {
67
+ if (item.options && item.options.from) {
68
+ const rows = resolvePath(item.options.from, { data: ctx.data });
69
+ const o = item.options;
70
+ return (Array.isArray(rows) ? rows : []).map((r) => ({
71
+ value: own(r, o.value || 'id'),
72
+ label: String(own(r, o.label || 'label') ?? own(r, o.value || 'id') ?? ''),
73
+ desc: o.description ? String(own(r, o.description) ?? '') : '',
74
+ }));
75
+ }
76
+ const en = (s && s.enum) || (s && s.items && s.items.enum) || [];
77
+ return en.map((v) => ({
78
+ value: v,
79
+ label: String(own(item.labels, v) ?? v),
80
+ desc: String(own(item.descriptions, v) ?? ''),
81
+ }));
82
+ }
83
+
84
+ /** One choice button: tick + label (+ description). `role` is 'button' or 'checkbox'. */
85
+ export function choiceButton(ctx, opt, { id, role = null, on = false }) {
86
+ const b = h(ctx.doc, 'button', 'af-choice');
87
+ b.type = 'button';
88
+ b.id = id;
89
+ if (role) { b.setAttribute('role', role); b.setAttribute('aria-checked', String(on)); }
90
+ else b.setAttribute('aria-pressed', String(on));
91
+ if (on) b.classList.add('on');
92
+ b.disabled = ctx.readonly;
93
+ const txt = h(ctx.doc, 'span', 'af-choice-txt', opt.label);
94
+ if (opt.desc) txt.appendChild(h(ctx.doc, 'span', 'af-choice-desc', opt.desc));
95
+ b.append(checkMark(ctx), txt);
96
+ return b;
97
+ }
98
+
99
+ /** The house `.seg` control, driven by aria-pressed + the `.on` class it styles on. */
100
+ export function segmented(ctx, opts, current, onPick, { tones = null, small = false, idBase = '', labelledBy = '' } = {}) {
101
+ const box = h(ctx.doc, 'div', small ? 'seg af-seg-sm' : 'seg');
102
+ box.setAttribute('role', 'group');
103
+ if (labelledBy) box.setAttribute('aria-labelledby', labelledBy);
104
+ opts.forEach((o, i) => {
105
+ const b = h(ctx.doc, 'button', o.value === current ? 'on' : '', o.label);
106
+ b.type = 'button';
107
+ b.id = `${idBase}-${i}`;
108
+ b.setAttribute('aria-pressed', String(o.value === current));
109
+ const tone = toneOf(tones && tones[o.value]);
110
+ if (tone) b.dataset.tone = tone;
111
+ b.disabled = ctx.readonly;
112
+ b.addEventListener('click', () => {
113
+ for (const sib of box.children) { sib.setAttribute('aria-pressed', 'false'); sib.classList.remove('on'); }
114
+ b.setAttribute('aria-pressed', 'true'); b.classList.add('on');
115
+ onPick(o.value);
116
+ });
117
+ box.appendChild(b);
118
+ });
119
+ return box;
120
+ }
121
+
122
+ function textWidget(item, ctx) {
123
+ const s = ctx.schemaOf(item.field);
124
+ const input = ctx.doc.createElement('input');
125
+ input.type = 'text';
126
+ input.className = item.mono ? 'af-inp mono' : 'af-inp';
127
+ if (item.placeholder) input.placeholder = String(item.placeholder);
128
+ if (s.maxLength != null) input.maxLength = Number(s.maxLength);
129
+ input.value = ctx.get(item.field) == null ? '' : String(ctx.get(item.field));
130
+ prepControl(input, item, ctx);
131
+ input.addEventListener('input', () => ctx.set(item.field, input.value));
132
+ return fieldShell(item, ctx, input);
133
+ }
134
+
135
+ function textareaWidget(item, ctx) {
136
+ const s = ctx.schemaOf(item.field);
137
+ const ta = ctx.doc.createElement('textarea');
138
+ ta.className = 'af-inp af-area';
139
+ ta.rows = Number(item.rows) > 0 ? Number(item.rows) : 3;
140
+ if (item.placeholder) ta.placeholder = String(item.placeholder);
141
+ if (s.maxLength != null) ta.maxLength = Number(s.maxLength);
142
+ ta.value = ctx.get(item.field) == null ? '' : String(ctx.get(item.field));
143
+ prepControl(ta, item, ctx);
144
+ ta.addEventListener('input', () => ctx.set(item.field, ta.value));
145
+ return fieldShell(item, ctx, ta);
146
+ }
147
+
148
+ function numberWidget(item, ctx) {
149
+ const s = ctx.schemaOf(item.field);
150
+ const inp = ctx.doc.createElement('input');
151
+ inp.type = 'number';
152
+ inp.className = 'af-inp mono';
153
+ if (s.minimum != null) inp.setAttribute('min', String(s.minimum));
154
+ if (s.maximum != null) inp.setAttribute('max', String(s.maximum));
155
+ inp.setAttribute('step', String(s.multipleOf != null ? s.multipleOf : (s.type === 'integer' ? 1 : 'any')));
156
+ if (item.placeholder) inp.placeholder = String(item.placeholder);
157
+ const cur = ctx.get(item.field);
158
+ inp.value = cur == null ? '' : String(cur);
159
+ prepControl(inp, item, ctx);
160
+ inp.addEventListener('input', () => {
161
+ const n = Number(inp.value);
162
+ ctx.set(item.field, inp.value === '' || !Number.isFinite(n) ? '' : n);
163
+ });
164
+ const row = h(ctx.doc, 'div', 'af-num-row');
165
+ row.appendChild(inp);
166
+ if (item.unit) row.appendChild(h(ctx.doc, 'span', 'af-unit', item.unit));
167
+ return fieldShell(item, ctx, row);
168
+ }
169
+
170
+ function sliderWidget(item, ctx) {
171
+ const s = ctx.schemaOf(item.field);
172
+ const unit = item.unit ? String(item.unit) : '';
173
+ const min = s.minimum != null ? Number(s.minimum) : 0;
174
+ const max = s.maximum != null ? Number(s.maximum) : 100;
175
+ const cur = ctx.get(item.field);
176
+ const val = cur == null ? min : Number(cur);
177
+ // Same rule as the toggle: a required slider with no value posts the position it
178
+ // shows (its minimum - what P1's auto mode answers); an optional one stays unanswered.
179
+ if (!ctx.readonly && cur == null && ctx.isRequired(item.field)) ctx.values[item.field] = val;
180
+ const out = h(ctx.doc, 'output', 'af-slider-val', `${val}${unit}`);
181
+ const r = ctx.doc.createElement('input');
182
+ r.type = 'range';
183
+ r.setAttribute('min', String(min));
184
+ r.setAttribute('max', String(max));
185
+ r.setAttribute('step', String(s.multipleOf != null ? s.multipleOf : 1));
186
+ r.value = String(val);
187
+ prepControl(r, item, ctx);
188
+ r.addEventListener('input', () => { out.textContent = `${r.value}${unit}`; ctx.set(item.field, Number(r.value)); });
189
+ const row = h(ctx.doc, 'div', 'af-slider-row');
190
+ row.append(r, out);
191
+ const ends = h(ctx.doc, 'div', 'af-slider-ends');
192
+ ends.append(h(ctx.doc, 'span', '', item.minLabel != null ? item.minLabel : `${min}${unit}`),
193
+ h(ctx.doc, 'span', '', item.maxLabel != null ? item.maxLabel : `${max}${unit}`));
194
+ return fieldShell(item, ctx, [row, ends]);
195
+ }
196
+
197
+ function toggleWidget(item, ctx) {
198
+ const s = ctx.schemaOf(item.field);
199
+ // The switch's visible state IS its answer, exactly as P1's auto mode reads it
200
+ // (answer.mjs `candidate`: a required boolean with no default is `false`): a
201
+ // required toggle with no value starts as the "off" it shows, so an untouched
202
+ // switch is never a `required` error on a control that plainly reads off. An
203
+ // optional one stays unanswered until moved - auto mode omits it too.
204
+ if (!ctx.readonly && ctx.get(item.field) == null && ctx.isRequired(item.field)) ctx.values[item.field] = false;
205
+ const box = ctx.doc.createElement('input');
206
+ box.type = 'checkbox';
207
+ box.className = 'sw-input'; // the existing switch skin
208
+ box.checked = ctx.get(item.field) === true;
209
+ prepControl(box, item, ctx);
210
+ box.addEventListener('change', () => { ctx.values[item.field] = box.checked; ctx.set(item.field, box.checked); });
211
+ const row = h(ctx.doc, 'label', 'af-switch-row');
212
+ row.setAttribute('for', box.id);
213
+ row.append(box, h(ctx.doc, 'span', 'switch'),
214
+ h(ctx.doc, 'span', 'af-switch-txt', item.label || s.title || item.field));
215
+ return fieldShell(item, ctx, row, { noLabel: true });
216
+ }
217
+
218
+ function dateWidget(item, ctx) {
219
+ const inp = ctx.doc.createElement('input');
220
+ inp.type = 'date';
221
+ inp.className = 'af-inp af-date mono';
222
+ const cur = ctx.get(item.field);
223
+ inp.value = cur == null ? '' : String(cur);
224
+ prepControl(inp, item, ctx);
225
+ inp.addEventListener('input', () => ctx.set(item.field, inp.value));
226
+ return fieldShell(item, ctx, inp);
227
+ }
228
+
229
+ function selectWidget(item, ctx) {
230
+ const s = ctx.schemaOf(item.field);
231
+ const id = ctx.idFor(item.field);
232
+
233
+ // X5 / P1 C14 — today's clarify question, expressed as a form: suggestions + free
234
+ // text. `suggest` is a LAYOUT key (`item.suggest`), never a schema keyword; the
235
+ // field's schema is a plain { type: 'string' }, so no enum check ever runs.
236
+ if (Array.isArray(item.suggest) && item.suggest.length) {
237
+ const suggest = item.suggest;
238
+ const list = h(ctx.doc, 'div', 'af-choices');
239
+ list.setAttribute('role', 'group');
240
+ list.setAttribute('aria-labelledby', `${id}-l`);
241
+ const free = ctx.doc.createElement('input');
242
+ free.type = 'text';
243
+ free.className = 'af-inp af-free';
244
+ free.id = `${id}-free`;
245
+ free.setAttribute('aria-labelledby', `${id}-l`);
246
+ free.disabled = ctx.readonly;
247
+ const release = () => { for (const b of list.children) { b.setAttribute('aria-pressed', 'false'); b.classList.remove('on'); } };
248
+ suggest.forEach((v, i) => {
249
+ const opt = { value: v, label: String(v), desc: '' };
250
+ const b = choiceButton(ctx, opt, { id: `${id}-${i}`, on: ctx.get(item.field) === v });
251
+ b.addEventListener('click', () => { release(); b.setAttribute('aria-pressed', 'true'); b.classList.add('on'); free.value = ''; ctx.set(item.field, v); });
252
+ list.appendChild(b);
253
+ });
254
+ const cur = ctx.get(item.field);
255
+ free.value = cur != null && !suggest.includes(cur) ? String(cur) : '';
256
+ free.addEventListener('input', () => { release(); ctx.set(item.field, free.value); });
257
+ return fieldShell(item, ctx, [list, free], { group: true });
258
+ }
259
+
260
+ const opts = optionsFor(item, s, ctx);
261
+ const style = item.style || (opts.length <= 6 ? 'cards' : 'dropdown');
262
+
263
+ if (style === 'segmented') {
264
+ return fieldShell(item, ctx,
265
+ segmented(ctx, opts, ctx.get(item.field), (v) => ctx.set(item.field, v),
266
+ { tones: item.tones, idBase: id, labelledBy: `${id}-l` }),
267
+ { group: true });
268
+ }
269
+ if (style === 'dropdown') {
270
+ const sel = ctx.doc.createElement('select');
271
+ sel.className = 'af-select';
272
+ const blank = ctx.doc.createElement('option');
273
+ blank.value = ''; blank.textContent = '';
274
+ sel.appendChild(blank);
275
+ for (const o of opts) {
276
+ const op = ctx.doc.createElement('option');
277
+ op.value = String(o.value); op.textContent = o.label;
278
+ if (o.value === ctx.get(item.field)) op.selected = true;
279
+ sel.appendChild(op);
280
+ }
281
+ prepControl(sel, item, ctx);
282
+ // The TYPED option value (a numeric enum stays numeric), never the string the
283
+ // <select> reports; index 0 is the blank option.
284
+ sel.addEventListener('change', () => { const o = opts[sel.selectedIndex - 1]; ctx.set(item.field, o ? o.value : ''); });
285
+ const wrap = h(ctx.doc, 'span', 'select-wrap'); // the house chevron
286
+ wrap.appendChild(sel);
287
+ return fieldShell(item, ctx, wrap);
288
+ }
289
+
290
+ const box = h(ctx.doc, 'div', 'af-choices');
291
+ box.setAttribute('role', 'group');
292
+ box.setAttribute('aria-labelledby', `${id}-l`);
293
+ opts.forEach((o, i) => {
294
+ const b = choiceButton(ctx, o, { id: `${id}-${i}`, on: o.value === ctx.get(item.field) });
295
+ b.addEventListener('click', () => {
296
+ for (const sib of box.children) { sib.setAttribute('aria-pressed', 'false'); sib.classList.remove('on'); }
297
+ b.setAttribute('aria-pressed', 'true'); b.classList.add('on');
298
+ ctx.set(item.field, o.value);
299
+ });
300
+ box.appendChild(b);
301
+ });
302
+ return fieldShell(item, ctx, box, { group: true });
303
+ }
304
+
305
+ function multiselectWidget(item, ctx) {
306
+ const s = ctx.schemaOf(item.field);
307
+ const id = ctx.idFor(item.field);
308
+ const opts = optionsFor(item, s, ctx);
309
+ const box = h(ctx.doc, 'div', item.style === 'chips' ? 'af-choices af-chips' : 'af-choices');
310
+ box.setAttribute('role', 'group');
311
+ box.setAttribute('aria-labelledby', `${id}-l`);
312
+ const picked = () => (Array.isArray(ctx.get(item.field)) ? ctx.get(item.field).slice() : []);
313
+ opts.forEach((o, i) => {
314
+ const b = choiceButton(ctx, o, { id: `${id}-${i}`, role: 'checkbox', on: picked().includes(o.value) });
315
+ b.addEventListener('click', () => {
316
+ const cur = picked();
317
+ const at = cur.indexOf(o.value);
318
+ if (at >= 0) cur.splice(at, 1); else cur.push(o.value);
319
+ b.setAttribute('aria-checked', String(at < 0));
320
+ b.classList.toggle('on', at < 0);
321
+ ctx.values[item.field] = cur;
322
+ ctx.set(item.field, cur);
323
+ });
324
+ box.appendChild(b);
325
+ });
326
+ return fieldShell(item, ctx, box, { group: true });
327
+ }
328
+
329
+ /** Rows a data-driven widget draws, through the shared path language. */
330
+ function rowsOf(item, ctx) {
331
+ const rows = resolvePath(item.bind, { data: ctx.data });
332
+ return Array.isArray(rows) ? rows.filter((r) => own(r, 'id') != null) : [];
333
+ }
334
+
335
+ /** The usable column descriptors of a `table` / `table-select` item: objects only
336
+ * (gate 1 admits `columns: [null]` shapes the cell painter must not trip on). */
337
+ export function colsOf(item) {
338
+ return (Array.isArray(item.columns) ? item.columns : []).filter((c) => c && typeof c === 'object' && !Array.isArray(c));
339
+ }
340
+
341
+ /**
342
+ * One table cell. `format: 'delta'` signs the number and tones it; `format: 'pill'`
343
+ * draws a `.af-pill` carrying `data-tone` when the column's `tones` names one of the
344
+ * closed families (dom.mjs TONES). Shared with the `table` display widget.
345
+ */
346
+ export function cellFor(ctx, col, row) {
347
+ const v = own(row, col.key);
348
+ const td = ctx.doc.createElement('td');
349
+ if (col.align === 'right') td.classList.add('af-r');
350
+ if (col.mono) td.classList.add('mono');
351
+ if (col.format === 'delta') {
352
+ const n = Number(v) || 0;
353
+ td.classList.add(n > 0 ? 'af-up' : n < 0 ? 'af-down' : 'af-flat');
354
+ td.textContent = `${n > 0 ? '+' : ''}${n}${col.unit || ''}`;
355
+ } else if (col.format === 'pill') {
356
+ const pill = h(ctx.doc, 'span', 'af-pill', String(v ?? ''));
357
+ const tone = toneOf(col.tones && col.tones[v]);
358
+ if (tone) pill.dataset.tone = tone;
359
+ td.appendChild(pill);
360
+ } else {
361
+ td.textContent = v == null ? '—' : `${v}${col.unit || ''}`;
362
+ }
363
+ return td;
364
+ }
365
+
366
+ function rankWidget(item, ctx) {
367
+ const rows = rowsOf(item, ctx);
368
+ const byId = new Map(rows.map((r) => [String(r.id), r]));
369
+ const id = ctx.idFor(item.field);
370
+ const titleKey = item.titleKey || 'title';
371
+ const metaKey = item.metaKey || 'meta';
372
+ const seeded = Array.isArray(ctx.get(item.field)) && ctx.get(item.field).length
373
+ ? ctx.get(item.field).slice()
374
+ : rows.map((r) => String(r.id));
375
+ const order = seeded.filter((rid) => byId.has(String(rid))).map(String);
376
+ ctx.values[item.field] = order.slice(); // D10: the data order IS the answer
377
+ const ol = h(ctx.doc, 'ol', 'af-rank');
378
+ ol.setAttribute('aria-labelledby', `${id}-l`);
379
+ let dragId = null;
380
+
381
+ const move = (from, to) => {
382
+ if (ctx.readonly || to < 0 || to >= order.length || from === to) return;
383
+ order.splice(to, 0, order.splice(from, 1)[0]);
384
+ paint();
385
+ ctx.set(item.field, order.slice());
386
+ const focus = ol.children[to] && ol.children[to].querySelector(to > from ? '.af-rank-dn' : '.af-rank-up');
387
+ if (focus && !focus.disabled) focus.focus();
388
+ };
389
+
390
+ function paint() {
391
+ ol.textContent = '';
392
+ order.forEach((rid, i) => {
393
+ const r = byId.get(rid) || {};
394
+ const name = String(own(r, titleKey) ?? rid);
395
+ const meta = own(r, metaKey);
396
+ const li = ctx.doc.createElement('li');
397
+ if (!ctx.readonly) { li.draggable = true; li.dataset.afDrag = 'rank'; }
398
+ li.appendChild(h(ctx.doc, 'span', 'af-rank-n', String(i + 1)));
399
+ const txt = h(ctx.doc, 'span', 'af-rank-txt');
400
+ txt.appendChild(h(ctx.doc, 'b', '', name));
401
+ if (meta != null && meta !== '') txt.appendChild(h(ctx.doc, 'span', '', String(meta)));
402
+ li.appendChild(txt);
403
+ const btns = h(ctx.doc, 'span', 'af-rank-btns');
404
+ const mk = (cls, glyph, label, to, off) => {
405
+ const b = h(ctx.doc, 'button', `af-icon-btn ${cls}`);
406
+ b.type = 'button';
407
+ b.id = `${id}-${cls}-${rid}`;
408
+ b.setAttribute('aria-label', label);
409
+ b.disabled = ctx.readonly || off;
410
+ b.appendChild(icon(ctx.doc, glyph, 14));
411
+ b.addEventListener('click', () => move(i, to));
412
+ return b;
413
+ };
414
+ btns.append(
415
+ mk('af-rank-up', ICON_UP, `Move ${name} up`, i - 1, i === 0),
416
+ mk('af-rank-dn', ICON_DOWN, `Move ${name} down`, i + 1, i === order.length - 1),
417
+ );
418
+ li.appendChild(btns);
419
+ li.addEventListener('dragstart', (e) => {
420
+ dragId = rid; li.classList.add('af-drag');
421
+ if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', rid); }
422
+ });
423
+ li.addEventListener('dragend', () => li.classList.remove('af-drag'));
424
+ li.addEventListener('dragover', (e) => { e.preventDefault(); li.classList.add('af-over'); });
425
+ li.addEventListener('dragleave', () => li.classList.remove('af-over'));
426
+ li.addEventListener('drop', (e) => {
427
+ e.preventDefault(); li.classList.remove('af-over');
428
+ const from = order.indexOf(dragId);
429
+ if (from >= 0) move(from, i);
430
+ });
431
+ ol.appendChild(li);
432
+ });
433
+ }
434
+ paint();
435
+ return fieldShell(item, ctx, ol, { group: true });
436
+ }
437
+
438
+ function tableSelectWidget(item, ctx) {
439
+ const s = ctx.schemaOf(item.field);
440
+ const multi = s.type === 'array';
441
+ const id = ctx.idFor(item.field);
442
+ const rows = rowsOf(item, ctx);
443
+ const cols = colsOf(item);
444
+ const tbody = ctx.doc.createElement('tbody');
445
+ const boxes = [];
446
+
447
+ const sync = () => {
448
+ const picked = [];
449
+ rows.forEach((r, i) => {
450
+ const on = boxes[i].checked;
451
+ tbody.children[i].setAttribute('aria-selected', String(on));
452
+ if (on) picked.push(String(r.id));
453
+ });
454
+ ctx.values[item.field] = multi ? picked : picked[0];
455
+ ctx.set(item.field, multi ? picked : (picked[0] == null ? '' : picked[0]));
456
+ };
457
+
458
+ rows.forEach((r, i) => {
459
+ const box = ctx.doc.createElement('input');
460
+ box.type = multi ? 'checkbox' : 'radio';
461
+ box.name = id;
462
+ box.id = `${id}-${r.id}`;
463
+ box.disabled = ctx.readonly;
464
+ const cur = ctx.get(item.field);
465
+ box.checked = multi ? (Array.isArray(cur) && cur.includes(String(r.id))) : cur === String(r.id);
466
+ box.setAttribute('aria-label', String(own(r, (cols[0] || {}).key) ?? r.id));
467
+ box.addEventListener('change', sync);
468
+ boxes.push(box);
469
+ const tr = h(ctx.doc, 'tr', 'af-pick');
470
+ tr.setAttribute('aria-selected', String(box.checked));
471
+ tr.addEventListener('click', (e) => {
472
+ if (ctx.readonly || e.target === box) return;
473
+ box.checked = multi ? !box.checked : true;
474
+ if (!multi) for (const other of boxes) if (other !== box) other.checked = false;
475
+ sync();
476
+ });
477
+ const pick = ctx.doc.createElement('td');
478
+ pick.appendChild(box);
479
+ tr.appendChild(pick);
480
+ for (const c of cols) tr.appendChild(cellFor(ctx, c, r));
481
+ tbody.appendChild(tr);
482
+ });
483
+
484
+ const table = h(ctx.doc, 'table', 'af-tbl');
485
+ const thead = ctx.doc.createElement('thead');
486
+ const hr = ctx.doc.createElement('tr');
487
+ hr.appendChild(ctx.doc.createElement('th'));
488
+ for (const c of cols) {
489
+ const th = h(ctx.doc, 'th', c.align === 'right' ? 'af-r' : '', c.label || c.key);
490
+ hr.appendChild(th);
491
+ }
492
+ thead.appendChild(hr);
493
+ table.append(thead, tbody);
494
+ const wrap = h(ctx.doc, 'div', 'af-tbl-wrap');
495
+ wrap.appendChild(table);
496
+ return fieldShell(item, ctx, wrap, { group: true });
497
+ }
498
+
499
+ function reviewListWidget(item, ctx) {
500
+ const s = ctx.schemaOf(item.field);
501
+ const vs = ((s.items || {}).properties || {}).verdict || {};
502
+ const en = Array.isArray(vs.enum) ? vs.enum : [];
503
+ const dflt = vs.default !== undefined ? vs.default : en[0];
504
+ const id = ctx.idFor(item.field);
505
+ const rows = rowsOf(item, ctx);
506
+ const stored = new Map((Array.isArray(ctx.get(item.field)) ? ctx.get(item.field) : [])
507
+ .map((e) => [String(e && e.id), e]));
508
+ const state = rows.map((r) => {
509
+ const prev = stored.get(String(r.id));
510
+ const entry = { id: String(r.id), verdict: prev && prev.verdict != null ? prev.verdict : dflt };
511
+ if (prev && prev.note) entry.note = prev.note;
512
+ return entry;
513
+ });
514
+ ctx.values[item.field] = state.map((e) => ({ ...e }));
515
+ const opts = en.map((v) => ({ value: v, label: String(own(item.labels, v) ?? v), desc: '' }));
516
+ const box = h(ctx.doc, 'div', 'af-rv');
517
+
518
+ rows.forEach((r, i) => {
519
+ const note = ctx.doc.createElement('input');
520
+ note.type = 'text';
521
+ note.className = 'af-inp af-rv-note';
522
+ note.id = `${id}-note-${r.id}`;
523
+ note.setAttribute('aria-label', `Note for ${own(r, item.titleKey || 'title') ?? r.id}`);
524
+ note.placeholder = item.notePlaceholder ? String(item.notePlaceholder) : '';
525
+ note.value = state[i].note || '';
526
+ note.hidden = state[i].verdict === dflt;
527
+ note.disabled = ctx.readonly;
528
+ note.addEventListener('input', () => {
529
+ if (note.value) state[i].note = note.value; else delete state[i].note;
530
+ ctx.set(item.field, state.map((e) => ({ ...e })));
531
+ });
532
+ const card = h(ctx.doc, 'div', 'af-rv-item');
533
+ const tone0 = toneOf(item.tones && item.tones[state[i].verdict]);
534
+ if (tone0) card.dataset.tone = tone0;
535
+ const main = h(ctx.doc, 'div', 'af-rv-main');
536
+ const body = own(r, item.bodyKey || 'body');
537
+ const meta = own(r, item.metaKey || 'meta');
538
+ main.appendChild(h(ctx.doc, 'div', 'af-rv-title', String(own(r, item.titleKey || 'title') ?? r.id)));
539
+ if (body != null && body !== '') main.appendChild(h(ctx.doc, 'div', 'af-rv-body', String(body)));
540
+ if (meta != null && meta !== '') main.appendChild(h(ctx.doc, 'div', 'af-rv-meta', String(meta)));
541
+ const seg = segmented(ctx, opts, state[i].verdict, (v) => {
542
+ state[i].verdict = v;
543
+ note.hidden = v === dflt;
544
+ if (v === dflt) { delete state[i].note; note.value = ''; }
545
+ const tone = toneOf(item.tones && item.tones[v]);
546
+ if (tone) card.dataset.tone = tone; else delete card.dataset.tone;
547
+ ctx.set(item.field, state.map((e) => ({ ...e })));
548
+ }, { tones: item.tones, small: true, idBase: `${id}-${r.id}` });
549
+ const top = h(ctx.doc, 'div', 'af-rv-top');
550
+ top.append(main, seg);
551
+ card.append(top, note);
552
+ box.appendChild(card);
553
+ });
554
+ return fieldShell(item, ctx, box, { group: true });
555
+ }
556
+
557
+ function galleryPicker(item, ctx) {
558
+ const rows = rowsOf(item, ctx);
559
+ const id = ctx.idFor(item.field || 'gallery');
560
+ const capKey = item.captionKey || 'caption';
561
+ const fileKey = item.fileKey || 'file';
562
+ const pick = Boolean(item.field);
563
+ const grid = h(ctx.doc, 'div', 'af-gal');
564
+
565
+ for (const r of rows) {
566
+ // X16: a ROW widget knows its file column from its own declared `fileKey`, so it
567
+ // needs no fileRefs lookup — the scalar widgets are the ones that had to guess.
568
+ const rel = own(r, fileKey);
569
+ const entry = ctx.fileFor(rel);
570
+ const url = entry ? ctx.fileUrl(entry.index) : null; // X14: either may be absent
571
+ const caption = String(own(r, capKey) ?? r.id);
572
+ const kids = [];
573
+ if (url) {
574
+ const img = ctx.doc.createElement('img'); // SVG included: <img> only
575
+ img.setAttribute('src', url);
576
+ img.setAttribute('alt', caption);
577
+ img.setAttribute('loading', 'lazy');
578
+ kids.push(img);
579
+ } else {
580
+ // No snapshot for this row: the pinned neutral tile, never a broken <img>.
581
+ const tile = h(ctx.doc, 'span', 'af-nofile af-gal-nofile');
582
+ tile.append(h(ctx.doc, 'span', 'af-file-badge', extOf(rel)),
583
+ h(ctx.doc, 'span', 'af-file-name', String(rel ?? '')));
584
+ kids.push(tile);
585
+ }
586
+ kids.push(h(ctx.doc, 'span', 'af-gal-cap', caption));
587
+ if (entry) kids.push(h(ctx.doc, 'span', 'af-gal-sub', `${entry.name} · ${fmtBytes(entry.bytes)}`));
588
+ let card;
589
+ if (pick) {
590
+ card = h(ctx.doc, 'button', 'af-gal-card');
591
+ card.type = 'button';
592
+ card.id = `${id}-${r.id}`;
593
+ card.disabled = ctx.readonly;
594
+ card.setAttribute('aria-pressed', String(ctx.get(item.field) === String(r.id)));
595
+ card.addEventListener('click', () => {
596
+ for (const sib of grid.children) sib.setAttribute('aria-pressed', 'false');
597
+ card.setAttribute('aria-pressed', 'true');
598
+ ctx.set(item.field, String(r.id));
599
+ });
600
+ } else {
601
+ card = h(ctx.doc, 'div', 'af-gal-card');
602
+ }
603
+ for (const k of kids) card.appendChild(k);
604
+ grid.appendChild(card);
605
+ }
606
+ return pick ? fieldShell(item, ctx, grid, { group: true }) : grid;
607
+ }
608
+
609
+ export { galleryPicker };
610
+
611
+ export const INPUT_WIDGETS_TABLE = Object.freeze({
612
+ text: textWidget,
613
+ textarea: textareaWidget,
614
+ number: numberWidget,
615
+ slider: sliderWidget,
616
+ toggle: toggleWidget,
617
+ date: dateWidget,
618
+ select: selectWidget,
619
+ multiselect: multiselectWidget,
620
+ rank: rankWidget,
621
+ 'table-select': tableSelectWidget,
622
+ 'review-list': reviewListWidget,
623
+ gallery: galleryPicker,
624
+ });