segreant 0.1.0 → 0.2.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 (142) hide show
  1. package/LICENSING.md +5 -3
  2. package/README.md +57 -19
  3. package/bin/segreant-statusline.mjs +20 -0
  4. package/bin/segreant.mjs +19 -8
  5. package/dist/alerts/detect.js +13 -0
  6. package/dist/billing/epistemic.js +4 -0
  7. package/dist/billing/importer.js +29 -10
  8. package/dist/billing/reconcile.js +122 -32
  9. package/dist/billing/scope.js +7 -0
  10. package/dist/budget/guard.js +33 -4
  11. package/dist/cli/allocCmd.js +73 -4
  12. package/dist/cli/askModelPrices.js +59 -0
  13. package/dist/cli/backupCmd.js +2 -1
  14. package/dist/cli/basisLines.js +45 -0
  15. package/dist/cli/billingCmd.js +68 -9
  16. package/dist/cli/capitalCmd.js +2 -1
  17. package/dist/cli/connectCmd.js +50 -9
  18. package/dist/cli/egressCmd.js +15 -3
  19. package/dist/cli/evidenceCmd.js +6 -3
  20. package/dist/cli/flags.js +68 -0
  21. package/dist/cli/homeCmd.js +117 -0
  22. package/dist/cli/importCmd.js +210 -54
  23. package/dist/cli/launchCmd.js +2 -1
  24. package/dist/cli/opsCmd.js +49 -2
  25. package/dist/cli/planCmd.js +181 -0
  26. package/dist/cli/pricingDeclareCmd.js +178 -0
  27. package/dist/cli/quotaCmd.js +190 -0
  28. package/dist/cli/runCmd.js +203 -51
  29. package/dist/cli/showCmd.js +37 -11
  30. package/dist/cli/teamCmd.js +5 -5
  31. package/dist/cli/ui.js +24 -8
  32. package/dist/cli/valueCmd.js +189 -14
  33. package/dist/cli.js +102 -21
  34. package/dist/config.js +95 -6
  35. package/dist/connect/antigravity.js +310 -0
  36. package/dist/connect/claudeCode.js +43 -3
  37. package/dist/connect/codex.js +38 -1
  38. package/dist/connect/importShared.js +58 -5
  39. package/dist/connect/opencode.js +37 -9
  40. package/dist/connect/protobuf.js +126 -0
  41. package/dist/connect/registry.js +46 -0
  42. package/dist/connect/repoRoot.js +112 -0
  43. package/dist/cost/basis.js +168 -0
  44. package/dist/cost/declarations.js +37 -0
  45. package/dist/cost/exactPricing.js +4 -1
  46. package/dist/cost/firstSight.js +98 -0
  47. package/dist/cost/overrides.js +230 -0
  48. package/dist/cost/pricing.js +228 -15
  49. package/dist/cost/reprice.js +130 -0
  50. package/dist/dashboard/claim-support.js +11 -8
  51. package/dist/dashboard/contracts.js +23 -3
  52. package/dist/dashboard/routes.js +980 -112
  53. package/dist/dashboard/sessions.js +345 -0
  54. package/dist/dashboard/settings.js +2 -1
  55. package/dist/dashboard/value-cache.js +105 -0
  56. package/dist/dashboard/web/app/components/claimInspector.js +6 -5
  57. package/dist/dashboard/web/app/components/drawer.js +105 -35
  58. package/dist/dashboard/web/app/components/ruleFields.js +144 -0
  59. package/dist/dashboard/web/app/components/spine.js +54 -24
  60. package/dist/dashboard/web/app/components/workflow.js +82 -0
  61. package/dist/dashboard/web/app/core/actions.js +734 -23
  62. package/dist/dashboard/web/app/core/api.js +37 -1
  63. package/dist/dashboard/web/app/core/chain.js +11 -4
  64. package/dist/dashboard/web/app/core/claimLayers.js +179 -51
  65. package/dist/dashboard/web/app/core/claimTypes.js +30 -0
  66. package/dist/dashboard/web/app/core/fmt.js +19 -2
  67. package/dist/dashboard/web/app/core/generated-contract.js +23 -3
  68. package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
  69. package/dist/dashboard/web/app/core/generated-types.js +1 -1
  70. package/dist/dashboard/web/app/core/griffin.js +10 -0
  71. package/dist/dashboard/web/app/core/jobs.js +12 -0
  72. package/dist/dashboard/web/app/core/period.js +35 -0
  73. package/dist/dashboard/web/app/core/periodWords.js +10 -0
  74. package/dist/dashboard/web/app/core/periods.js +32 -0
  75. package/dist/dashboard/web/app/core/pricingActions.js +264 -0
  76. package/dist/dashboard/web/app/core/privacyText.js +78 -0
  77. package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
  78. package/dist/dashboard/web/app/core/registry.js +79 -46
  79. package/dist/dashboard/web/app/core/slug.js +9 -0
  80. package/dist/dashboard/web/app/core/theme.js +48 -0
  81. package/dist/dashboard/web/app/core/view.js +69 -0
  82. package/dist/dashboard/web/app/main.js +197 -15
  83. package/dist/dashboard/web/app/views/allocation.js +249 -61
  84. package/dist/dashboard/web/app/views/control.js +57 -16
  85. package/dist/dashboard/web/app/views/data.js +4 -4
  86. package/dist/dashboard/web/app/views/evidence.js +336 -87
  87. package/dist/dashboard/web/app/views/privacy.js +13 -0
  88. package/dist/dashboard/web/app/views/spend.js +30 -15
  89. package/dist/dashboard/web/app/views/statement.js +189 -0
  90. package/dist/dashboard/web/app/views/system.js +44 -4
  91. package/dist/dashboard/web/app/views/value.js +105 -59
  92. package/dist/dashboard/web/app/views/workbench.js +312 -0
  93. package/dist/dashboard/web/classic.html +16 -4
  94. package/dist/dashboard/web/index.html +1 -1
  95. package/dist/dashboard/web/styles/app.css +536 -23
  96. package/dist/demo/seed.js +153 -14
  97. package/dist/economics/ledger.js +1 -1
  98. package/dist/egress/policy.js +129 -5
  99. package/dist/export/allocationCsv.js +70 -0
  100. package/dist/export/billingCsv.js +6 -2
  101. package/dist/export/csv.js +10 -2
  102. package/dist/export/economic.js +6 -2
  103. package/dist/export/statementCsv.js +104 -0
  104. package/dist/git/correlate.js +86 -9
  105. package/dist/git/quality.js +107 -24
  106. package/dist/git/repoScope.js +207 -0
  107. package/dist/git/survivalStore.js +186 -0
  108. package/dist/guide.js +9 -4
  109. package/dist/plans/detect.js +147 -0
  110. package/dist/proxy/server.js +134 -14
  111. package/dist/proxy/usage.js +9 -4
  112. package/dist/quota/limits.js +138 -0
  113. package/dist/quota/statusline.js +68 -0
  114. package/dist/quota/view.js +122 -0
  115. package/dist/scan/knownApps.js +6 -2
  116. package/dist/scan/scan.js +9 -0
  117. package/dist/store/allocation.js +14 -0
  118. package/dist/store/db.js +259 -34
  119. package/dist/store/realization.js +78 -14
  120. package/dist/store/schema.js +59 -1
  121. package/dist/store/scopeEvidence.js +67 -0
  122. package/dist/util/pool.js +19 -0
  123. package/dist/util/time.js +7 -0
  124. package/dist/value/realization.js +321 -30
  125. package/dist/value/report.js +28 -8
  126. package/dist/value/selfReported.js +10 -9
  127. package/docs/ARCHITECTURE.md +8 -4
  128. package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
  129. package/docs/CLI-JSON-CONTRACT.md +38 -0
  130. package/docs/DATA-BOUNDARIES.md +66 -6
  131. package/docs/FAQ.md +43 -9
  132. package/docs/GETTING-STARTED.md +19 -12
  133. package/docs/GUIDE.md +81 -18
  134. package/docs/INTEGRATIONS.md +9 -9
  135. package/docs/METHODOLOGY.md +3 -2
  136. package/docs/NEUTRALITY.md +1 -1
  137. package/docs/PRIVACY.md +51 -0
  138. package/docs/README.md +1 -0
  139. package/docs/RELEASE-GATE.md +52 -7
  140. package/docs/THREAT-MODEL.md +2 -1
  141. package/package.json +3 -2
  142. package/pricing/models.json +1366 -60
