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.
- package/LICENSING.md +5 -3
- package/README.md +57 -19
- package/bin/segreant-statusline.mjs +20 -0
- package/bin/segreant.mjs +19 -8
- package/dist/alerts/detect.js +13 -0
- package/dist/billing/epistemic.js +4 -0
- package/dist/billing/importer.js +29 -10
- package/dist/billing/reconcile.js +122 -32
- package/dist/billing/scope.js +7 -0
- package/dist/budget/guard.js +33 -4
- package/dist/cli/allocCmd.js +73 -4
- package/dist/cli/askModelPrices.js +59 -0
- package/dist/cli/backupCmd.js +2 -1
- package/dist/cli/basisLines.js +45 -0
- package/dist/cli/billingCmd.js +68 -9
- package/dist/cli/capitalCmd.js +2 -1
- package/dist/cli/connectCmd.js +50 -9
- package/dist/cli/egressCmd.js +15 -3
- package/dist/cli/evidenceCmd.js +6 -3
- package/dist/cli/flags.js +68 -0
- package/dist/cli/homeCmd.js +117 -0
- package/dist/cli/importCmd.js +210 -54
- package/dist/cli/launchCmd.js +2 -1
- package/dist/cli/opsCmd.js +49 -2
- package/dist/cli/planCmd.js +181 -0
- package/dist/cli/pricingDeclareCmd.js +178 -0
- package/dist/cli/quotaCmd.js +190 -0
- package/dist/cli/runCmd.js +203 -51
- package/dist/cli/showCmd.js +37 -11
- package/dist/cli/teamCmd.js +5 -5
- package/dist/cli/ui.js +24 -8
- package/dist/cli/valueCmd.js +189 -14
- package/dist/cli.js +102 -21
- package/dist/config.js +95 -6
- package/dist/connect/antigravity.js +310 -0
- package/dist/connect/claudeCode.js +43 -3
- package/dist/connect/codex.js +38 -1
- package/dist/connect/importShared.js +58 -5
- package/dist/connect/opencode.js +37 -9
- package/dist/connect/protobuf.js +126 -0
- package/dist/connect/registry.js +46 -0
- package/dist/connect/repoRoot.js +112 -0
- package/dist/cost/basis.js +168 -0
- package/dist/cost/declarations.js +37 -0
- package/dist/cost/exactPricing.js +4 -1
- package/dist/cost/firstSight.js +98 -0
- package/dist/cost/overrides.js +230 -0
- package/dist/cost/pricing.js +228 -15
- package/dist/cost/reprice.js +130 -0
- package/dist/dashboard/claim-support.js +11 -8
- package/dist/dashboard/contracts.js +23 -3
- package/dist/dashboard/routes.js +980 -112
- package/dist/dashboard/sessions.js +345 -0
- package/dist/dashboard/settings.js +2 -1
- package/dist/dashboard/value-cache.js +105 -0
- package/dist/dashboard/web/app/components/claimInspector.js +6 -5
- package/dist/dashboard/web/app/components/drawer.js +105 -35
- package/dist/dashboard/web/app/components/ruleFields.js +144 -0
- package/dist/dashboard/web/app/components/spine.js +54 -24
- package/dist/dashboard/web/app/components/workflow.js +82 -0
- package/dist/dashboard/web/app/core/actions.js +734 -23
- package/dist/dashboard/web/app/core/api.js +37 -1
- package/dist/dashboard/web/app/core/chain.js +11 -4
- package/dist/dashboard/web/app/core/claimLayers.js +179 -51
- package/dist/dashboard/web/app/core/claimTypes.js +30 -0
- package/dist/dashboard/web/app/core/fmt.js +19 -2
- package/dist/dashboard/web/app/core/generated-contract.js +23 -3
- package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
- package/dist/dashboard/web/app/core/generated-types.js +1 -1
- package/dist/dashboard/web/app/core/griffin.js +10 -0
- package/dist/dashboard/web/app/core/jobs.js +12 -0
- package/dist/dashboard/web/app/core/period.js +35 -0
- package/dist/dashboard/web/app/core/periodWords.js +10 -0
- package/dist/dashboard/web/app/core/periods.js +32 -0
- package/dist/dashboard/web/app/core/pricingActions.js +264 -0
- package/dist/dashboard/web/app/core/privacyText.js +78 -0
- package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
- package/dist/dashboard/web/app/core/registry.js +79 -46
- package/dist/dashboard/web/app/core/slug.js +9 -0
- package/dist/dashboard/web/app/core/theme.js +48 -0
- package/dist/dashboard/web/app/core/view.js +69 -0
- package/dist/dashboard/web/app/main.js +197 -15
- package/dist/dashboard/web/app/views/allocation.js +249 -61
- package/dist/dashboard/web/app/views/control.js +57 -16
- package/dist/dashboard/web/app/views/data.js +4 -4
- package/dist/dashboard/web/app/views/evidence.js +336 -87
- package/dist/dashboard/web/app/views/privacy.js +13 -0
- package/dist/dashboard/web/app/views/spend.js +30 -15
- package/dist/dashboard/web/app/views/statement.js +189 -0
- package/dist/dashboard/web/app/views/system.js +44 -4
- package/dist/dashboard/web/app/views/value.js +105 -59
- package/dist/dashboard/web/app/views/workbench.js +312 -0
- package/dist/dashboard/web/classic.html +16 -4
- package/dist/dashboard/web/index.html +1 -1
- package/dist/dashboard/web/styles/app.css +536 -23
- package/dist/demo/seed.js +153 -14
- package/dist/economics/ledger.js +1 -1
- package/dist/egress/policy.js +129 -5
- package/dist/export/allocationCsv.js +70 -0
- package/dist/export/billingCsv.js +6 -2
- package/dist/export/csv.js +10 -2
- package/dist/export/economic.js +6 -2
- package/dist/export/statementCsv.js +104 -0
- package/dist/git/correlate.js +86 -9
- package/dist/git/quality.js +107 -24
- package/dist/git/repoScope.js +207 -0
- package/dist/git/survivalStore.js +186 -0
- package/dist/guide.js +9 -4
- package/dist/plans/detect.js +147 -0
- package/dist/proxy/server.js +134 -14
- package/dist/proxy/usage.js +9 -4
- package/dist/quota/limits.js +138 -0
- package/dist/quota/statusline.js +68 -0
- package/dist/quota/view.js +122 -0
- package/dist/scan/knownApps.js +6 -2
- package/dist/scan/scan.js +9 -0
- package/dist/store/allocation.js +14 -0
- package/dist/store/db.js +259 -34
- package/dist/store/realization.js +78 -14
- package/dist/store/schema.js +59 -1
- package/dist/store/scopeEvidence.js +67 -0
- package/dist/util/pool.js +19 -0
- package/dist/util/time.js +7 -0
- package/dist/value/realization.js +321 -30
- package/dist/value/report.js +28 -8
- package/dist/value/selfReported.js +10 -9
- package/docs/ARCHITECTURE.md +8 -4
- package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
- package/docs/CLI-JSON-CONTRACT.md +38 -0
- package/docs/DATA-BOUNDARIES.md +66 -6
- package/docs/FAQ.md +43 -9
- package/docs/GETTING-STARTED.md +19 -12
- package/docs/GUIDE.md +81 -18
- package/docs/INTEGRATIONS.md +9 -9
- package/docs/METHODOLOGY.md +3 -2
- package/docs/NEUTRALITY.md +1 -1
- package/docs/PRIVACY.md +51 -0
- package/docs/README.md +1 -0
- package/docs/RELEASE-GATE.md +52 -7
- package/docs/THREAT-MODEL.md +2 -1
- package/package.json +3 -2
- 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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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:
|
|
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
|
-
}),
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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' :
|
|
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(() =>
|
|
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() ? '
|
|
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.
|
|
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
|
-
|
|
76
|
-
|
|
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': `
|
|
101
|
+
'aria-label': `How the ${layer.label.toLowerCase()} figure was worked out`,
|
|
81
102
|
onclick: () => state.onInspect(layer),
|
|
82
|
-
text: () => (isPrecise() ? '
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
const
|
|
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'}
|
|
111
|
-
:
|
|
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 ? '
|
|
116
|
-
: `
|
|
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 ')}
|
|
121
|
-
: `
|
|
122
|
-
return h('section', { class: 'spine', 'aria-label': 'The four
|
|
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
|
|
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
|
|
129
|
-
: 'These are four different questions, not four versions of one number. None is simply missing
|
|
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
|
|
132
|
-
: `These are four different questions, not four versions of one number
|
|
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
|
+
}
|