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
@@ -13,17 +13,25 @@
13
13
  import { h, render, captureFocus, restoreFocus, trapFocus } from './core/dom.js';
14
14
  import { signal, effect, computed, onCleanup } from './core/signal.js';
15
15
  import { register, setRegister } from './core/fmt.js';
16
- import { loadChain } from './core/chain.js';
16
+ import { GRIFFIN_PATH, GRIFFIN_TRANSFORM, GRIFFIN_VIEWBOX } from './core/griffin.js';
17
+ import { api, ApiError } from './core/api.js';
18
+ import { buildClaimLayers, chainIsSampleData } from './core/claimLayers.js';
19
+ import { period } from './core/period.js';
17
20
  import { spine } from './components/spine.js';
18
21
  import { mountDrawer } from './components/drawer.js';
19
22
  import { mountClaimInspector, openClaimInspector } from './components/claimInspector.js';
20
23
  import { spendView } from './views/spend.js';
21
24
  import { evidenceView } from './views/evidence.js';
22
25
  import { systemView } from './views/system.js';
26
+ import { privacyView } from './views/privacy.js';
23
27
  import { allocationView } from './views/allocation.js';
24
28
  import { valueView } from './views/value.js';
25
29
  import { controlView } from './views/control.js';
26
30
  import { dataView } from './views/data.js';
31
+ import { statementView } from './views/statement.js';
32
+ import { initTheme } from './core/theme.js';
33
+ import { initView, viewPrefs, jobShown } from './core/view.js';
34
+ import { JOBS as SHARED_JOBS } from './core/jobs.js';
27
35
  /** The four spine bands map onto the territories that answer their question. */
