@mythicalos/ui-core 0.2.1 → 0.3.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.
@@ -0,0 +1,443 @@
1
+ // @mythicalos/ui-core — the pure derivation behind the `session-card` atom (design registry spec
2
+ // v1, `mythical-design/ds/components-session-card.html`). Extracted from the only product that
3
+ // implemented it (the Control Room's rail `SessionCard.tsx` + its `derive.ts`/`sessionsVm.ts`
4
+ // state-dot derivation), with the product's session view-model lifted OUT: every function here
5
+ // takes plain primitives, so a second product can render the same card from a different wire shape.
6
+ //
7
+ // Everything branchy lives here — the Preact and React bindings only render what these functions
8
+ // return, so they cannot drift.
9
+ //
10
+ // THREE HONESTY INVARIANTS ARE LOAD-BEARING (they are design, not product logic):
11
+ //
12
+ // 1. ABSENCE IS NOT ZERO. A session with no context reading belongs to NO band: `ctxBand`
13
+ // returns `"unknown"`, `ctxFillPct` returns `undefined` (so the binding draws no fill rect at
14
+ // all — never a 0-width one that reads as "0%"), and `ctxValueText` returns `"—"`, never
15
+ // `"0%"`. The same rule governs the spine strip: an unreported `distills` yields ZERO nodes
16
+ // (the strip is omitted), never a fabricated "0 distills"; an unreported `savedTok` renders
17
+ // `"—"`, never `"0 tok"`. A REPORTED zero is a real reading and renders as zero.
18
+ //
19
+ // 2. UNKNOWN IS NOT IDLE. `idle` is a claim the product's wire has to make. When no activity is
20
+ // reported, `sessionStatus` falls back to the honest LIFECYCLE label (an active session with
21
+ // no activity signal reads `"active"`, not `"idle"`); when nothing at all is reported it
22
+ // returns the distinct `"unknown"` key/tone. `"idle"` is returned ONLY for an explicit
23
+ // `activity: "idle"`.
24
+ //
25
+ // 3. THE 75/90 THRESHOLDS ARE PRODUCT-TUNABLE (canonical tokens.css rule #4: "thresholds are
26
+ // product-defined (tweakable)"). They are a defaulted parameter — `CTX_THRESHOLDS_DEFAULT` —
27
+ // never a constant baked into a render path.
28
+ /** The design card's tick positions: warn at 75%, critical at 90% — overridable per product. */
29
+ export const CTX_THRESHOLDS_DEFAULT = { warn: 75, critical: 90 };
30
+ /** A defensive read for the public entry points below: a parameter default only covers
31
+ * `undefined`, and a product's runtime-shaped config can hand us `null` or a non-object. */
32
+ function isObject(v) {
33
+ return typeof v === "object" && v !== null;
34
+ }
35
+ /**
36
+ * Normalize a product's threshold pair ONCE, so the band and the bar's ticks can never disagree
37
+ * about where the thresholds are. Two corrections:
38
+ *
39
+ * · A member that is not a finite number strictly inside the rail (0 < t < 100) is not a usable
40
+ * threshold — it marks nothing the meter can draw — so it falls back to the design default
41
+ * rather than silently reclassifying every reading. (`{warn: NaN}` must not quietly make every
42
+ * session look nominal; `{warn: -5}` must not quietly make every session look hot while
43
+ * drawing no tick to explain why.)
44
+ * · The pair is ORDERED (`warn` ≤ `critical`). A mis-ordered pair leaves the higher tick
45
+ * unreachable — with `{warn: 90, critical: 75}` an 80% reading is already `error` while a
46
+ * `warn` tick still sits at 90, so the bar marks a boundary that can never be crossed. Both
47
+ * boundaries the product asked for are kept; only which one is named `warn` is corrected.
48
+ */
49
+ export function normalizeCtxThresholds(thresholds) {
50
+ // These are PUBLIC entry points a product reaches with runtime-shaped config (a settings blob,
51
+ // an API response), so a non-object gets the defaults rather than a TypeError mid-render — a
52
+ // parameter default only covers `undefined`.
53
+ const t = isObject(thresholds) ? thresholds : CTX_THRESHOLDS_DEFAULT;
54
+ const usable = (v, fallback) => typeof v === "number" && Number.isFinite(v) && v > 0 && v < CTX_BAR_SPAN ? v : fallback;
55
+ const warn = usable(t.warn, CTX_THRESHOLDS_DEFAULT.warn);
56
+ const critical = usable(t.critical, CTX_THRESHOLDS_DEFAULT.critical);
57
+ return { warn: Math.min(warn, critical), critical: Math.max(warn, critical) };
58
+ }
59
+ /** True only for a usable numeric reading — a garbage reading is not a reading. */
60
+ function isReading(pct) {
61
+ return typeof pct === "number" && Number.isFinite(pct);
62
+ }
63
+ /**
64
+ * The ONE number the card works from: the reading clamped to 0–100, at FULL precision, or
65
+ * `undefined` when there is no reading. The band and the fill are derived from this — the
66
+ * threshold comparison is against what was actually reported, so an 89.5% session has NOT crossed
67
+ * a 90% critical line. The LABEL then adapts its precision to this same number (see
68
+ * `ctxValueText`) so what the card displays can never contradict what it claims.
69
+ */
70
+ export function ctxReading(pct) {
71
+ return isReading(pct) ? Math.min(100, Math.max(0, pct)) : undefined;
72
+ }
73
+ /**
74
+ * Band for a context reading, derived from the reading AS REPORTED — 89.5 has not crossed a 90
75
+ * critical line and must not be shown as though it had. Absent ⇒ `"unknown"`. The thresholds are
76
+ * normalized (and ordered) first, so the band and the bar's ticks always agree.
77
+ */
78
+ export function ctxBand(pct, thresholds) {
79
+ const v = ctxReading(pct);
80
+ if (v === undefined)
81
+ return "unknown";
82
+ const t = normalizeCtxThresholds(thresholds);
83
+ if (v >= t.critical)
84
+ return "error";
85
+ if (v >= t.warn)
86
+ return "warn";
87
+ return "ok";
88
+ }
89
+ /**
90
+ * The 0–100 fill length, or `undefined` when there is no reading. `undefined` means the binding
91
+ * draws NO fill at all — the caller must never substitute 0, which would render as a confident
92
+ * "empty context" (invariant 1).
93
+ */
94
+ export function ctxFillPct(pct) {
95
+ return ctxReading(pct);
96
+ }
97
+ /** What an absent reading renders as — never `"0%"`. */
98
+ export const CTX_UNKNOWN_TEXT = "—";
99
+ /** How many decimal places the value may grow to before it is shown verbatim. */
100
+ const CTX_VALUE_PLACES = [0, 1, 2];
101
+ /**
102
+ * The meter's right-hand value: `"62%"` for a reading, `"—"` for none (invariant 1).
103
+ *
104
+ * Whole percents are what the design card shows, but a rounded percent must never contradict the
105
+ * band the card is enforcing: at the default thresholds, 89.5% is `warn`, so displaying it as
106
+ * "90%" would put an amber card next to the critical number. The label therefore takes the FIRST
107
+ * precision whose displayed number lands in the same band as the reading — "89.5%" here, plain
108
+ * "62%" in the ordinary case — falling back to the reading verbatim, which by definition agrees.
109
+ */
110
+ export function ctxValueText(pct, thresholds) {
111
+ const v = ctxReading(pct);
112
+ if (v === undefined)
113
+ return CTX_UNKNOWN_TEXT;
114
+ const band = ctxBand(v, thresholds);
115
+ for (const places of CTX_VALUE_PLACES) {
116
+ const shown = Number(v.toFixed(places));
117
+ if (ctxBand(shown, thresholds) === band)
118
+ return `${shown}%`;
119
+ }
120
+ return `${v}%`;
121
+ }
122
+ /** The meter's left-hand label and the separator the card composes its notes with. */
123
+ export const CTX_LABEL = "context";
124
+ export const SESSION_CARD_SEP = " · ";
125
+ /**
126
+ * The meter's left-hand note. The design card annotates the band (`context · distill suggested` at
127
+ * warn, `context · distill now` at error, `context · stale` for a stale reading); the unknown state
128
+ * says so outright, and says it BEFORE `stale` — "stale" implies a last-known value, so it must
129
+ * never stand in for "we never measured it".
130
+ */
131
+ export function ctxNoteText(band, opts) {
132
+ if (band === "unknown")
133
+ return `${CTX_LABEL}${SESSION_CARD_SEP}not measured`;
134
+ if (isObject(opts) && opts.stale === true)
135
+ return `${CTX_LABEL}${SESSION_CARD_SEP}stale`;
136
+ if (band === "error")
137
+ return `${CTX_LABEL}${SESSION_CARD_SEP}distill now`;
138
+ if (band === "warn")
139
+ return `${CTX_LABEL}${SESSION_CARD_SEP}distill suggested`;
140
+ return CTX_LABEL;
141
+ }
142
+ /** Wrapper class for the meter — the band modifier tints the fill AND the value in one place. */
143
+ export function ctxMeterClass(input) {
144
+ const band = isObject(input) ? input.band : "unknown";
145
+ const stale = isObject(input) && input.stale === true;
146
+ return `my-session-card__ctx my-session-card__ctx--${band}${stale ? " is-stale" : ""}`;
147
+ }
148
+ // ── bar geometry (SVG presentation attributes only — CSP forbids the inline `width:62%` the
149
+ // design prototype used, and product-tunable ticks cannot be expressed as fixed classes) ──
150
+ /** The bar's user-space width: percent IS the x axis, so a reading maps 1:1 onto it. */
151
+ export const CTX_BAR_SPAN = 100;
152
+ /** The bar's user-space height (the design card's 8px rail). */
153
+ export const CTX_BAR_HEIGHT = 8;
154
+ /** `viewBox` for the bar; the binding stretches it to the card width (`preserveAspectRatio="none"`). */
155
+ export const CTX_BAR_VIEWBOX = `0 0 ${CTX_BAR_SPAN} ${CTX_BAR_HEIGHT}`;
156
+ /** Tick width in user space (≈1.5px once the bar is stretched to a ~250px card). */
157
+ export const CTX_BAR_TICK_WIDTH = 0.6;
158
+ /**
159
+ * Pure bar geometry, from the SAME normalized thresholds `ctxBand` uses — an unusable threshold
160
+ * falls back to the default in both places, so the ticks always mark the boundaries the band is
161
+ * actually enforcing. Duplicates collapse, so `{warn: 90, critical: 90}` draws one tick, not two
162
+ * stacked ones.
163
+ */
164
+ export function ctxBarGeom(pct, thresholds) {
165
+ const norm = normalizeCtxThresholds(thresholds);
166
+ const seen = new Set();
167
+ const ticks = [];
168
+ for (const t of [norm.warn, norm.critical]) {
169
+ if (seen.has(t))
170
+ continue;
171
+ seen.add(t);
172
+ const x = Math.min(CTX_BAR_SPAN - CTX_BAR_TICK_WIDTH, Math.max(0, t - CTX_BAR_TICK_WIDTH / 2));
173
+ ticks.push({ pct: t, x, width: CTX_BAR_TICK_WIDTH });
174
+ }
175
+ ticks.sort((a, b) => a.pct - b.pct);
176
+ return { span: CTX_BAR_SPAN, height: CTX_BAR_HEIGHT, fill: ctxFillPct(pct), ticks };
177
+ }
178
+ /** The design card's context escalation: at warn/error the status line stops restating the
179
+ * lifecycle and names the thing that actually needs acting on. */
180
+ const CONTEXT_ESCALATION = {
181
+ warn: { key: "context-high", label: "context high", tone: "warn" },
182
+ error: { key: "context-critical", label: "context critical", tone: "error" },
183
+ };
184
+ /** Statuses the context escalation may speak over: a live session, or one whose lifecycle was
185
+ * never reported. A TERMINAL or TRANSIENT lifecycle claim (stopped/failed/spawning/stopping/
186
+ * paused) and a DOWN LINK are all more important than a hot context and keep the line. */
187
+ const ESCALATABLE_KEYS = new Set(["working", "idle", "active", "unknown"]);
188
+ const LIFECYCLE_STATUS = {
189
+ spawning: { label: "spawning…", tone: "info", pulse: true },
190
+ active: { label: "active", tone: "ok", pulse: false },
191
+ stopping: { label: "stopping…", tone: "warn", pulse: true },
192
+ stopped: { label: "stopped", tone: "muted", pulse: false },
193
+ failed: { label: "failed", tone: "error", pulse: false },
194
+ paused: { label: "paused", tone: "warn", pulse: false },
195
+ };
196
+ /** The honest "we were told nothing" status — distinct key AND distinct tone from `idle`. */
197
+ export const SESSION_STATUS_UNKNOWN = {
198
+ key: "unknown",
199
+ label: "unknown",
200
+ tone: "unknown",
201
+ pulse: false,
202
+ };
203
+ /**
204
+ * Derive the card's status. Precedence, and why:
205
+ *
206
+ * 1. A DOWN LINK on a session the product calls `active` — or on one whose lifecycle it does not
207
+ * report at all — wins: whatever else was claimed, we are not hearing from it. It does NOT
208
+ * override `spawning`/`stopping`/`stopped`/`failed`, whose own lifecycle already explains the
209
+ * missing link. The label is `"disconnected"`, not the design prototype's "reconnecting…":
210
+ * retry machinery is a product capability this atom cannot verify (a product that really does
211
+ * retry can pass its own `statusLabel`).
212
+ * 2. An ACTIVITY claim, but only for a session that is `active` or has no reported lifecycle —
213
+ * a "working" claim about a stopped session is a contradiction, and the lifecycle is the
214
+ * stronger truth.
215
+ * 3. The LIFECYCLE claim. This is where an active-but-no-activity session lands: `"active"`,
216
+ * never a fabricated `"idle"` or `"working"` (invariant 2).
217
+ * 4. Nothing reported at all ⇒ `"unknown"` (invariant 2).
218
+ */
219
+ export function sessionStatus(input, contextBand) {
220
+ const base = baseSessionStatus(input);
221
+ // The design card's warn/error states replace the status line with the context claim ("context
222
+ // high" / "context critical", in the band's hue). It is a claim about a REAL MEASUREMENT, so it
223
+ // never fabricates anything — and `unknown` is not a band, so an unmeasured session is never
224
+ // escalated (invariant 1). The underlying pulse rides along: a working session that goes hot is
225
+ // still working.
226
+ if ((contextBand === "warn" || contextBand === "error") && ESCALATABLE_KEYS.has(base.key)) {
227
+ const esc = CONTEXT_ESCALATION[contextBand];
228
+ return { key: esc.key, label: esc.label, tone: esc.tone, pulse: base.pulse };
229
+ }
230
+ return base;
231
+ }
232
+ function baseSessionStatus(input) {
233
+ const { lifecycle, activity, connected } = isObject(input) ? input : {};
234
+ if (connected === false && (lifecycle === "active" || lifecycle === undefined)) {
235
+ return { key: "disconnected", label: "disconnected", tone: "muted", pulse: true };
236
+ }
237
+ if (lifecycle === "active" || lifecycle === undefined) {
238
+ if (activity === "working")
239
+ return { key: "working", label: "working", tone: "ok", pulse: true };
240
+ if (activity === "idle")
241
+ return { key: "idle", label: "idle", tone: "muted", pulse: false };
242
+ }
243
+ if (lifecycle !== undefined) {
244
+ const base = LIFECYCLE_STATUS[lifecycle];
245
+ return { key: lifecycle, label: base.label, tone: base.tone, pulse: base.pulse };
246
+ }
247
+ return SESSION_STATUS_UNKNOWN;
248
+ }
249
+ /** Status class: tone modifier + the transient-pulse flag. The dot is a child element, so the
250
+ * modifier tints the words and the dot together. */
251
+ export function sessionStatusClass(status) {
252
+ return `my-session-card__status my-session-card__status--${status.tone}${status.pulse ? " is-pulse" : ""}`;
253
+ }
254
+ /**
255
+ * Whether the card wears the design's STALE treatment (dashed border, muted values) — true exactly
256
+ * when the link is down. Kept here rather than in the binding so both bindings, and any product
257
+ * that wants to pre-compute it, agree on one rule.
258
+ */
259
+ export function sessionCardIsStale(status) {
260
+ return status.key === "disconnected";
261
+ }
262
+ /**
263
+ * The card's stale treatment, including a product's own claim. A product may ADD staleness (its
264
+ * data is old for a reason the card cannot see); it may NOT take away the staleness a down link
265
+ * implies — `stale={false}` on a disconnected session would paint it as a live, solid-bordered
266
+ * card, which is exactly the lie this treatment exists to prevent.
267
+ */
268
+ export function sessionCardStale(status, productClaim) {
269
+ return sessionCardIsStale(status) || productClaim === true;
270
+ }
271
+ /** Every word the derivation reserves, mapped to the status key that earns it. An override may
272
+ * not borrow another key's word — that is how a product would launder a claim it never made. */
273
+ const RESERVED_STATUS_WORDS = new Map([
274
+ ["working", "working"],
275
+ ["idle", "idle"],
276
+ ["active", "active"],
277
+ ["spawning", "spawning"],
278
+ ["stopping", "stopping"],
279
+ ["stopped", "stopped"],
280
+ ["failed", "failed"],
281
+ ["paused", "paused"],
282
+ ["disconnected", "disconnected"],
283
+ ["unknown", "unknown"],
284
+ ["context high", "context-high"],
285
+ ["context critical", "context-critical"],
286
+ ]);
287
+ /** Fold a candidate word to the form the reserved map is keyed by (the derived labels carry a
288
+ * trailing ellipsis on the transient states). */
289
+ function normalizeStatusWord(word) {
290
+ return word.trim().toLowerCase().replace(/(…|\.{3})$/, "").trim();
291
+ }
292
+ /**
293
+ * The ACTIVITY vocabulary invariant 2 is about, guarded more tightly than the rest of the reserved
294
+ * words: these two are refused ANYWHERE in an override ("currently idle", "working now"), not just
295
+ * as the whole of it, unless the derivation itself produced that activity. Every other reserved
296
+ * word is an exact-match refusal — the atom polices the vocabulary the invariant names, and does
297
+ * not try to police arbitrary prose (a product determined to editorialize can always find other
298
+ * words; what it cannot do is state the specific claim the wire never made).
299
+ */
300
+ const ACTIVITY_WORD_PATTERNS = [
301
+ { key: "idle", re: /\bidle\b/i },
302
+ { key: "working", re: /\bworking\b/i },
303
+ ];
304
+ /**
305
+ * The words beside the dot. A product may substitute its own honest phrasing for the derived
306
+ * label — the tone, the pulse and the stale treatment still come from the derivation.
307
+ *
308
+ * Three refusals:
309
+ * · a BLANK override falls back to the derived label, rather than leaving the status as colour
310
+ * alone (token rule #7);
311
+ * · the `unknown` status ignores the override entirely. There is no alternative honest wording
312
+ * for "the product told us nothing" — nothing was claimed, so there is nothing to reword;
313
+ * · an override that states another status's claim is ignored. `statusLabel="idle"` — or
314
+ * `"currently idle"` — on a session whose activity was never reported would state a claim no
315
+ * wire made; invariant 2 is structural here, not a matter of trusting the caller. The two
316
+ * ACTIVITY words are refused anywhere in the override; every other reserved word is refused
317
+ * only as the whole of it. Rewording is still free: anything outside that vocabulary
318
+ * (`"wake unavailable"`, `"stopped (exit 1)"`, …) passes.
319
+ *
320
+ * Documented limit: a status the context escalation has taken over (`context high` /
321
+ * `context critical`) can no longer use an activity word either, even if the wire did report one —
322
+ * the rendered status is the context claim, and the product can word it another way.
323
+ */
324
+ export function sessionStatusText(status, override) {
325
+ if (status.key === "unknown")
326
+ return status.label;
327
+ if (typeof override !== "string" || override.trim().length === 0)
328
+ return status.label;
329
+ const reserved = RESERVED_STATUS_WORDS.get(normalizeStatusWord(override));
330
+ if (reserved !== undefined && reserved !== status.key)
331
+ return status.label;
332
+ for (const { key, re } of ACTIVITY_WORD_PATTERNS) {
333
+ if (status.key !== key && re.test(override))
334
+ return status.label;
335
+ }
336
+ return override;
337
+ }
338
+ // ════════════════════════════════════════════════════════════════════════════════════════
339
+ // card container, identity line, subline
340
+ // ════════════════════════════════════════════════════════════════════════════════════════
341
+ /** Container class — the WHOLE class attribute the card's root carries, including any extra
342
+ * classes a product passes, so neither binding composes a class string of its own. `selected` and
343
+ * `stale` are INDEPENDENT: a selected session can also be disconnected, so they are two
344
+ * modifiers, never one enum. */
345
+ export function sessionCardClass(input) {
346
+ const i = isObject(input) ? input : {};
347
+ const extra = typeof i.extra === "string" ? i.extra.trim() : "";
348
+ return (`my-session-card${i.selected === true ? " is-selected" : ""}${i.stale === true ? " is-stale" : ""}` +
349
+ (extra.length > 0 ? ` ${extra}` : ""));
350
+ }
351
+ /** What an unnameable session's avatar shows — a question mark, not a fabricated initial. */
352
+ export const SESSION_AVATAR_UNKNOWN = "?";
353
+ /** Avatar initial: the first alphanumeric of the display name, uppercased; `"?"` when there is
354
+ * none. (Merges the two divergent copies the product carried — the rail card's "first char,
355
+ * em-dash fallback" and derive.ts's "first alphanumeric, ?-fallback" — on the latter, which
356
+ * cannot emit punctuation or whitespace as an initial.) */
357
+ export function sessionAvatarInitial(name) {
358
+ const m = String(name ?? "").match(/[a-z0-9]/i);
359
+ return m ? m[0].toUpperCase() : SESSION_AVATAR_UNKNOWN;
360
+ }
361
+ /**
362
+ * The card's meta subline — `role · model · duration` on the design card, but deliberately a free
363
+ * list: the spec's own states also use it for `role · model · queued: 1 ON-DONE` and
364
+ * `role · last seen 00:12 ago`. Absent/blank parts COLLAPSE, so there is never a dangling
365
+ * separator, and an all-absent list yields `""` (the binding then omits the element entirely
366
+ * rather than rendering an empty row).
367
+ */
368
+ export function sessionSubline(parts) {
369
+ return (Array.isArray(parts) ? parts : [])
370
+ .map((p) => (typeof p === "string" ? p.trim() : ""))
371
+ .filter((p) => p.length > 0)
372
+ .join(SESSION_CARD_SEP);
373
+ }
374
+ /**
375
+ * The most nodes the strip will draw. The strip is a fixed-width row of 13px nodes, so beyond this
376
+ * they cannot be told apart anyway — and a runaway count from a malformed wire value must not be
377
+ * able to build an unbounded node list and lock the render. The LABEL beside the strip always
378
+ * states the TRUE count, so the bound costs no honesty: the strip is a bounded illustration, the
379
+ * number is the claim.
380
+ */
381
+ export const SPINE_MAX_NODES = 24;
382
+ /**
383
+ * Nodes for the strip: one filled node per completed distill, plus the hollow live tip.
384
+ * An UNREPORTED `distills` yields `[]` — no strip, no fabricated "0 distills" (invariant 1). A
385
+ * REPORTED 0 is a real reading and yields the tip alone. Non-integer/negative counts are floored,
386
+ * and the list is capped at `SPINE_MAX_NODES` so a runaway value cannot lock the render (the
387
+ * strip's label still states the true count).
388
+ */
389
+ export function sessionSpineNodes(distills) {
390
+ if (!isReading(distills))
391
+ return [];
392
+ const n = Math.min(SPINE_MAX_NODES - 1, Math.max(0, Math.floor(distills)));
393
+ const nodes = [];
394
+ for (let i = 0; i < n; i++)
395
+ nodes.push({ tip: false });
396
+ nodes.push({ tip: true });
397
+ return nodes;
398
+ }
399
+ /** Compact token count: `41.2k`, `1.3M`, `412`. */
400
+ function compactTokens(n) {
401
+ if (n >= 1_000_000)
402
+ return `${(n / 1_000_000).toFixed(1)}M`;
403
+ if (n >= 1_000)
404
+ return `${(n / 1_000).toFixed(1)}k`;
405
+ return `${Math.round(n)}`;
406
+ }
407
+ /** What an unreported token saving renders as. */
408
+ export const SPINE_SAVED_UNKNOWN = CTX_UNKNOWN_TEXT;
409
+ /**
410
+ * The strip's right-hand value. Absent ⇒ `"—"` (invariant 1). A saving is a REDUCTION, so it
411
+ * carries a real minus sign (U+2212); a reported 0 is unsigned; a negative input (context grew)
412
+ * is stated as growth rather than silently re-signed.
413
+ */
414
+ export function sessionSpineSavedText(savedTok) {
415
+ if (!isReading(savedTok))
416
+ return SPINE_SAVED_UNKNOWN;
417
+ if (savedTok === 0)
418
+ return "0 tok";
419
+ return `${savedTok > 0 ? "−" : "+"}${compactTokens(Math.abs(savedTok))} tok`;
420
+ }
421
+ /** The strip's left-hand label: `spine · 3 distills` (singular at 1). */
422
+ export function sessionSpineLabel(distills) {
423
+ const n = Math.max(0, Math.floor(distills));
424
+ return `spine${SESSION_CARD_SEP}${n} ${n === 1 ? "distill" : "distills"}`;
425
+ }
426
+ /**
427
+ * The whole strip, or `undefined` when the product reported no distill count — the card then
428
+ * renders no strip at all. `spine.savedTok` is independent: a reported distill count with an
429
+ * unreported saving still draws the strip, with `"—"` for the value.
430
+ */
431
+ export function sessionSpineSummary(spine) {
432
+ if (!spine || !isReading(spine.distills))
433
+ return undefined;
434
+ return {
435
+ nodes: sessionSpineNodes(spine.distills),
436
+ label: sessionSpineLabel(spine.distills),
437
+ value: sessionSpineSavedText(spine.savedTok),
438
+ };
439
+ }
440
+ /** Class for one spine node — filled distill vs. the hollow live tip. */
441
+ export function spineNodeClass(node) {
442
+ return `my-session-card__spine-node${node.tip ? " my-session-card__spine-node--tip" : ""}`;
443
+ }
@@ -0,0 +1,49 @@
1
+ /** Tile tone. `accent` is the design card's "brag" number (`.tile.save .v`); `warn`/`error` are the
2
+ * gauge-band tint. Absent ⇒ a plain tile. */
3
+ export type StatTileTone = "accent" | "warn" | "error";
4
+ export interface StatTile {
5
+ /** Uppercase micro key, e.g. `tokens in`. */
6
+ label: string;
7
+ /** The already-formatted value — use the formatters below, or supply your own string. */
8
+ value: string;
9
+ /** Mono sub-caption under the value, e.g. `this session`. */
10
+ sub?: string;
11
+ tone?: StatTileTone;
12
+ }
13
+ /** The value shown when a number is absent or not finite. An em dash — NEVER a zero. */
14
+ export declare const STAT_TILE_EMPTY = "\u2014";
15
+ /** Unicode minus (U+2212), not an ASCII hyphen: it matches the digit metrics of the tabular-nums
16
+ * mono face the tile value is set in. */
17
+ export declare const STAT_TILE_MINUS = "\u2212";
18
+ /** Every class the row renders, containers and elements — the bindings import these rather than
19
+ * spelling the strings themselves, so the two frameworks cannot drift apart on a rename. */
20
+ export declare const STAT_TILE_PARTS: {
21
+ readonly row: "my-stat-tiles";
22
+ readonly tile: "my-stat-tile";
23
+ readonly label: "my-stat-tile__label";
24
+ readonly value: "my-stat-tile__value";
25
+ readonly sub: "my-stat-tile__sub";
26
+ };
27
+ /** Row container class. */
28
+ export declare function statTilesClass(): string;
29
+ /** Tile class: base + optional tone modifier. */
30
+ export declare function statTileClass(tone?: StatTileTone): string;
31
+ /** Compact count: `500`, `12.8k`, `230k`, `1.5M`; negatives take the Unicode minus (`−212k`).
32
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`.
33
+ *
34
+ * The unit is chosen from the ROUNDED value, not the raw one: rounding first and picking the unit
35
+ * second would render `999.5` as `1000` and `999_999` as `1000k` — values that are noncanonical
36
+ * for the scale and sit right in the middle of a normal token count. Each rung promotes to the
37
+ * next when its own rounding carries it to 1000. `M` is the top rung — the design card defines no
38
+ * unit above it, so a billion reads `1000M`, not `1B`. */
39
+ export declare function formatStatCompact(n: number | null | undefined): string;
40
+ /** Grouped count: `1,204,551` — the design card's tokens-in/out format. Deliberately NOT
41
+ * `toLocaleString()`: the card's grouping is a fixed visual (comma every three digits, mono
42
+ * tabular-nums), and a host locale must not silently turn it into `1.204.551`.
43
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
44
+ export declare function formatStatCount(n: number | null | undefined): string;
45
+ /** USD: `$4.18`, always two decimals. Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
46
+ export declare function formatStatUsd(n: number | null | undefined): string;
47
+ /** Percent: `71%` by default, `94.5%` at `digits: 1` — the design card shows both forms.
48
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
49
+ export declare function formatStatPercent(n: number | null | undefined, digits?: number): string;
@@ -0,0 +1,102 @@
1
+ // @mythicalos/ui-core — the stat-tile row's tone→class derivation + the value formatters
2
+ // (ds/components-stat-tiles.html: a flex-wrap row of bordered tiles, each an uppercase micro
3
+ // "key", a big mono tabular-nums value, and a mono sub-caption; the "brag" tile paints its value
4
+ // --my-accent-strong).
5
+ //
6
+ // Extracted from the reference implementation in the reference product's session detail pane. That
7
+ // version took the product's `SessionVm` and hard-coded the five session tiles (tokens in/out,
8
+ // prompt cache, cost, spine savings) INSIDE the component. That composition is the product's
9
+ // domain, not the design system's: the atom here takes an already-composed `StatTile[]`, so a
10
+ // consumer's view-model decides which tiles exist and in what order.
11
+ //
12
+ // What DID come along, because it is design and not product:
13
+ // - the "—" placeholder: an absent value renders the em dash, never a fabricated `0` / `$0.00`
14
+ // (partial data renders partially — never all-or-nothing),
15
+ // - the Unicode minus (U+2212, not a hyphen) on negative compact counts — the design card's
16
+ // spine-savings tile literally reads `−212k`,
17
+ // - the warn/error tone tint, which the reference drove off the context gauge's own 75/90 bands
18
+ // (token rule #4) — the thresholds themselves stay with the caller, only the tint is here.
19
+ /** The value shown when a number is absent or not finite. An em dash — NEVER a zero. */
20
+ export const STAT_TILE_EMPTY = "—";
21
+ /** Unicode minus (U+2212), not an ASCII hyphen: it matches the digit metrics of the tabular-nums
22
+ * mono face the tile value is set in. */
23
+ export const STAT_TILE_MINUS = "−";
24
+ /** Every class the row renders, containers and elements — the bindings import these rather than
25
+ * spelling the strings themselves, so the two frameworks cannot drift apart on a rename. */
26
+ export const STAT_TILE_PARTS = {
27
+ row: "my-stat-tiles",
28
+ tile: "my-stat-tile",
29
+ label: "my-stat-tile__label",
30
+ value: "my-stat-tile__value",
31
+ sub: "my-stat-tile__sub",
32
+ };
33
+ /** Row container class. */
34
+ export function statTilesClass() {
35
+ return STAT_TILE_PARTS.row;
36
+ }
37
+ /** Tile class: base + optional tone modifier. */
38
+ export function statTileClass(tone) {
39
+ const base = STAT_TILE_PARTS.tile;
40
+ return tone === undefined ? base : `${base} ${base}--${tone}`;
41
+ }
42
+ /** True only for a real, finite number — `undefined`, `null` and `NaN`/`Infinity` all fall through
43
+ * to `STAT_TILE_EMPTY` rather than being coerced. */
44
+ function isNum(n) {
45
+ return typeof n === "number" && Number.isFinite(n);
46
+ }
47
+ /** Strip a trailing `.0` so `230.0` → `230` while `12.8` stays `12.8`. */
48
+ function stripZero(s) {
49
+ return s.endsWith(".0") ? s.slice(0, -2) : s;
50
+ }
51
+ /** Sign prefix for an already-rounded magnitude string. A negative input whose ROUNDED magnitude is
52
+ * zero gets no sign — `−0` / `−$0.00` would be a lie about the direction of a value that landed on
53
+ * zero at display precision. */
54
+ function signed(n, body, zero) {
55
+ return n < 0 && body !== zero ? STAT_TILE_MINUS + body : body;
56
+ }
57
+ /** Compact count: `500`, `12.8k`, `230k`, `1.5M`; negatives take the Unicode minus (`−212k`).
58
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`.
59
+ *
60
+ * The unit is chosen from the ROUNDED value, not the raw one: rounding first and picking the unit
61
+ * second would render `999.5` as `1000` and `999_999` as `1000k` — values that are noncanonical
62
+ * for the scale and sit right in the middle of a normal token count. Each rung promotes to the
63
+ * next when its own rounding carries it to 1000. `M` is the top rung — the design card defines no
64
+ * unit above it, so a billion reads `1000M`, not `1B`. */
65
+ export function formatStatCompact(n) {
66
+ if (!isNum(n))
67
+ return STAT_TILE_EMPTY;
68
+ const abs = Math.abs(n);
69
+ let body;
70
+ if (Math.round(abs) < 1000)
71
+ body = String(Math.round(abs));
72
+ else if (Number((abs / 1000).toFixed(1)) < 1000)
73
+ body = `${stripZero((abs / 1000).toFixed(1))}k`;
74
+ else
75
+ body = `${stripZero((abs / 1_000_000).toFixed(1))}M`;
76
+ return signed(n, body, "0");
77
+ }
78
+ /** Grouped count: `1,204,551` — the design card's tokens-in/out format. Deliberately NOT
79
+ * `toLocaleString()`: the card's grouping is a fixed visual (comma every three digits, mono
80
+ * tabular-nums), and a host locale must not silently turn it into `1.204.551`.
81
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
82
+ export function formatStatCount(n) {
83
+ if (!isNum(n))
84
+ return STAT_TILE_EMPTY;
85
+ const grouped = String(Math.round(Math.abs(n))).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
86
+ return signed(n, grouped, "0");
87
+ }
88
+ /** USD: `$4.18`, always two decimals. Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
89
+ export function formatStatUsd(n) {
90
+ if (!isNum(n))
91
+ return STAT_TILE_EMPTY;
92
+ return signed(n, `$${Math.abs(n).toFixed(2)}`, "$0.00");
93
+ }
94
+ /** Percent: `71%` by default, `94.5%` at `digits: 1` — the design card shows both forms.
95
+ * Absent/non-finite ⇒ `STAT_TILE_EMPTY`. */
96
+ export function formatStatPercent(n, digits = 0) {
97
+ if (!isNum(n))
98
+ return STAT_TILE_EMPTY;
99
+ const safeDigits = Math.max(0, Math.min(4, Math.trunc(digits)));
100
+ const body = `${Math.abs(n).toFixed(safeDigits)}%`;
101
+ return signed(n, body, `${(0).toFixed(safeDigits)}%`);
102
+ }