@@ -65,10 +65,59 @@ function panel(spec) {
65
65
  const committing = signal(false);
66
66
  const result = signal(null);
67
67
  const typed = signal('');
68
- void spec.preview()
69
- .then((p) => preview.set(p))
70
- .catch((e) => error.set(e instanceof Error ? e.message : String(e)))
71
- .finally(() => busy.set(false));
68
+ // The preview is re-run whenever a field changes (H004). Before, it ran once
69
+ // at open: a budget of -1 failed at commit, the failure disabled Apply, and
70
+ // correcting the field to 25 changed nothing on screen; on Optional features
71
+ // the preview kept describing the first switch while Apply wrote the one now
72
+ // selected. `refreshing` keeps the last preview on screen, marked stale, and
73
+ // holds Apply until the preview matches the fields again. Only the newest
74
+ // run may land.
75
+ const refreshing = signal(false);
76
+ let run = 0;
77
+ const runPreview = () => {
78
+ const mine = ++run;
79
+ if (preview.peek() !== null || error.peek() !== null)
80
+ refreshing.set(true);
81
+ void spec.preview()
82
+ .then((p) => { if (mine === run) {
83
+ preview.set(p);
84
+ error.set(null);
85
+ } })
86
+ .catch((e) => { if (mine === run)
87
+ error.set(e instanceof Error ? e.message : String(e)); })
88
+ .finally(() => { if (mine === run) {
89
+ busy.set(false);
90
+ refreshing.set(false);
91
+ } });
92
+ };
93
+ runPreview();
94
+ let pending = null;
95
+ // What the fields last said. A text field fires `change` again when it loses
96
+ // focus, and pressing Apply is what makes it lose focus: re-previewing on that
97
+ // echo disabled Apply between mousedown and click, so typing a value and
98
+ // clicking Apply did nothing the first time. Only a real difference counts.
99
+ let lastFields = null;
100
+ const fieldsChanged = (event) => {
101
+ const scope = event?.currentTarget instanceof Element ? event.currentTarget : null;
102
+ if (scope) {
103
+ const now = Array.from(scope.querySelectorAll('input, select, textarea'))
104
+ .map((el) => (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio') ? String(el.checked) : el.value))
105
+ .join('\u0000');
106
+ if (now === lastFields)
107
+ return;
108
+ lastFields = now;
109
+ }
110
+ // An outcome described the input as it was; once the input changes it is
111
+ // history, and keeping it would also keep Apply disabled.
112
+ if (!committing.peek())
113
+ result.set(null);
114
+ refreshing.set(true);
115
+ if (pending !== null)
116
+ clearTimeout(pending);
117
+ pending = setTimeout(() => { pending = null; runPreview(); }, 250);
118
+ };
119
+ onCleanup(() => { if (pending !== null)
120
+ clearTimeout(pending); });
72
121
  const confirmPhrase = cap.id;
73
122
  const confirmed = () => cap.consequence !== 'destructive' || typed().trim() === confirmPhrase;
74
123
  const scrim = h('div', {
@@ -80,7 +129,7 @@ function panel(spec) {
80
129
  role: 'dialog',
81
130
  'aria-modal': 'true',
82
131
  'aria-labelledby': 'drawer-title',
83
- }, h('header', { class: 'drawer-head' }, h('div', { class: 'drawer-badge', text: consequence.badge }), h('h2', { id: 'drawer-title', class: 'drawer-title', text: cap.label }), h('p', { class: 'drawer-plain', text: cap.plain }), h('button', { class: 'drawer-close', 'aria-label': 'Close', onclick: () => closeAction() }, '×')), h('p', { class: 'drawer-consequence', text: consequence.plain }), cap.warning ? h('div', { class: 'drawer-warning', role: 'note' }, h('strong', { text: 'Before you do this' }), h('p', { text: cap.warning })) : null, spec.fields ? h('div', { class: 'drawer-fields' }, spec.fields()) : null, h('section', { class: 'drawer-preview' }, h('h3', { class: 'drawer-h3', text: 'Preview' }), () => {
132
+ }, h('header', { class: 'drawer-head' }, h('div', { class: 'drawer-badge', text: consequence.badge }), h('h2', { id: 'drawer-title', class: 'drawer-title', text: cap.label }), h('p', { class: 'drawer-plain', text: cap.plain }), h('button', { class: 'drawer-close', 'aria-label': 'Close', onclick: () => closeAction() }, '×')), h('p', { class: 'drawer-consequence', text: consequence.plain }), cap.warning ? h('div', { class: 'drawer-warning', role: 'note' }, h('strong', { text: 'Before you do this' }), h('p', { text: cap.warning })) : null, spec.fields ? h('div', { class: 'drawer-fields', oninput: fieldsChanged, onchange: fieldsChanged }, spec.fields()) : null, h('section', { class: () => `drawer-preview${refreshing() ? ' is-refreshing' : ''}`, 'aria-busy': () => (refreshing() ? 'true' : 'false') }, h('h3', { class: 'drawer-h3', text: 'Preview' }), () => {
84
133
  if (busy())
85
134
  return h('p', { class: 'drawer-muted', text: 'Working it out…' });
86
135
  const err = error();
@@ -101,11 +150,11 @@ function panel(spec) {
101
150
  // The command is always shown. For the developer it is the faster path; for
102
151
  // everyone it is the audit trail — what this button is about to do, in a form
103
152
  // that can be pasted into an issue, a runbook, or a code review.
104
- h('section', { class: 'drawer-command' }, h('h3', { class: 'drawer-h3', text: isPrecise() ? 'Equivalent command' : 'The same thing, as a command' }), h('div', { class: 'cmd-row' }, h('code', { class: 'cmd', text: cap.command }), h('button', {
153
+ h('section', { class: 'drawer-command' }, h('h3', { class: 'drawer-h3', text: isPrecise() ? 'Equivalent command' : 'The same thing, as a command' }), h('div', { class: 'cmd-row' }, h('code', { class: 'cmd', text: () => spec.command?.() ?? cap.command }), h('button', {
105
154
  class: 'cmd-copy',
106
155
  text: 'Copy',
107
156
  onclick: (event) => {
108
- void navigator.clipboard?.writeText(cap.command);
157
+ void navigator.clipboard?.writeText(spec.command?.() ?? cap.command);
109
158
  const button = event.currentTarget;
110
159
  button.textContent = 'Copied';
111
160
  setTimeout(() => { button.textContent = 'Copy'; }, 1400);
@@ -151,42 +200,63 @@ function panel(spec) {
151
200
  });
152
201
  }
153
202
  return null;
154
- }), h('div', { class: 'drawer-actions' }, h('button', { class: 'btn-ghost', text: 'Close', onclick: () => closeAction() }), spec.download
155
- ? h('a', { class: 'btn-commit', href: spec.download, download: '', text: 'Download' })
156
- : null, () => {
157
- if (!spec.commit)
158
- return null;
203
+ }),
204
+ // Why Apply is unavailable gets its own line. Squeezed between the two
205
+ // buttons it wrapped mid-word ("--" / "demo") on a phone-width drawer.
206
+ () => {
159
207
  const p = preview();
160
- const ready = !busy() && p !== null && p.applicable && confirmed() && !committing() && result() === null;
161
- // `title` is not exposed to keyboard or screen-reader users on a
162
- // `disabled` control — browsers do not run hover/focus tooltip
163
- // logic for it, and a disabled element cannot receive focus at
164
- // all — so it was the ONLY carrier of why the button could not be
165
- // pressed. The reason is now a visible sibling, reached from the
166
- // button with `aria-describedby`, the same pattern the daily-cap
167
- // field uses for its own consequence text (`actions.ts`).
168
- const blocked = p && !p.applicable ? (p.blockedReason ?? 'Nothing to apply') : null;
169
- return [
170
- blocked
171
- ? h('p', { id: 'drawer-blocked-reason', class: 'drawer-note', text: blocked })
172
- : null,
173
- h('button', {
208
+ if (!spec.commit || !p || p.applicable)
209
+ return null;
210
+ return h('p', { id: 'drawer-blocked-reason', class: 'drawer-note drawer-blocked', text: p.blockedReason ?? 'Nothing to apply' });
211
+ }, h('div', { class: 'drawer-actions' }, h('button', { class: 'btn-ghost', text: 'Close', onclick: () => closeAction() }), spec.download
212
+ ? h('a', { class: 'btn-commit', href: spec.download, download: '', text: 'Download' })
213
+ : null,
214
+ // One persistent button whose state is reactive. It used to be rebuilt
215
+ // whenever the preview, the busy flag or the result changed: a preview
216
+ // landing between mousedown and mouseup swallowed the click (no click
217
+ // event fires across two elements), and a commit moved keyboard focus
218
+ // from Apply to the page body.
219
+ spec.commit
220
+ ? (() => {
221
+ // A failed outcome does not block a retry; a successful one does,
222
+ // until a field changes and the preview is recomputed.
223
+ const ready = () => {
224
+ const p = preview();
225
+ return !busy() && !refreshing() && p !== null && p.applicable && confirmed() && !committing() && result()?.ok !== true;
226
+ };
227
+ // `title` is not exposed to keyboard or screen-reader users on a
228
+ // `disabled` control, so the reason Apply is unavailable is a
229
+ // visible sibling reached with `aria-describedby`, the pattern the
230
+ // daily-cap field uses for its own consequence text (`actions.ts`).
231
+ const blocked = () => { const p = preview(); return p !== null && !p.applicable; };
232
+ return h('button', {
174
233
  class: `btn-commit tone-${consequence.tone}`,
175
- disabled: !ready,
176
- 'aria-describedby': blocked ? 'drawer-blocked-reason' : undefined,
177
- text: () => (committing() ? 'Working…' : commitLabel(cap.consequence)),
178
- onclick: () => {
179
- if (!ready || !spec.commit)
234
+ disabled: () => !ready(),
235
+ 'aria-describedby': () => (blocked() ? 'drawer-blocked-reason' : null),
236
+ text: () => (committing() ? 'Working…' : spec.commitLabel ?? commitLabel(cap.consequence)),
237
+ onclick: (event) => {
238
+ if (!ready() || !spec.commit)
180
239
  return;
240
+ const button = event.currentTarget;
181
241
  committing.set(true);
182
242
  void spec.commit()
183
243
  .then((r) => result.set(r))
184
244
  .catch((e) => result.set({ ok: false, message: e instanceof Error ? e.message : String(e) }))
185
- .finally(() => committing.set(false));
245
+ .finally(() => {
246
+ committing.set(false);
247
+ // A disabled Apply can hold no useful focus: Chrome either
248
+ // blurs it to the page body or leaves it on the dead
249
+ // button. The result is announced by the status line;
250
+ // focus goes to Close, the next thing to do.
251
+ const at = document.activeElement;
252
+ if (button.disabled && (at === null || at === document.body || at === button)) {
253
+ button.parentElement?.querySelector('.btn-ghost')?.focus();
254
+ }
255
+ });
186
256
  },
187
- }),
188
- ];
189
- })));
257
+ });
258
+ })()
259
+ : null)));
190
260
  const release = trapFocus(body);
191
261
  const onKey = (event) => {
192
262
  if (event.key === 'Escape')
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The allocation rule form's fields, and the rule they describe.
3
+ *
4
+ * One copy, used by the Allocate page's form and by the rule drawer, so a rule
5
+ * written in either place is the same rule `segreant alloc rule` writes, field
6
+ * by field. The fields only build a request body; saving it is the caller's job,
7
+ * which is what lets the drawer preview before it commits.
8
+ */
9
+ import { h } from '../core/dom.js';
10
+ import { signal } from '../core/signal.js';
11
+ import { isPrecise } from '../core/fmt.js';
12
+ import { slug } from '../core/slug.js';
13
+ import { localMidnightIso } from '../core/periods.js';
14
+ import { field } from './workflow.js';
15
+ export const METHOD_WORDS = {
16
+ direct: { plain: 'all of it to one centre', precise: 'direct' },
17
+ fixed_split: { plain: 'split by fixed shares', precise: 'fixed_split' },
18
+ proportional_to_direct: { plain: 'shared, split like the direct spend', precise: 'proportional_to_direct' },
19
+ };
20
+ /** The match fields beyond project, in the engine's own vocabulary (MATCH_FIELDS). */
21
+ export const EXTRA_MATCH = [
22
+ { key: 'model', label: 'Model', placeholder: 'for example gpt-5' },
23
+ { key: 'provider', label: 'Provider', placeholder: 'for example openai' },
24
+ { key: 'source', label: 'Tool', placeholder: 'for example claude-code' },
25
+ { key: 'user', label: 'User', placeholder: 'as recorded on the request' },
26
+ ];
27
+ /** A rule in a sentence: what spend it takes and where that spend goes. */
28
+ export function describeRule(r, nameOf) {
29
+ const match = Object.entries(r.match ?? {}).filter(([, v]) => v != null).map(([k, v]) => `${k} ${v}`).join(', ');
30
+ const what = match ? `Spend where ${match}` : 'All spend';
31
+ if (r.method === 'proportional_to_direct')
32
+ return `${what}: shared across centres in proportion to their direct spend`;
33
+ if (r.method === 'fixed_split')
34
+ return `${what}: ${r.targets.map((t) => `${Math.round(t.ratio * 1000) / 10}% to ${nameOf(t.costCentreId)}`).join(', ')}`;
35
+ return `${what} goes to ${r.targets[0] ? nameOf(r.targets[0].costCentreId) : '—'}`;
36
+ }
37
+ /**
38
+ * The fields, as children to place inside a form or a drawer, and `build()`,
39
+ * which returns the rule they describe or the reason they describe none.
40
+ * `prefix` keeps element ids unique when two copies could share a page.
41
+ */
42
+ export function ruleFields(centres, projects, models, prefix = 'rule') {
43
+ const method = signal('direct');
44
+ const project = signal('');
45
+ const extra = signal({});
46
+ const priority = signal('');
47
+ const from = signal('');
48
+ const until = signal('');
49
+ const owner = signal('');
50
+ const note = signal('');
51
+ const target = signal(centres[0]?.costCentreId ?? '');
52
+ const shares = signal({});
53
+ const shareTotal = () => Object.values(shares()).reduce((s, v) => s + (Number(v) || 0), 0);
54
+ const id = (key) => `${prefix}-${key}`;
55
+ const build = () => {
56
+ if (centres.length === 0)
57
+ return 'Add a cost centre first: a rule needs somewhere to send the spend.';
58
+ const p = project().trim();
59
+ const match = p ? { project: p } : {};
60
+ for (const { key } of EXTRA_MATCH) {
61
+ const v = (extra()[key] ?? '').trim();
62
+ if (v)
63
+ match[key] = v;
64
+ }
65
+ const pr = priority().trim();
66
+ if (pr !== '' && !/^-?\d+$/.test(pr))
67
+ return 'Priority must be a whole number.';
68
+ if (from() && until() && until() <= from())
69
+ return 'The stop date must be after the start date.';
70
+ const targets = method() === 'fixed_split'
71
+ ? Object.entries(shares()).filter(([, v]) => Number(v) > 0).map(([costCentreId, v]) => ({ costCentreId, ratio: Math.round(Number(v) * 10_000) / 1_000_000 }))
72
+ : [{ costCentreId: target(), ratio: method() === 'direct' ? 1 : 0 }];
73
+ if (method() === 'fixed_split' && Math.abs(shareTotal() - 100) > 1e-9)
74
+ return `The shares add up to ${shareTotal()}%. They need to add up to 100%.`;
75
+ // Every condition is part of the id: with only project and centre in it, a
76
+ // rule for one model would have replaced an existing rule for all spend.
77
+ const ruleId = slug([...Object.values(match), method() === 'direct' ? target() : method()].join('-') || 'all');
78
+ return {
79
+ id: ruleId,
80
+ rule: { match, method: method(), targets },
81
+ priority: pr === '' ? null : Number(pr),
82
+ from: from(),
83
+ until: until(),
84
+ owner: owner().trim(),
85
+ note: note().trim(),
86
+ body: {
87
+ op: 'rule', id: ruleId, method: method(), targets, match,
88
+ ...(pr === '' ? {} : { priority: Number(pr) }),
89
+ ...(from() ? { from: localMidnightIso(from()) } : {}),
90
+ ...(until() ? { until: localMidnightIso(until()) } : {}),
91
+ ...(owner().trim() ? { owner: owner().trim() } : {}),
92
+ ...(note().trim() ? { note: note().trim() } : {}),
93
+ },
94
+ };
95
+ };
96
+ const children = [
97
+ h('div', { class: 'form-row' }, field(id('project'), 'Which spend', h('input', {
98
+ id: id('project'), class: 'drawer-input', list: id('projects'), autocomplete: 'off',
99
+ placeholder: 'a project name, or empty for all spend',
100
+ oninput: (e) => project.set(e.target.value),
101
+ })), h('datalist', { id: id('projects') }, () => projects().map((p) => h('option', { value: p }))), h('datalist', { id: id('models') }, () => models().map((m) => h('option', { value: m }))), field(id('method'), 'How', h('select', {
102
+ id: id('method'), class: 'drawer-input',
103
+ onchange: (e) => method.set(e.target.value),
104
+ }, ...Object.keys(METHOD_WORDS).map((m) => h('option', { value: m, text: isPrecise() ? METHOD_WORDS[m].precise : METHOD_WORDS[m].plain })))), () => method() === 'direct'
105
+ ? field(id('target'), 'To', h('select', {
106
+ id: id('target'), class: 'drawer-input',
107
+ onchange: (e) => target.set(e.target.value),
108
+ // `peek`, not a read: the select keeps the centre already chosen when
109
+ // the method changes away and back, without rebuilding on each choice.
110
+ }, ...centres.map((c) => h('option', { value: c.costCentreId, text: c.name, selected: c.costCentreId === target.peek() }))))
111
+ : null),
112
+ () => method() === 'fixed_split'
113
+ ? h('fieldset', { class: 'shares' }, h('legend', { text: () => `Shares, in percent (now ${shareTotal()}% of 100%)` }), ...centres.map((c) => h('label', { class: 'share' }, h('span', { text: c.name }), h('input', {
114
+ class: 'drawer-input', type: 'number', min: '0', max: '100', step: '0.01', inputmode: 'decimal',
115
+ 'aria-label': `Share for ${c.name}, percent`,
116
+ oninput: (e) => shares.set({ ...shares(), [c.costCentreId]: e.target.value }),
117
+ }))))
118
+ : method() === 'proportional_to_direct'
119
+ ? h('p', { class: 'form-hint', text: 'This spend is shared out in the same proportions as the spend each centre gets directly in the period. If nothing was allocated directly, it stays unallocated rather than being split evenly.' })
120
+ : null,
121
+ h('details', { class: 'rule-more' }, h('summary', { text: 'More conditions and dates' }), h('p', { class: 'form-hint', text: 'Every condition must hold for a request to match. Leave a field empty to match anything.' }), h('div', { class: 'form-row rule-more-row' }, ...EXTRA_MATCH.map(({ key, label, placeholder }) => field(id(key), label, h('input', {
122
+ id: id(key), class: 'drawer-input', autocomplete: 'off', spellcheck: 'false', placeholder,
123
+ ...(key === 'model' ? { list: id('models') } : {}),
124
+ oninput: (e) => extra.set({ ...extra(), [key]: e.target.value }),
125
+ })))), h('div', { class: 'form-row rule-more-row' }, field(id('priority'), 'Priority', h('input', {
126
+ id: id('priority'), class: 'drawer-input', type: 'number', step: '1', inputmode: 'numeric', placeholder: '100',
127
+ 'aria-describedby': id('priority-hint'),
128
+ oninput: (e) => priority.set(e.target.value),
129
+ })), field(id('from'), 'Applies from', h('input', {
130
+ id: id('from'), class: 'drawer-input', type: 'date',
131
+ oninput: (e) => from.set(e.target.value),
132
+ })), field(id('until'), 'Stops on', h('input', {
133
+ id: id('until'), class: 'drawer-input', type: 'date',
134
+ oninput: (e) => until.set(e.target.value),
135
+ }))), h('div', { class: 'form-row rule-more-row' }, field(id('owner'), 'Owner (optional)', h('input', {
136
+ id: id('owner'), class: 'drawer-input', maxlength: '120', autocomplete: 'off', placeholder: 'who answers for this rule',
137
+ oninput: (e) => owner.set(e.target.value),
138
+ })), field(id('note'), 'Note (optional)', h('input', {
139
+ id: id('note'), class: 'drawer-input', maxlength: '500', autocomplete: 'off', placeholder: 'why the spend goes here',
140
+ oninput: (e) => note.set(e.target.value),
141
+ }))), h('p', { id: id('priority-hint'), class: 'form-hint', text: 'Rules are checked lowest number first, and the first rule that matches a request takes it. Dates are matched against when the spend happened; empty means from the start of your history and with no end.' })),
142
+ ];
143
+ return { children, build };
144
+ }
@@ -55,33 +55,56 @@ function bandValue(layer) {
55
55
  case 'withheld_uncosted':
56
56
  return isPrecise() ? 'supported; not priced' : 'happened, but not priced';
57
57
  case 'not_a_money_claim':
58
- return isPrecise() ? 'not a money claim' : 'not a dollar figure';
58
+ return isPrecise() ? 'no dollar figure, by design' : 'no dollar figure here';
59
59
  case 'withheld_unsupported':
60
- return 'not established';
60
+ return isPrecise() ? 'not established' : 'nothing to show yet';
61
61
  }
62
62
  }
63
+ /** A shown figure whose money is an estimate rather than metered, billed or allocated. */
64
+ function estimated(layer) {
65
+ return layer.support.figure === 'shown' && layer.support.profile.monetaryBasis === 'estimated';
66
+ }
63
67
  function band(layer, state) {
64
68
  const active = state.active === layer.id;
69
+ if (state.pending?.has(layer.id)) {
70
+ // Not a value and not an absence: the answer is on its way. Drawn as an
71
+ // open socket with no figure, so nothing here can be read as a zero.
72
+ return h('div', { class: `band band-open band-pending${active ? ' band-active' : ''}`, 'aria-busy': 'true' }, h('button', {
73
+ class: 'band-hit',
74
+ 'aria-current': active ? 'page' : false,
75
+ onclick: () => state.onSelect(layer.id),
76
+ }, h('span', { class: 'band-label' }, layer.label, h('span', { class: 'band-period', text: ` · ${layer.period}` })), h('span', { class: 'band-value band-unset', text: 'reading…' }), h('span', { class: 'band-basis', text: layer.id === 'realized'
77
+ ? "measuring what the work produced; the other figures don't wait for it"
78
+ : 'reading the ledger' })));
79
+ }
65
80
  const supported = claimIsSupported(layer);
66
81
  return h('div', { class: `band${active ? ' band-active' : ''}${supported ? '' : ' band-open'}` }, h('button', {
67
82
  class: 'band-hit',
68
83
  'aria-current': active ? 'page' : false,
69
84
  onclick: () => state.onSelect(layer.id),
70
- }, h('span', { class: 'band-label', text: layer.label }),
85
+ }, h('span', { class: 'band-label' }, layer.label, h('span', { class: 'band-period', text: ` · ${layer.period}` })),
71
86
  // Three distinct situations, three different words. `established` said
72
87
  // "not established" for all of them, including a Billed band that had
73
88
  // been reconciled (no dollar by design, so `usd(null)` rendered a bare
74
89
  // em dash) and a Realized band whose units shipped but were never priced.
75
- h('span', { class: `band-value${claimShowsFigure(layer) ? '' : ' band-unset'}`,
76
- text: bandValue(layer) }), h('span', { class: 'band-basis', text: layer.basis }), layer.nextStep
90
+ // An estimated figure must not look like a measured one. Realized carried
91
+ // the largest number on the page at the same size as Metered, so the
92
+ // least-evidenced claim read as the headline (critique 2026-10-12). It
93
+ // keeps its figure, smaller, with the word "estimate" on the band itself.
94
+ estimated(layer)
95
+ ? h('span', { class: 'band-value band-estimate' }, h('span', { class: 'band-chip', text: () => (isPrecise() ? 'estimate' : 'estimate, not a measurement') }), h('span', { text: bandValue(layer) }))
96
+ : h('span', { class: `band-value${claimShowsFigure(layer) ? '' : ' band-unset'}`,
97
+ text: bandValue(layer) }), h('span', { class: 'band-basis', text: layer.basis }), layer.nextStep
77
98
  ? h('span', { class: 'band-next', text: layer.nextStep })
78
99
  : null), h('button', {
79
100
  class: 'band-inspect',
80
- 'aria-label': `Inspect the evidence for the ${layer.label.toLowerCase()} claim`,
101
+ 'aria-label': `How the ${layer.label.toLowerCase()} figure was worked out`,
81
102
  onclick: () => state.onInspect(layer),
82
- text: () => (isPrecise() ? 'inspect claim' : 'why this number?'),
103
+ text: () => (isPrecise() ? 'show the working' : 'why this number?'),
83
104
  }));
84
105
  }
106
+ /** A sentence that opens with a lowercased label still starts with a capital. */
107
+ const sentence = (s) => s.charAt(0).toUpperCase() + s.slice(1);
85
108
  export function spine(state) {
86
109
  const children = [];
87
110
  state.layers.forEach((layer, i) => {
@@ -99,35 +122,42 @@ export function spine(state) {
99
122
  // claims that really are unevidenced, and the other two states say their own
100
123
  // thing. This became reachable when the axes moved to the wire: the browser's
101
124
  // count-based inference had no branch that could produce either one.
102
- const missing = state.layers.filter(claimIsUnevidenced).map((l) => l.label.toLowerCase());
103
- const conflicted = state.layers.filter(claimIsConflicted).map((l) => l.label.toLowerCase());
104
- const refuted = state.layers.filter(claimIsRefuted).map((l) => l.label.toLowerCase());
105
- const settled = state.layers.every(claimIsSupported);
106
- const uncosted = state.layers.filter(claimIsSupportedButUncosted).map((l) => l.label.toLowerCase());
125
+ // A claim still on its way is neither missing nor answered. Counting it as
126
+ // "cannot answer yet" told the reader Realized had no evidence while it was
127
+ // being measured.
128
+ const arrived = state.layers.filter((l) => !state.pending?.has(l.id));
129
+ const waiting = state.layers.filter((l) => state.pending?.has(l.id)).map((l) => l.label.toLowerCase());
130
+ const missing = arrived.filter(claimIsUnevidenced).map((l) => l.label.toLowerCase());
131
+ const conflicted = arrived.filter(claimIsConflicted).map((l) => l.label.toLowerCase());
132
+ const refuted = arrived.filter(claimIsRefuted).map((l) => l.label.toLowerCase());
133
+ const settled = waiting.length === 0 && arrived.every(claimIsSupported);
134
+ const uncosted = arrived.filter(claimIsSupportedButUncosted).map((l) => l.label.toLowerCase());
107
135
  const uncostedLine = uncosted.length === 0
108
136
  ? null
109
137
  : h('p', { class: 'spine-read spine-uncosted' }, isPrecise()
110
- ? `${uncosted.join(' and ')} ${uncosted.length === 1 ? 'is' : 'are'} substantiated but unpriced: the evidence holds and an input needed to state a dollar figure is absent. That is a withheld figure, not an absent claim.`
111
- : `We can see that ${uncosted.join(' and ')} happened — we just cannot put a number on ${uncosted.length === 1 ? 'it' : 'them'} yet.`);
138
+ ? `${sentence(uncosted.join(' and '))} ${uncosted.length === 1 ? 'is' : 'are'} backed by evidence but not priced: an input needed for a dollar figure is missing, so the figure is held back. The evidence itself stands.`
139
+ : `${sentence(uncosted.join(' and '))} happened, but there isn't enough yet to put a dollar figure on ${uncosted.length === 1 ? 'it' : 'them'}.`);
112
140
  const conflictedLine = conflicted.length === 0
113
141
  ? null
114
142
  : h('p', { class: 'spine-read spine-conflicted' }, isPrecise()
115
- ? `${conflicted.join(' and ')} ${conflicted.length === 1 ? 'is' : 'are'} contradicted: two observations of the same thing disagree. That is unadjudicated evidence, not missing evidence, and resolving it is a different task from collecting more.`
116
- : `Something about ${conflicted.join(' and ')} does not add up — two sources say different things. That is a disagreement to settle, not a gap to fill.`);
143
+ ? `${sentence(conflicted.join(' and '))} ${conflicted.length === 1 ? 'has' : 'have'} conflicting evidence: two records of the same thing disagree. Nothing is missing, so collecting more won't settle it; the conflict has to be resolved.`
144
+ : `Two sources disagree about ${conflicted.join(' and ')}. That needs sorting out; it isn't missing data.`);
117
145
  const refutedLine = refuted.length === 0
118
146
  ? null
119
147
  : h('p', { class: 'spine-read spine-refuted' }, isPrecise()
120
- ? `${refuted.join(' and ')} ${refuted.length === 1 ? 'is' : 'are'} refuted: the evidence says no. That is a measured answer, and the only one of these states that is.`
121
- : `The evidence actually says no for ${refuted.join(' and ')}. That is an answer, not a gap.`);
122
- return h('section', { class: 'spine', 'aria-label': 'The four claims' }, h('div', { class: 'spine-rail' }, ...children), h('p', { class: 'spine-read' }, settled
148
+ ? `${sentence(refuted.join(' and '))}: the evidence says no. That is a measured answer, the only one of these states that is.`
149
+ : `For ${refuted.join(' and ')}, the evidence says no. That is an answer, not a gap.`);
150
+ return h('section', { class: 'spine', 'aria-label': 'The four figures' }, h('div', { class: 'spine-rail' }, ...children), h('p', { class: 'spine-read' }, settled
123
151
  ? (isPrecise()
124
- ? 'All four claims are substantiated on this machine, each on its own evidence.'
152
+ ? 'All four figures are backed by evidence on this machine, each by its own.'
125
153
  : 'All four of these are backed by evidence on this machine.')
126
154
  : missing.length === 0
127
155
  ? (isPrecise()
128
- ? 'Four separate claims with four evidence standards. None is simply unevidenced here; see below for the ones whose evidence is unsettled.'
129
- : 'These are four different questions, not four versions of one number. None is simply missing — see below.')
156
+ ? 'Four separate figures, each needing its own kind of evidence. None is simply missing; the notes below cover the ones still unsettled.'
157
+ : 'These are four different questions, not four versions of one number. None is simply missing; see below.')
130
158
  : (isPrecise()
131
- ? `Four separate claims with four evidence standards; ${missing.join(' and ')} ${missing.length === 1 ? 'is' : 'are'} unsubstantiated here. An unsubstantiated layer is an absence of evidence, never a measured zero.`
132
- : `These are four different questions, not four versions of one number — and we cannot answer ${missing.join(' or ')} yet. That is missing evidence, not an answer of nothing.`)), conflictedLine, refutedLine, uncostedLine);
159
+ ? `Four separate figures, each needing its own kind of evidence. ${sentence(missing.join(' and '))} ${missing.length === 1 ? 'has' : 'have'} no evidence here yet, which is not the same as a measured zero.`
160
+ : `These are four different questions, not four versions of one number. ${sentence(missing.join(' and '))}: nothing to show yet, which isn't the same as zero.`)), conflictedLine, refutedLine, uncostedLine, waiting.length === 0
161
+ ? null
162
+ : h('p', { class: 'spine-read', 'aria-live': 'polite', text: `Still reading: ${waiting.join(' and ')}.` }));
133
163
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The pieces a job page is built from: numbered steps, labelled fields, and one
3
+ * page-level notice for what the server said.
4
+ *
5
+ * Allocate and Close are both jobs done in an order the engine needs, and both
6
+ * preview before they record. They share these so the two pages behave alike:
7
+ * the same step heading (an h2 under the page h1), the same field labelling,
8
+ * and the same notice that survives the reload a save triggers.
9
+ */
10
+ import { h } from '../core/dom.js';
11
+ import { signal } from '../core/signal.js';
12
+ /**
13
+ * A notice that lives outside a page's data region. A save reloads the payload,
14
+ * which rebuilds every form, and a message held by the form would vanish with it.
15
+ * A confirmation clears itself; a refusal stays until the next action replaces it.
16
+ */
17
+ export function pageNotice() {
18
+ const notice = signal(null);
19
+ const say = (ok, text) => {
20
+ const shown = { ok, text };
21
+ notice.set(shown);
22
+ if (ok)
23
+ setTimeout(() => { if (notice() === shown)
24
+ notice.set(null); }, 6000);
25
+ };
26
+ const node = h('p', {
27
+ class: () => `form-msg page-notice${notice()?.ok === false ? ' drawer-error' : ''}`,
28
+ // Always in the accessibility tree, empty when idle. A live region that is
29
+ // unhidden with its message already inside is usually not announced, so
30
+ // "saved" was shown but never spoken (NVDA run, 2026-10-15).
31
+ role: 'status', 'aria-live': 'polite',
32
+ text: () => notice()?.text ?? '',
33
+ });
34
+ return { node, say };
35
+ }
36
+ /**
37
+ * A numbered step. Pass `{ n, done: true }` for a step the ledger already
38
+ * satisfies: its number becomes a check, and screen readers hear "done".
39
+ */
40
+ export function step(at, title, lede, ...body) {
41
+ const n = typeof at === 'number' ? at : at.n;
42
+ const done = typeof at === 'number' ? false : at.done;
43
+ return h('section', { class: `section step${done ? ' step-done' : ''}` }, h('h2', { class: 'step-title' }, h('span', { class: 'step-n', 'aria-hidden': 'true', text: done ? '✓' : String(n) }), h('span', { text: title }), done ? h('span', { class: 'sr-only', text: ' (done)' }) : null), h('p', { class: 'step-lede', text: lede }), ...body);
44
+ }
45
+ const FOCUSABLE = 'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])';
46
+ function focusKey(el) {
47
+ return JSON.stringify([el.id, el.tagName, el.getAttribute('aria-label') ?? el.textContent?.trim() ?? '']);
48
+ }
49
+ function usable(el) {
50
+ return el instanceof HTMLElement && !el.disabled && el.getClientRects().length > 0;
51
+ }
52
+ /**
53
+ * Call just before a job page swaps in reloaded data; call the result after.
54
+ *
55
+ * The swap rebuilds the forms, and the control that started the save goes with
56
+ * them, dropping keyboard focus to the top of the document. The returned
57
+ * function puts focus back on the rebuilt equivalent of that control, or, when
58
+ * the save made it disappear, on the first control of the first step still to
59
+ * do. Focus the operator moved elsewhere while the request ran is left alone.
60
+ */
61
+ export function holdFocus(root) {
62
+ const active = document.activeElement;
63
+ if (!root || !active || !root.contains(active))
64
+ return () => { };
65
+ const key = focusKey(active);
66
+ return () => {
67
+ if (active.isConnected || (document.activeElement && document.activeElement !== document.body))
68
+ return;
69
+ const candidates = [...root.querySelectorAll(FOCUSABLE)].filter(usable);
70
+ const same = candidates.find((el) => focusKey(el) === key);
71
+ const next = same ?? [...root.querySelectorAll('.step:not(.step-done)')]
72
+ .map((s) => [...s.querySelectorAll(FOCUSABLE)].find(usable))
73
+ .find((el) => el !== undefined);
74
+ (next ?? candidates[0])?.focus();
75
+ };
76
+ }
77
+ export function field(id, label, control) {
78
+ return h('div', { class: 'form-field' }, h('label', { for: id, text: label }), control);
79
+ }
80
+ export function errorText(e) {
81
+ return e instanceof Error ? e.message : String(e);
82
+ }