28
36
  const LAYER_ROUTE = {
29
37
  metered: 'spend',
@@ -37,15 +45,64 @@ const ROUTE_LAYER = {
37
45
  allocation: 'allocated',
38
46
  value: 'realized',
39
47
  };
40
- /** Everything that is not a money claim: how you feed and configure the tool. */
41
- const OPERATIONS = [
42
- { id: 'data', label: 'Data', plain: 'Getting your usage in, from tools and providers.' },
43
- { id: 'control', label: 'Control', plain: 'Budgets and alerts, so nothing surprises you.' },
44
- { id: 'system', label: 'System', plain: 'Pricing, settings, maintenance, and what this GUI covers.' },
48
+ /**
49
+ * The navigation names the jobs people come to do, in the order they meet them.
50
+ * It used to list only Data, Control and System: the four money views were
51
+ * reachable through the spine alone, and Billed, Allocated and Realized had no
52
+ * name a finance reader would look for (critique 2026-10-12).
53
+ */
54
+ const JOBS = SHARED_JOBS;
55
+ /** How you feed and configure the tool: needed, but not why anyone opens it. */
56
+ const UTILITIES = [
57
+ { id: 'data', label: 'Data', plain: 'Bring usage in from your tools and providers.' },
58
+ { id: 'system', label: 'Settings', plain: 'Pricing, settings, maintenance, and what this GUI covers.' },
45
59
  ];
46
- const ALL_ROUTES = ['spend', 'evidence', 'allocation', 'value', 'data', 'control', 'system'];
60
+ const ALL_ROUTES = ['statement', 'spend', 'evidence', 'allocation', 'value', 'data', 'control', 'system', 'privacy'];
47
61
  const current = signal(readRoute());
48
- const chain = signal(null);
62
+ // The four payloads behind the claims. `undefined` = still on its way; `null` =
63
+ // the read failed, which the derivation already treats as missing evidence.
64
+ const overviewIn = signal(undefined);
65
+ const billingIn = signal(undefined);
66
+ const allocationIn = signal(undefined);
67
+ const valueIn = signal(undefined);
68
+ const chainInputs = computed(() => ({
69
+ overview: overviewIn() ?? null,
70
+ billing: billingIn() ?? null,
71
+ allocation: allocationIn() ?? null,
72
+ value: valueIn() ?? null,
73
+ }));
74
+ const chain = computed(() => buildClaimLayers(chainInputs(), period()));
75
+ const pendingClaims = computed(() => new Set([
76
+ ...(overviewIn() === undefined ? ['metered'] : []),
77
+ ...(billingIn() === undefined ? ['billed'] : []),
78
+ ...(allocationIn() === undefined ? ['allocated'] : []),
79
+ ...(valueIn() === undefined ? ['realized'] : []),
80
+ ]));
81
+ const sampleData = computed(() => chainIsSampleData(chainInputs()));
82
+ /**
83
+ * The figures on screen were read from a server that has since stopped answering.
84
+ * Set by a failed read or by the timed re-check; cleared only by a fresh page load,
85
+ * because nothing re-reads the payloads in place.
86
+ */
87
+ const figuresStale = signal(false);
88
+ /** When the last successful read landed, so the age of the figures can be stated. */
89
+ const lastReadAt = signal(null);
90
+ /** Advances on each re-check, so the stated age keeps moving while the page sits. */
91
+ const clock = signal(Date.now());
92
+ const STALE_RECHECK_MS = 15_000;
93
+ const unreachable = (e) => e instanceof ApiError && e.status === 0;
94
+ function ageText(readAt, now) {
95
+ if (readAt === null)
96
+ return 'at an unknown time';
97
+ const minutes = Math.floor((now - readAt) / 60_000);
98
+ if (minutes < 1)
99
+ return 'less than a minute ago';
100
+ return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
101
+ }
102
+ /** Stated on the page, above the figures, so a stale number is never read as current. */
103
+ function staleBanner() {
104
+ return h('div', { class: 'banner banner-stale', role: 'status', 'aria-live': 'polite' }, h('p', null, h('strong', { text: 'Segreant stopped responding. ' }), h('span', { text: () => `The figures below were read ${ageText(lastReadAt(), clock())} and are not current.` })), h('button', { type: 'button', class: 'btn-ghost banner-retry', onclick: () => window.location.reload(), text: 'Retry' }));
105
+ }
49
106
  /**
50
107
  * Whether the operator has chosen a register at all — NOT which one they chose.
51
108
  *
@@ -63,12 +120,42 @@ function go(route) {
63
120
  location.hash = `/${route}`;
64
121
  current.set(route);
65
122
  }
123
+ /**
124
+ * Focus the current page's h1 once the view has rendered; give up quietly after
125
+ * a second, or as soon as the user has put focus somewhere themselves. The
126
+ * retries used to land late and pull focus out of wherever the user had just
127
+ * moved it: the skip link's target, a field, a button (caught by the browser
128
+ * suite's skip-link diagnostics on CI, 2026-10-17).
129
+ */
130
+ function focusPageHeading(tries = 20, from = document.activeElement) {
131
+ const now = document.activeElement;
132
+ if (now !== from && now !== null && now !== document.body)
133
+ return;
134
+ const heading = document.querySelector('main h1');
135
+ if (!heading) {
136
+ if (tries > 0)
137
+ setTimeout(() => focusPageHeading(tries - 1, from), 50);
138
+ return;
139
+ }
140
+ if (!heading.hasAttribute('tabindex'))
141
+ heading.setAttribute('tabindex', '-1');
142
+ heading.focus();
143
+ }
66
144
  function brandMark() {
67
- return h('svg', { class: 'brand-mark', viewBox: '0 0 30 34', 'aria-hidden': 'true' }, h('path', { d: 'M15 1 28 6v11c0 8-5.6 13.4-13 16C7.6 30.4 2 25 2 17V6z', fill: 'none', stroke: 'var(--gold)', 'stroke-width': '1.6' }), h('path', { d: 'M11 11h8M11 16h6M11 21h4', stroke: 'var(--gold)', 'stroke-width': '1.6', 'stroke-linecap': 'round' }));
145
+ return h('svg', { class: 'brand-mark', viewBox: GRIFFIN_VIEWBOX, 'aria-hidden': 'true' }, h('path', { d: GRIFFIN_PATH, transform: GRIFFIN_TRANSFORM, fill: 'var(--brand)', 'fill-rule': 'evenodd' }));
68
146
  }
69
147
  function firstRun() {
70
148
  const previousFocus = captureFocus(document.activeElement);
71
- const body = h('div', { class: 'firstrun', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'fr-title', tabindex: '-1' }, h('div', { class: 'firstrun-card' }, h('div', { class: 'firstrun-mark' }, brandMark()), h('h1', { id: 'fr-title', text: 'How should Segreant talk to you?' }), h('p', { class: 'lede', text: 'The numbers are identical either way. This only changes how precisely they are worded and how much detail shows by default — and you can switch whenever you like.' }), h('div', { class: 'choice-grid' }, h('button', { class: 'choice', onclick: () => setRegister('plain') }, h('strong', { text: 'Plain language' }), h('span', { text: 'Rounded figures, concepts explained where they appear, no command line assumed.' }), h('code', { class: 'choice-eg', text: '$59.16 · measured from metered requests' })), h('button', { class: 'choice', onclick: () => setRegister('precise') }, h('strong', { text: 'Precise' }), h('span', { text: 'Exact microdollar figures, provenance labels shown, equivalent commands surfaced.' }), h('code', { class: 'choice-eg', text: '$59.163468 · local_list_price' }))), h('p', { class: 'fine', text: 'Runs entirely on this machine. This page loads nothing from the internet — no fonts, no analytics, no third parties.' })));
149
+ const body = h('div', { class: 'firstrun', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'fr-title', tabindex: '-1' }, h('div', { class: 'firstrun-card' }, h('div', { class: 'firstrun-mark' }, brandMark()),
150
+ // The product has two audiences on one set of screens, and the first run
151
+ // is where it learns which one is here. It used to ask only how figures
152
+ // should be worded, so a finance reader and a developer landed on the
153
+ // same screen with different decimals. The answer now also picks where
154
+ // each starts: the money, or getting usage in.
155
+ h('h1', { id: 'fr-title', text: 'What brings you to Segreant?' }), h('p', { class: 'lede', text: 'Pick the closer one. It decides where you start and how figures are worded. The numbers are the same either way, and you can switch at any time with Plain and Precise at the top right.' }), h('div', { class: 'choice-grid' }, h('button', { class: 'choice', onclick: () => { setRegister('plain'); go('statement'); } }, h('strong', { text: 'I look after the money' }), h('span', { text: 'See what AI cost, set limits, check it against the bill and assign it to budgets. Plain wording; no terminal needed.' }),
156
+ // A wording sample, not a figure from any ledger: it is the same on a
157
+ // fresh install with no data, so it says so (release gate, 2026-10-18).
158
+ h('code', { class: 'choice-eg', text: 'e.g. $59.16 list cost (estimate)' })), h('button', { class: 'choice', onclick: () => { setRegister('precise'); go('data'); } }, h('strong', { text: 'I build with AI tools' }), h('span', { text: 'Bring in usage from Claude Code, Codex and others, then see it by project and model. Exact figures and the matching commands.' }), h('code', { class: 'choice-eg', text: 'e.g. $59.163468 list cost (estimate)' }))), h('p', { class: 'fine', text: 'Runs entirely on this machine. This page loads nothing from the internet: no fonts, no analytics, no third parties.' })));
72
159
  const release = trapFocus(body);
73
160
  const onKey = (event) => {
74
161
  if (event.key === 'Escape') {
@@ -82,14 +169,35 @@ function firstRun() {
82
169
  onCleanup(() => {
83
170
  release();
84
171
  body.removeEventListener('keydown', onKey);
85
- restoreFocus(previousFocus);
172
+ // On a fresh page the opener is <body>, and "restoring" to it left a screen
173
+ // reader at the top of the document (NVDA run, 2026-10-15). Land on the
174
+ // chosen page's heading instead.
175
+ if (previousFocus && previousFocus !== document.body)
176
+ restoreFocus(previousFocus);
177
+ else
178
+ focusPageHeading();
86
179
  });
87
180
  queueMicrotask(() => body.querySelector('.choice')?.focus());
88
181
  return body;
89
182
  }
90
183
  function topbar() {
91
- return h('header', { class: 'topbar' }, h('a', { class: 'brand', href: '#/spend', onclick: () => go('spend') }, brandMark(), h('span', { class: 'brand-text' }, h('span', { class: 'brand-name', text: 'Segreant' }), h('span', { class: 'brand-tag', text: 'AI Financial Ops' }))), h('nav', { class: 'ops', 'aria-label': 'Operations' }, ...OPERATIONS.map((op) => h('button', {
184
+ return h('header', { class: 'topbar' }, h('a', { class: 'brand', href: '#/spend', onclick: () => go('spend') }, brandMark(), h('span', { class: 'brand-text' }, h('span', { class: 'brand-name', text: 'Segreant' }), h('span', { class: 'brand-tag', text: 'AI Financial Ops' }))), () => (sampleData()
185
+ ? h('span', {
186
+ class: 'pill pill-demo topbar-demo',
187
+ role: 'status',
188
+ title: 'Sample data. None of this was measured from your machine.',
189
+ text: 'sample data',
190
+ })
191
+ : null), h('nav', { class: 'ops', 'aria-label': 'Jobs' }, ...JOBS.map((op) => h('button', {
92
192
  class: 'op',
193
+ // A job the reader hid stays out of the bar unless it is the one open.
194
+ hidden: () => !jobShown(op.id) && current() !== op.id,
195
+ 'aria-current': () => (current() === op.id ? 'page' : false),
196
+ title: op.plain,
197
+ onclick: () => go(op.id),
198
+ text: op.label,
199
+ }))), h('nav', { class: 'ops ops-util', 'aria-label': 'Setup' }, ...UTILITIES.map((op) => h('button', {
200
+ class: 'op op-util',
93
201
  'aria-current': () => (current() === op.id ? 'page' : false),
94
202
  title: op.plain,
95
203
  onclick: () => go(op.id),
@@ -102,6 +210,7 @@ function topbar() {
102
210
  }
103
211
  function viewFor(route) {
104
212
  switch (route) {
213
+ case 'statement': return statementView();
105
214
  case 'spend': return spendView();
106
215
  case 'evidence': return evidenceView();
107
216
  case 'allocation': return allocationView();
@@ -109,9 +218,12 @@ function viewFor(route) {
109
218
  case 'control': return controlView();
110
219
  case 'data': return dataView();
111
220
  case 'system': return systemView();
221
+ case 'privacy': return privacyView();
112
222
  }
113
223
  }
114
224
  function boot() {
225
+ initTheme();
226
+ initView();
115
227
  const root = document.getElementById('app');
116
228
  if (!root)
117
229
  throw new Error('missing #app');
@@ -130,7 +242,65 @@ function boot() {
130
242
  // wording register, and re-reading them on a plain/precise click re-issued
131
243
  // every endpoint behind the spine — including `/api/value`, which correlates
132
244
  // against the repository and is the slowest read this product has.
133
- void loadChain('30d').then((layers) => chain.set(layers)).catch(() => chain.set(null));
245
+ //
246
+ // Each claim draws as soon as its own payload lands. They used to wait for all
247
+ // four, and `/api/value` (which reads git history) took 17-21 s on a real
248
+ // ledger, so the whole header said "Reading the ledger…" for that long.
249
+ // Billing, allocation and value do not depend on the period, so they are read
250
+ // once; the overview is re-read whenever the period changes.
251
+ //
252
+ // Order matters as much as independence: fired together, the value read
253
+ // pushed the overview from 0.5 s alone to 10 s. (Other reads are not queued
254
+ // behind it in general: settings answered in 6-16 ms during a 25 s value
255
+ // read; the overview shares the work the value read is doing.) So the cheap reads go first and the
256
+ // value read starts only once the overview has answered.
257
+ let restStarted = false;
258
+ const readTheRest = () => {
259
+ if (restStarted)
260
+ return;
261
+ restStarted = true;
262
+ void Promise.allSettled([
263
+ api.billing().then((b) => { billingIn.set(b); lastReadAt.set(Date.now()); }, (e) => { billingIn.set(null); if (unreachable(e))
264
+ figuresStale.set(true); }),
265
+ api.allocation().then((a) => { allocationIn.set(a); lastReadAt.set(Date.now()); }, (e) => { allocationIn.set(null); if (unreachable(e))
266
+ figuresStale.set(true); }),
267
+ ]).then(() => api.value().then((v) => { valueIn.set(v); lastReadAt.set(Date.now()); }, (e) => { valueIn.set(null); if (unreachable(e))
268
+ figuresStale.set(true); }));
269
+ };
270
+ effect(() => {
271
+ const r = period();
272
+ const controller = new AbortController();
273
+ overviewIn.set(undefined);
274
+ void api.overview(r, controller.signal).then((o) => { if (!controller.signal.aborted) {
275
+ overviewIn.set(o);
276
+ lastReadAt.set(Date.now());
277
+ } }, (e) => { if (!controller.signal.aborted) {
278
+ overviewIn.set(null);
279
+ if (unreachable(e))
280
+ figuresStale.set(true);
281
+ } }).finally(readTheRest);
282
+ onCleanup(() => controller.abort());
283
+ });
284
+ // A screen that changed the ledger says so, and the claim it touched is
285
+ // re-read, so the spine never contradicts the page beneath it.
286
+ window.addEventListener('segreant:ledger-changed', (event) => {
287
+ const claim = event.detail?.claim;
288
+ if (claim === 'allocation') {
289
+ void api.allocation().then((a) => { allocationIn.set(a); lastReadAt.set(Date.now()); }, () => undefined);
290
+ }
291
+ if (claim === 'billing') {
292
+ void api.billing().then((b) => { billingIn.set(b); lastReadAt.set(Date.now()); }, () => undefined);
293
+ }
294
+ });
295
+ // A stopped server is noticed without a click. While any figure is on screen the
296
+ // shell re-checks the server; a dead one marks what is shown as not current.
297
+ setInterval(() => {
298
+ clock.set(Date.now());
299
+ if (lastReadAt() === null || figuresStale())
300
+ return;
301
+ void api.health().then(() => undefined, (e) => { if (unreachable(e))
302
+ figuresStale.set(true); });
303
+ }, STALE_RECHECK_MS);
134
304
  effect(() => {
135
305
  // `registerChosen()`, never `register()`. Reading the register itself made
136
306
  // this effect — which renders the ENTIRE application — a subscriber to the
@@ -164,17 +334,29 @@ function boot() {
164
334
  event.preventDefault();
165
335
  document.getElementById('main')?.focus();
166
336
  },
167
- }), topbar(), h('div', { class: 'sheet' }, () => {
337
+ }), topbar(), () => (figuresStale() && lastReadAt() !== null ? staleBanner() : null),
338
+ // The banner alone was easy to miss: the figures under it looked exactly
339
+ // as current as before. While stale, the sheet is dimmed as well.
340
+ h('div', { class: () => (figuresStale() && lastReadAt() !== null ? 'sheet is-stale' : 'sheet') }, () => {
341
+ // The four claims head the four money views only. On Protect, Data
342
+ // and Settings they pushed the screen's own content below the fold
343
+ // and asserted nothing about it.
344
+ if (!ROUTE_LAYER[current()])
345
+ return null;
346
+ // The reader can fold the strip away in Settings; every figure is still on its own page.
347
+ if (!viewPrefs().strip)
348
+ return null;
168
349
  const layers = chain();
169
350
  if (!layers)
170
351
  return h('div', { class: 'spine spine-loading' }, h('p', { class: 'spine-read', text: 'Reading the ledger…' }));
171
352
  return spine({
172
353
  layers,
354
+ pending: pendingClaims(),
173
355
  active: ROUTE_LAYER[current()] ?? null,
174
356
  onSelect: (id) => go(LAYER_ROUTE[id]),
175
357
  onInspect: openClaimInspector,
176
358
  });
177
- }, h('main', { class: 'main', id: 'main', tabindex: '-1' }, () => viewFor(current()))), h('footer', { class: 'shellfoot' }, h('span', { text: 'Dashboard runs locally; provider traffic follows your configured egress boundary.' }), h('a', { href: '/classic', text: 'Classic dashboard' })));
359
+ }, h('main', { class: 'main', id: 'main', tabindex: '-1' }, () => viewFor(current()))), h('footer', { class: 'shellfoot' }, h('span', { text: 'Runs on this machine. Outbound traffic goes only to the configured provider and the other routes you set up.' }), h('span', { class: 'shellfoot-links' }, h('a', { href: '#/privacy', text: 'Privacy' }), h('a', { href: '/classic', text: 'Classic dashboard' }))));
178
360
  });
179
361
  }
180
362
  boot();