eidotter 0.39.0 → 0.40.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 (72) hide show
  1. package/README.md +19 -2
  2. package/dist/components/DecayStrip/components/DecayStrip.d.ts +60 -0
  3. package/dist/components/DecayStrip/components/DecayStrip.js +99 -0
  4. package/dist/components/DecayStrip/components/index.d.ts +3 -0
  5. package/dist/components/DecayStrip/components/index.js +1 -0
  6. package/dist/components/DecayStrip/components/resample.d.ts +34 -0
  7. package/dist/components/DecayStrip/components/resample.js +44 -0
  8. package/dist/components/DecayStrip/components/resample.test.d.ts +1 -0
  9. package/dist/components/DecayStrip/index.d.ts +2 -0
  10. package/dist/components/DecayStrip/index.js +1 -0
  11. package/dist/components/Gauge/components/Gauge.d.ts +43 -0
  12. package/dist/components/Gauge/components/Gauge.js +70 -0
  13. package/dist/components/Gauge/components/index.d.ts +2 -0
  14. package/dist/components/Gauge/components/index.js +1 -0
  15. package/dist/components/Gauge/index.d.ts +2 -0
  16. package/dist/components/Gauge/index.js +1 -0
  17. package/dist/components/InlineExpand/components/InlineExpand.d.ts +5 -3
  18. package/dist/components/InlineExpand/components/InlineExpand.js +15 -13
  19. package/dist/components/Meter/components/Meter.d.ts +45 -0
  20. package/dist/components/Meter/components/Meter.js +79 -0
  21. package/dist/components/Meter/components/MeterGroup.d.ts +26 -0
  22. package/dist/components/Meter/components/MeterGroup.js +28 -0
  23. package/dist/components/Meter/components/index.d.ts +4 -0
  24. package/dist/components/Meter/components/index.js +2 -0
  25. package/dist/components/Meter/index.d.ts +2 -0
  26. package/dist/components/Meter/index.js +1 -0
  27. package/dist/components/ProgressRing/components/ProgressRing.d.ts +5 -0
  28. package/dist/components/ProgressRing/components/ProgressRing.js +10 -2
  29. package/dist/components/RangeBar/components/RangeBar.d.ts +50 -0
  30. package/dist/components/RangeBar/components/RangeBar.js +90 -0
  31. package/dist/components/RangeBar/components/index.d.ts +2 -0
  32. package/dist/components/RangeBar/components/index.js +1 -0
  33. package/dist/components/RangeBar/index.d.ts +2 -0
  34. package/dist/components/RangeBar/index.js +1 -0
  35. package/dist/components/SeriesBars/components/SeriesBars.d.ts +43 -0
  36. package/dist/components/SeriesBars/components/SeriesBars.js +64 -0
  37. package/dist/components/SeriesBars/components/index.d.ts +2 -0
  38. package/dist/components/SeriesBars/components/index.js +1 -0
  39. package/dist/components/SeriesBars/index.d.ts +2 -0
  40. package/dist/components/SeriesBars/index.js +1 -0
  41. package/dist/components/Stat/components/Stat.d.ts +6 -0
  42. package/dist/components/Stat/components/Stat.js +2 -2
  43. package/dist/components/VitalsStrip/components/VitalsStrip.d.ts +40 -0
  44. package/dist/components/VitalsStrip/components/VitalsStrip.js +73 -0
  45. package/dist/components/VitalsStrip/components/index.d.ts +2 -0
  46. package/dist/components/VitalsStrip/components/index.js +1 -0
  47. package/dist/components/VitalsStrip/index.d.ts +2 -0
  48. package/dist/components/VitalsStrip/index.js +1 -0
  49. package/dist/components/registry.d.ts +1 -1
  50. package/dist/components/registry.js +183 -0
  51. package/dist/eidotter.css +1 -1
  52. package/dist/index.d.ts +14 -1
  53. package/dist/index.es.js +3064 -1800
  54. package/dist/index.umd.js +1 -1
  55. package/dist/utils/health/formatValue.d.ts +29 -0
  56. package/dist/utils/health/formatValue.js +46 -0
  57. package/dist/utils/health/formatValue.test.d.ts +1 -0
  58. package/dist/utils/health/healthPatterns.test.d.ts +1 -0
  59. package/dist/utils/health/index.d.ts +6 -0
  60. package/dist/utils/health/index.js +3 -0
  61. package/dist/utils/health/labels.d.ts +6 -0
  62. package/dist/utils/health/labels.js +18 -0
  63. package/dist/utils/health/resolveBand.d.ts +12 -0
  64. package/dist/utils/health/resolveBand.js +29 -0
  65. package/dist/utils/health/resolveBand.test.d.ts +1 -0
  66. package/dist/utils/health/types.d.ts +25 -0
  67. package/dist/utils/health/types.js +2 -0
  68. package/guidelines/README.md +1 -0
  69. package/guidelines/components.md +25 -0
  70. package/guidelines/health-metabolism-patterns.md +212 -0
  71. package/llms.txt +2 -2
  72. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  [![license](https://img.shields.io/badge/license-CC--BY--NC--4.0-ffb000?labelColor=020003)](./LICENSE.md)
16
16
  [![React](https://img.shields.io/badge/React-18%20%7C%2019-ffb000?labelColor=020003)](https://react.dev/)
17
17
 
18
- **eiDotter** is a React component library that takes the DOS/CGA terminal aesthetic seriously: authentic 16-color palette, bitmap-accurate typography, phosphor glow physics, keyboard-first interactions. 40 components across forms, navigation, windowing, timelines, and terminal chrome — shipped as ES modules with a prebuilt stylesheet so consumers don't need Tailwind to use it.
18
+ **eiDotter** is a React component library that takes the DOS/CGA terminal aesthetic seriously: authentic 16-color palette, bitmap-accurate typography, phosphor glow physics, keyboard-first interactions. 47 components across forms, navigation, windowing, timelines, health displays, and terminal chrome — shipped as ES modules with a prebuilt stylesheet so consumers don't need Tailwind to use it.
19
19
 
20
20
  → **[Storybook](https://cinimody.github.io/eiDotter/)**  ·  **[CHANGELOG](./CHANGELOG.md)**  ·  **[Accessibility](./ACCESSIBILITY.md)**  ·  **[Issues](https://github.com/CinimoDY/eiDotter/issues)**  ·  **[npm](https://www.npmjs.com/package/eidotter)**
21
21
 
@@ -111,7 +111,7 @@ for client apps and `'use client'` files.
111
111
 
112
112
  ## Components
113
113
 
114
- 40 components across five families — [complete catalog in Storybook](https://cinimody.github.io/eiDotter/).
114
+ 47 components across six families — [complete catalog in Storybook](https://cinimody.github.io/eiDotter/).
115
115
 
116
116
  <details>
117
117
  <summary><strong>Windowing &amp; shell</strong> — Terminal, Modal, Lightbox, CommandPrompt, CmdPalette, Header, Footer, Nav, Brand</summary>
@@ -130,6 +130,23 @@ for client apps and `'use client'` files.
130
130
 
131
131
  </details>
132
132
 
133
+ <details>
134
+ <summary><strong>Health &amp; metabolism</strong> — Gauge, Meter, MeterGroup, RangeBar, SeriesBars, DecayStrip, VitalsStrip</summary>
135
+
136
+ | Component | Purpose |
137
+ |---|---|
138
+ | `Gauge` | Quantised ring with a dominant percent, secondary value, tone, status, provenance tag and overflow marker |
139
+ | `Meter` | Single fill bar (vertical / horizontal) with an optimal tick or range and pattern fills |
140
+ | `MeterGroup` | Row or grid of Meters with an optional shared scale |
141
+ | `RangeBar` | Stacked range bar of ordered segments with legend and target tick (time-in-range style) |
142
+ | `SeriesBars` | Short bar series with a goal line and consumer-supplied threshold tones |
143
+ | `DecayStrip` | On-board decay over time from consumer-supplied samples — now marker, dimmed future, provenance required |
144
+ | `VitalsStrip` | Monitor-style vitals tiles with per-tile tone, status and paired values |
145
+
146
+ All six are presentational and server-safe; bands, targets and clinical copy are consumer props (no medical defaults), and tone is never the only signal.
147
+
148
+ </details>
149
+
133
150
  <details>
134
151
  <summary><strong>Content surfaces</strong> — Card, Accordion, Alert, Badge, Tag, Notification, Stat, Progress, Skeleton, Separator, Breadcrumb, LegalPage</summary>
135
152
 
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import type { HealthLabels, Provenance, Tone } from '../../../utils/health/index.js';
3
+ import type { DecaySample, TimeInput } from './resample.js';
4
+ export interface DecayStripItem {
5
+ /** Stable key for the row. */
6
+ id: string;
7
+ label: string;
8
+ /** Starting amount, e.g. 40. `null` renders as unknown. */
9
+ amount: number | null;
10
+ unit: string;
11
+ /** Share still active, 0..1. `null`, `NaN` and `Infinity` render as unknown. */
12
+ remainingShare: number | null;
13
+ /** Required: where the share comes from. */
14
+ provenance: Provenance;
15
+ }
16
+ export interface DecayStripProps {
17
+ /** What is still active. An empty list renders EmptyState; the strip still renders. */
18
+ items: DecayStripItem[];
19
+ /**
20
+ * Consumer-computed decay curve. `t` is epoch ms or an ISO string;
21
+ * `remaining` is a 0..1 share. eidotter does no absorption math (KTD5).
22
+ */
23
+ samples: DecaySample[];
24
+ /** Current time, epoch ms or ISO string. Pass it in; the strip never reads the clock. */
25
+ now: TimeInput;
26
+ /** Time span the strip covers. */
27
+ window: {
28
+ from: TimeInput;
29
+ to: TimeInput;
30
+ };
31
+ /** Required: where the samples come from. */
32
+ provenance: Provenance;
33
+ /** Size variant. `sm` renders items as a text list, `md` and `lg` as small Gauges. */
34
+ size?: 'sm' | 'md' | 'lg';
35
+ /** Tone of the strip and the item Gauges. */
36
+ tone?: Tone;
37
+ /** Number of strip cells (default 24). */
38
+ columns?: number;
39
+ /** BCP 47 locale for numbers and times (default `en`). */
40
+ locale?: string;
41
+ /**
42
+ * IANA time zone for the from, to and now labels (e.g. `Europe/Berlin`).
43
+ * Defaults to the runtime zone, which on a server render is the server's.
44
+ */
45
+ timeZone?: string;
46
+ /** Overrides for the built-in label text. */
47
+ labels?: Partial<HealthLabels>;
48
+ /** Opt in to a polite live region and the now-marker pulse (R14). */
49
+ announce?: boolean;
50
+ /** Additional CSS class name. */
51
+ className?: string;
52
+ }
53
+ /**
54
+ * Horizontal time strip of what is still active (a meal, a dose): past cells
55
+ * as stepped block glyphs scaled by the consumer's `remaining` samples, future
56
+ * cells dimmed, and a now marker. Item rows sit above the strip.
57
+ * The strip is `role="img"` with a composed summary, so the glyphs are never
58
+ * the only reading. Presentational and hook-free, so it stays server-safe.
59
+ */
60
+ export declare const DecayStrip: React.ForwardRefExoticComponent<DecayStripProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,99 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cn } from '../../../utils/cn.js';
4
+ import { Gauge } from '../../Gauge/index.js';
5
+ import { EmptyState } from '../../EmptyState/index.js';
6
+ import { formatValue, isKnown, PLACEHOLDER, provenanceLabel, resolveLabels, } from '../../../utils/health/index.js';
7
+ import { normaliseSamples, resample, toMs } from './resample.js';
8
+ const sizeClasses = {
9
+ sm: 'eidotter-decay-strip--sm text-dos-text-xs',
10
+ md: 'eidotter-decay-strip--md text-dos-text-sm',
11
+ lg: 'eidotter-decay-strip--lg text-dos-text-md',
12
+ };
13
+ const toneClasses = {
14
+ neutral: 'eidotter-tone--neutral',
15
+ info: 'eidotter-tone--info',
16
+ success: 'eidotter-tone--success',
17
+ warning: 'eidotter-tone--warning',
18
+ critical: 'eidotter-tone--critical',
19
+ };
20
+ /** Progress glyph ladder, empty to full. */
21
+ const LADDER = ['░', '▒', '▓', '█'];
22
+ const MARKER = '│';
23
+ const NO_DATA = '·';
24
+ function glyph(value) {
25
+ if (value === null)
26
+ return NO_DATA;
27
+ const v = Math.min(1, Math.max(0, value));
28
+ return LADDER[Math.round(v * (LADDER.length - 1))];
29
+ }
30
+ /**
31
+ * Time from `now` until the curve reaches zero for good (the first zero
32
+ * sample after the last positive one), as locale text. Null when the curve
33
+ * never reaches zero or is already at zero before `now`.
34
+ */
35
+ function timeToZero(samples, now, locale) {
36
+ const points = normaliseSamples(samples);
37
+ let lastPositive = -1;
38
+ points.forEach((p, i) => {
39
+ if (p.v > 0)
40
+ lastPositive = i;
41
+ });
42
+ const zero = points.slice(lastPositive + 1).find((p) => p.v <= 0);
43
+ if (!zero || !Number.isFinite(now) || zero.t < now)
44
+ return null;
45
+ const minutes = (zero.t - now) / 60000;
46
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'always' });
47
+ return minutes < 60
48
+ ? rtf.format(Math.round(minutes), 'minute')
49
+ : rtf.format(Math.round(minutes / 6) / 10, 'hour');
50
+ }
51
+ /**
52
+ * Horizontal time strip of what is still active (a meal, a dose): past cells
53
+ * as stepped block glyphs scaled by the consumer's `remaining` samples, future
54
+ * cells dimmed, and a now marker. Item rows sit above the strip.
55
+ * The strip is `role="img"` with a composed summary, so the glyphs are never
56
+ * the only reading. Presentational and hook-free, so it stays server-safe.
57
+ */
58
+ export const DecayStrip = forwardRef(({ items, samples, now, window, provenance, size = 'md', tone = 'neutral', columns = 24, locale = 'en', timeZone, labels: labelOverrides, announce = false, className, ...props }, ref) => {
59
+ const labels = resolveLabels(labelOverrides);
60
+ const nowMs = toMs(now);
61
+ const cells = resample(samples, window, columns);
62
+ const time = new Intl.DateTimeFormat(locale, {
63
+ timeStyle: 'short',
64
+ ...(timeZone && { timeZone }),
65
+ });
66
+ const formatTime = (ms) => (Number.isFinite(ms) ? time.format(ms) : PLACEHOLDER);
67
+ const fromText = formatTime(toMs(window.from));
68
+ const toText = formatTime(toMs(window.to));
69
+ const tag = (p) => provenanceLabel(labels, p);
70
+ const zeroPercent = formatValue(0, { locale, percent: true }).text;
71
+ const rows = items.map((item) => {
72
+ const amountText = formatValue(item.amount, { locale, unit: item.unit }).text;
73
+ const share = isKnown(item.remainingShare) ? item.remainingShare : null;
74
+ const shareText = share === null
75
+ ? labels.unknown
76
+ : `${formatValue(share * 100, { locale, percent: true }).text} ${labels.remaining}`;
77
+ return { item, amountText, share, shareText, tagText: tag(item.provenance) };
78
+ });
79
+ const zeroText = timeToZero(samples, nowMs, locale);
80
+ const stripTag = tag(provenance);
81
+ const summary = [
82
+ ...rows.map((r) => `${r.item.label} ${r.amountText}: ${r.shareText}${r.tagText ? `, ${r.tagText}` : ''}`),
83
+ `${fromText}–${toText}`,
84
+ Number.isFinite(nowMs) && `${labels.now} ${time.format(nowMs)}`,
85
+ zeroText && `${zeroPercent} ${zeroText}`,
86
+ stripTag,
87
+ ]
88
+ .filter(Boolean)
89
+ .join('; ');
90
+ const compact = size === 'sm';
91
+ return (_jsxs("div", { ref: ref, className: cn('eidotter-decay-strip eidotter-health flex flex-col gap-2 w-full min-w-0 font-dos text-dos-text-brand', sizeClasses[size] ?? sizeClasses.md, toneClasses[tone], className), ...props, children: [items.length === 0 ? (_jsx(EmptyState, { title: `${zeroPercent} ${labels.remaining}`, className: "py-2" })) : (_jsx("ul", { className: cn('flex gap-2 min-w-0', compact ? 'flex-col' : 'flex-wrap'), children: rows.map((r) => (_jsx("li", { className: cn('eidotter-decay-strip__item min-w-0', compact ? 'flex flex-wrap items-baseline gap-2 tabular-nums' : 'flex-1'), children: compact ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "truncate text-dos-text-accent", title: r.item.label, children: r.item.label }), _jsx("span", { children: r.amountText }), _jsx("span", { children: r.shareText }), r.tagText && (_jsx("span", { className: "eidotter-decay-strip__item-provenance border border-dos-border-brand px-1", children: r.tagText }))] })) : (_jsx(Gauge, { label: r.item.label, value: r.share === null ? null : r.share * 100, secondary: r.item.amount === null ? undefined : r.amountText, size: "sm", tone: tone, provenance: r.item.provenance, locale: locale, labels: labelOverrides })) }, r.item.id))) })), _jsxs("div", { className: "eidotter-decay-strip__strip flex items-center gap-2 min-w-0 tabular-nums", role: "img", "aria-label": summary, children: [_jsx("span", { className: "eidotter-decay-strip__from shrink-0", children: fromText }), _jsx("span", { className: "eidotter-decay-strip__cells flex-1 min-w-0 overflow-hidden whitespace-nowrap leading-none", style: tone === 'neutral' ? undefined : { color: 'var(--eidotter-fill)' }, children: cells.map((cell, i) => {
92
+ const last = i === cells.length - 1;
93
+ const isNow = nowMs >= cell.start && (nowMs < cell.end || (last && nowMs === cell.end));
94
+ const future = !isNow && nowMs < cell.start && nowMs >= cells[0].start;
95
+ const state = isNow ? 'now' : future ? 'future' : 'past';
96
+ return (_jsx("span", { className: cn('eidotter-decay-strip__cell', `eidotter-decay-strip__cell--${state}`, future && 'opacity-50', isNow && 'text-dos-text-accent', isNow && announce && 'eidotter-health-pulse'), children: isNow ? MARKER : glyph(cell.value) }, i));
97
+ }) }), _jsx("span", { className: "eidotter-decay-strip__to shrink-0", children: toText })] }), stripTag && (_jsx("span", { className: "eidotter-decay-strip__provenance self-start border border-dos-border-brand px-1", children: stripTag })), announce && (_jsx("p", { className: "sr-only", "aria-live": "polite", children: summary }))] }));
98
+ });
99
+ DecayStrip.displayName = 'DecayStrip';
@@ -0,0 +1,3 @@
1
+ export { DecayStrip } from './DecayStrip.js';
2
+ export type { DecayStripProps, DecayStripItem } from './DecayStrip.js';
3
+ export type { DecaySample, TimeInput } from './resample.js';
@@ -0,0 +1 @@
1
+ export { DecayStrip } from './DecayStrip.js';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Pure resampling for DecayStrip. No absorption or insulin math lives here
3
+ * (KTD5): the consumer supplies `remaining` samples and this only bins them.
4
+ */
5
+ /** Epoch milliseconds, or an ISO 8601 string. */
6
+ export type TimeInput = number | string;
7
+ export interface DecaySample {
8
+ t: TimeInput;
9
+ /** Share still active, 0..1 (0 = gone, 1 = all of it). */
10
+ remaining: number;
11
+ }
12
+ export interface DecayCell {
13
+ /** Cell start, epoch ms, inclusive. */
14
+ start: number;
15
+ /** Cell end, epoch ms, exclusive (the last cell includes the window end). */
16
+ end: number;
17
+ /** Mean `remaining` of the samples in the span, the nearest earlier sample when empty, or null. */
18
+ value: number | null;
19
+ }
20
+ export declare function toMs(t: TimeInput): number;
21
+ /** Finite, time-sorted `{t, v}` points (stable for equal times); non-finite samples are dropped. */
22
+ export declare function normaliseSamples(samples: DecaySample[]): {
23
+ t: number;
24
+ v: number;
25
+ }[];
26
+ /**
27
+ * Split `window` into `columns` equal cells. A cell's value is the mean of the
28
+ * samples whose time falls in its span; an empty cell carries the value of
29
+ * the nearest earlier sample, or null when no earlier sample exists.
30
+ */
31
+ export declare function resample(samples: DecaySample[], window: {
32
+ from: TimeInput;
33
+ to: TimeInput;
34
+ }, columns: number): DecayCell[];
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pure resampling for DecayStrip. No absorption or insulin math lives here
3
+ * (KTD5): the consumer supplies `remaining` samples and this only bins them.
4
+ */
5
+ export function toMs(t) {
6
+ return typeof t === 'number' ? t : Date.parse(t);
7
+ }
8
+ /** Finite, time-sorted `{t, v}` points (stable for equal times); non-finite samples are dropped. */
9
+ export function normaliseSamples(samples) {
10
+ return samples
11
+ .map((s) => ({ t: toMs(s.t), v: s.remaining }))
12
+ .filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v))
13
+ .sort((a, b) => a.t - b.t);
14
+ }
15
+ /**
16
+ * Split `window` into `columns` equal cells. A cell's value is the mean of the
17
+ * samples whose time falls in its span; an empty cell carries the value of
18
+ * the nearest earlier sample, or null when no earlier sample exists.
19
+ */
20
+ export function resample(samples, window, columns) {
21
+ const from = toMs(window.from);
22
+ const to = toMs(window.to);
23
+ const span = (to - from) / columns;
24
+ const points = normaliseSamples(samples);
25
+ const cells = [];
26
+ let next = 0;
27
+ let carry = null;
28
+ for (let i = 0; i < columns; i++) {
29
+ const start = from + i * span;
30
+ const end = i === columns - 1 ? to : from + (i + 1) * span;
31
+ const last = i === columns - 1;
32
+ while (next < points.length && points[next].t < start)
33
+ carry = points[next++].v;
34
+ let sum = 0;
35
+ let count = 0;
36
+ while (next < points.length && (points[next].t < end || (last && points[next].t === end))) {
37
+ sum += points[next].v;
38
+ carry = points[next++].v;
39
+ count++;
40
+ }
41
+ cells.push({ start, end, value: count > 0 ? sum / count : carry });
42
+ }
43
+ return cells;
44
+ }
@@ -0,0 +1,2 @@
1
+ export { DecayStrip } from './components/index.js';
2
+ export type { DecayStripProps, DecayStripItem, DecaySample, TimeInput } from './components/index.js';
@@ -0,0 +1 @@
1
+ export { DecayStrip } from './components/index.js';
@@ -0,0 +1,43 @@
1
+ import React from 'react';
2
+ import type { Band, HealthLabels, Provenance, Tone, ValueStatus } from '../../../utils/health/index.js';
3
+ export interface GaugeProps {
4
+ /** Full label. Always part of the accessible name. */
5
+ label: string;
6
+ /** Shown instead of `label` below the medium container width. */
7
+ shortLabel?: string;
8
+ /** Current value. `null`, `NaN` and `Infinity` render as unknown. */
9
+ value: number | null;
10
+ /** Maximum value (default 100). Zero or negative renders as unknown. */
11
+ max?: number;
12
+ /** Unit of `value`, used only to flag mismatched band units in dev. */
13
+ unit?: string;
14
+ /** Absolute value text shown beneath the percent, e.g. "890 kcal". */
15
+ secondary?: string;
16
+ /** Size variant. */
17
+ size?: 'sm' | 'md' | 'lg';
18
+ /** Explicit tone. Wins over `bands`. */
19
+ tone?: Tone;
20
+ /** Data status. */
21
+ status?: ValueStatus;
22
+ /** Tag shown for `estimated` and `modelled` values. */
23
+ provenance?: Provenance;
24
+ /** Consumer-supplied bands. Resolved on the raw value. */
25
+ bands?: Band[];
26
+ /** BCP 47 locale for number formatting (default `en`). */
27
+ locale?: string;
28
+ /** Overrides for the built-in label text. */
29
+ labels?: Partial<HealthLabels>;
30
+ /** Enable the phosphor glow on the ring. */
31
+ glow?: boolean;
32
+ /** Additional CSS class name. */
33
+ className?: string;
34
+ /** Replaces `label` in the composed accessible name. */
35
+ 'aria-label'?: string;
36
+ }
37
+ /**
38
+ * Ring gauge: a quantised ProgressRing arc with a dominant percent, optional
39
+ * secondary value, tone, status, provenance tag and overflow marker.
40
+ * The root owns the `meter` semantics; the ring is decorative (KTD7).
41
+ * Presentational and hook-free, so it stays server-safe.
42
+ */
43
+ export declare const Gauge: React.ForwardRefExoticComponent<GaugeProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cn } from '../../../utils/cn.js';
4
+ import { ProgressRing } from '../../ProgressRing/index.js';
5
+ import { Skeleton } from '../../Skeleton/index.js';
6
+ import { formatValue, isKnown, provenanceLabel, resolveBand, resolveLabels, PLACEHOLDER, } from '../../../utils/health/index.js';
7
+ const sizeClasses = {
8
+ sm: 'eidotter-gauge--sm text-dos-text-xs',
9
+ md: 'eidotter-gauge--md text-dos-text-sm',
10
+ lg: 'eidotter-gauge--lg text-dos-text-md',
11
+ };
12
+ const valueSizeClasses = {
13
+ sm: 'text-dos-text-sm',
14
+ md: 'text-dos-display-xs',
15
+ lg: 'text-dos-display-sm',
16
+ };
17
+ const ringSizes = { sm: 72, md: 104, lg: 136 };
18
+ const toneClasses = {
19
+ neutral: 'eidotter-tone--neutral',
20
+ info: 'eidotter-tone--info',
21
+ success: 'eidotter-tone--success',
22
+ warning: 'eidotter-tone--warning',
23
+ critical: 'eidotter-tone--critical',
24
+ };
25
+ /** Segment rhythm per filled tone: SVG strokes cannot take gradient fills (KTD3). */
26
+ const toneSegments = {
27
+ neutral: 16,
28
+ info: 12,
29
+ success: 16,
30
+ warning: 20,
31
+ critical: 24,
32
+ };
33
+ const statusClasses = {
34
+ ok: '',
35
+ loading: 'eidotter-gauge--loading',
36
+ empty: 'eidotter-gauge--empty',
37
+ stale: 'eidotter-gauge--stale',
38
+ unknown: 'eidotter-gauge--unknown',
39
+ };
40
+ /**
41
+ * Ring gauge: a quantised ProgressRing arc with a dominant percent, optional
42
+ * secondary value, tone, status, provenance tag and overflow marker.
43
+ * The root owns the `meter` semantics; the ring is decorative (KTD7).
44
+ * Presentational and hook-free, so it stays server-safe.
45
+ */
46
+ export const Gauge = forwardRef(({ label, shortLabel, value, max = 100, unit, secondary, size = 'md', tone, status = 'ok', provenance, bands, locale = 'en', labels: labelOverrides, glow = false, className, 'aria-label': ariaLabel, ...props }, ref) => {
47
+ const labels = resolveLabels(labelOverrides);
48
+ const loading = status === 'loading';
49
+ const unknown = !isKnown(value) ||
50
+ !(max > 0) ||
51
+ !Number.isFinite(max) ||
52
+ status === 'empty' ||
53
+ status === 'unknown';
54
+ const pct = unknown ? 0 : (value / max) * 100;
55
+ const overflow = !unknown && value > max;
56
+ const resolved = resolveBand(unknown ? null : value, bands, unit);
57
+ const effectiveTone = tone ?? resolved.tone;
58
+ const valueText = unknown
59
+ ? PLACEHOLDER
60
+ : formatValue(pct, { locale, percent: true }).text;
61
+ const stale = status === 'stale' && !unknown;
62
+ const valuetext = [unknown || loading ? labels.unknown : valueText, stale && labels.stale]
63
+ .filter(Boolean)
64
+ .join(', ');
65
+ const name = `${ariaLabel ?? label}: ${valuetext}`;
66
+ const diameter = ringSizes[size] ?? ringSizes.md;
67
+ const tagText = provenanceLabel(labels, provenance);
68
+ return (_jsxs("div", { ref: ref, className: cn('eidotter-gauge eidotter-health flex flex-col items-center gap-1 w-full font-dos', sizeClasses[size] ?? sizeClasses.md, toneClasses[effectiveTone], statusClasses[status], className), style: { minWidth: diameter }, role: "meter", "aria-valuemin": 0, "aria-valuemax": unknown ? 100 : max, "aria-valuenow": unknown || loading ? 0 : Math.max(0, Math.min(value, max)), "aria-valuetext": valuetext, "aria-label": name, "aria-busy": loading || undefined, ...props, children: [_jsxs("div", { className: "relative inline-flex items-center justify-center", children: [_jsx(ProgressRing, { decorative: true, value: loading ? 0 : pct, max: 100, size: diameter, thickness: Math.round(diameter / 8), segments: toneSegments[effectiveTone] ?? 16, color: effectiveTone === 'neutral' ? undefined : 'var(--eidotter-fill)', glow: false, className: cn(glow && 'eidotter-health-glow') }), _jsx("div", { className: "absolute inset-0 flex flex-col items-center justify-center text-center leading-none", children: loading ? (_jsx("div", { "aria-hidden": "true", className: "eidotter-gauge__skeleton w-full overflow-hidden", children: _jsx(Skeleton, { variant: "figure", lines: 2, animated: false }) })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: cn('eidotter-gauge__value text-dos-text-accent tabular-nums', valueSizeClasses[size] ?? valueSizeClasses.md, stale && 'opacity-60'), children: valueText }), secondary && !unknown && (_jsx("span", { className: "eidotter-gauge__secondary text-dos-text-brand text-dos-text-xs tabular-nums", children: secondary }))] })) })] }), _jsxs("div", { className: "flex flex-wrap items-center justify-center gap-1 text-dos-text-brand uppercase tracking-wider leading-tight max-w-full", children: [effectiveTone === 'critical' && (_jsx("span", { "aria-hidden": "true", className: "eidotter-gauge__glyph text-dos-text-error", children: "\u25B2" })), _jsxs("span", { className: "eidotter-gauge__label truncate", title: label, children: [_jsx("span", { className: "eidotter-gauge__label--full", children: label }), shortLabel && _jsx("span", { className: "eidotter-gauge__label--short", children: shortLabel })] }), overflow && (_jsx("span", { "aria-hidden": "true", className: "eidotter-gauge__overflow text-dos-text-warning", children: "+" })), tagText !== null && (_jsx("span", { className: "eidotter-gauge__provenance border border-dos-border-brand px-1 normal-case", children: tagText })), stale && _jsx("span", { className: "eidotter-gauge__stale normal-case", children: labels.stale })] }), resolved.label && (_jsx("span", { className: "eidotter-gauge__band text-dos-text-brand normal-case", children: resolved.label }))] }));
69
+ });
70
+ Gauge.displayName = 'Gauge';
@@ -0,0 +1,2 @@
1
+ export { Gauge } from './Gauge.js';
2
+ export type { GaugeProps } from './Gauge.js';
@@ -0,0 +1 @@
1
+ export { Gauge } from './Gauge.js';
@@ -0,0 +1,2 @@
1
+ export { Gauge } from './components/index.js';
2
+ export type { GaugeProps } from './components/index.js';
@@ -0,0 +1 @@
1
+ export { Gauge } from './components/index.js';
@@ -50,8 +50,10 @@ export interface InlineExpandProps {
50
50
  * - DOS-authentic styling with phosphor glow and CGA tokens
51
51
  * - WCAG 2.1 AA compliant
52
52
  *
53
- * Content stays in the DOM when collapsed (with visibility: hidden) to enable
54
- * smooth CSS transition exit animations. This differs from Section which
55
- * unmounts content on collapse via conditional rendering.
53
+ * Content is conditionally rendered — it mounts on expand and unmounts on
54
+ * collapse (like Section), so it occupies no inline space when closed. The
55
+ * expanded text continues the trigger's line with a marker-pen highlight per
56
+ * wrapped fragment; a trailing collapse control and a compact favicon-only
57
+ * sources cluster follow the last word. Reveal is an in-place opacity fade.
56
58
  */
57
59
  export declare const InlineExpand: React.FC<InlineExpandProps>;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState, useId, useRef } from 'react';
4
4
  import { Button as AriaButton } from 'react-aria-components';
5
5
  import { cn } from '../../../utils/cn.js';
@@ -26,23 +26,21 @@ function getGoogleFaviconUrl(sourceUrl) {
26
26
  * - DOS-authentic styling with phosphor glow and CGA tokens
27
27
  * - WCAG 2.1 AA compliant
28
28
  *
29
- * Content stays in the DOM when collapsed (with visibility: hidden) to enable
30
- * smooth CSS transition exit animations. This differs from Section which
31
- * unmounts content on collapse via conditional rendering.
29
+ * Content is conditionally rendered — it mounts on expand and unmounts on
30
+ * collapse (like Section), so it occupies no inline space when closed. The
31
+ * expanded text continues the trigger's line with a marker-pen highlight per
32
+ * wrapped fragment; a trailing collapse control and a compact favicon-only
33
+ * sources cluster follow the last word. Reveal is an in-place opacity fade.
32
34
  */
33
35
  export const InlineExpand = ({ children, content, defaultExpanded = false, expanded, onToggle, sources = [], className, ...props }) => {
34
36
  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
35
37
  // Tracks the current fallback stage per source URL:
36
38
  // undefined = try primary; 'google' = primary failed, try Google Favicons; 'icon' = both failed
37
39
  const [faviconFallbacks, setFaviconFallbacks] = useState({});
38
- const hasBeenExpanded = useRef(defaultExpanded);
39
40
  const contentId = useId();
40
41
  const triggerRef = useRef(null);
41
42
  const isControlled = expanded !== undefined;
42
43
  const isExpanded = isControlled ? expanded : internalExpanded;
43
- if (isExpanded) {
44
- hasBeenExpanded.current = true;
45
- }
46
44
  const handleToggle = () => {
47
45
  const next = !isExpanded;
48
46
  if (!isControlled) {
@@ -50,6 +48,13 @@ export const InlineExpand = ({ children, content, defaultExpanded = false, expan
50
48
  }
51
49
  onToggle?.(next);
52
50
  };
51
+ // The trailing [-] control unmounts when it collapses the content; return
52
+ // focus to the trigger so a keyboard/screen-reader user isn't dropped to
53
+ // <body>. Routes through handleToggle so controlled mode still works.
54
+ const handleCollapseFromTrailing = () => {
55
+ handleToggle();
56
+ triggerRef.current?.focus();
57
+ };
53
58
  const handleKeyDown = (event) => {
54
59
  if (event.key === 'Escape' && isExpanded) {
55
60
  event.stopPropagation();
@@ -57,10 +62,7 @@ export const InlineExpand = ({ children, content, defaultExpanded = false, expan
57
62
  triggerRef.current?.focus();
58
63
  }
59
64
  };
60
- return (_jsxs("span", { className: cn('eidotter-inline-expand', isExpanded && 'eidotter-inline-expand--expanded', className), onKeyDown: handleKeyDown, ...props, children: [_jsxs(AriaButton, { ref: triggerRef, className: "eidotter-inline-expand__trigger", "aria-expanded": isExpanded, "aria-controls": contentId, onPress: handleToggle, children: [children, _jsx("span", { className: "eidotter-inline-expand__indicator", "aria-hidden": "true", children: isExpanded ? '[-]' : '[+]' })] }), _jsx("span", { id: contentId, className: "eidotter-inline-expand__content", role: "region", inert: !isExpanded, children: _jsxs("span", { className: "eidotter-inline-expand__content-clip", children: [_jsx("span", { className: "eidotter-inline-expand__inner", children: content }), sources.length > 0 && (_jsx("span", { className: "eidotter-inline-expand__sources", role: "list", children: sources.map((source) => (_jsx("span", { className: "eidotter-inline-expand__source-item", role: "listitem", children: _jsxs("a", { href: isSafeUrl(source.url) ? source.url : undefined, className: "eidotter-inline-expand__source-link", target: "_blank", rel: "noopener noreferrer", "aria-label": `${source.title} (opens external website)`, children: [(() => {
61
- if (!hasBeenExpanded.current) {
62
- return _jsx("span", { className: "eidotter-inline-expand__source-icon", "aria-hidden": "true", children: "[\u2192]" });
63
- }
65
+ return (_jsxs("span", { className: cn('eidotter-inline-expand', isExpanded && 'eidotter-inline-expand--expanded', className), "aria-live": "polite", onKeyDown: handleKeyDown, ...props, children: [_jsxs(AriaButton, { ref: triggerRef, className: "eidotter-inline-expand__trigger", "aria-expanded": isExpanded, "aria-controls": contentId, onPress: handleToggle, children: [children, _jsx("span", { className: "eidotter-inline-expand__indicator", "aria-hidden": "true", children: isExpanded ? '[-]' : '[+]' })] }), isExpanded && (_jsxs(_Fragment, { children: [' ', _jsxs("span", { id: contentId, className: "eidotter-inline-expand__content", "data-ai-skip": "true", children: [content, _jsx(AriaButton, { className: "eidotter-inline-expand__collapse", "aria-expanded": true, "aria-controls": contentId, "aria-label": "Collapse", onPress: handleCollapseFromTrailing, children: "[-]" }), sources.length > 0 && (_jsx("span", { className: "eidotter-inline-expand__sources", role: "list", children: sources.map((source) => (_jsx("span", { className: "eidotter-inline-expand__source-item", role: "listitem", children: _jsx("a", { href: isSafeUrl(source.url) ? source.url : undefined, className: "eidotter-inline-expand__source-link", target: "_blank", rel: "noopener noreferrer", title: source.title, "aria-label": `${source.title} (opens external website)`, children: (() => {
64
66
  const stage = faviconFallbacks[source.url];
65
67
  if (stage === 'icon') {
66
68
  return _jsx("span", { className: "eidotter-inline-expand__source-icon", "aria-hidden": "true", children: "[\u2192]" });
@@ -74,5 +76,5 @@ export const InlineExpand = ({ children, content, defaultExpanded = false, expan
74
76
  return (_jsx("img", { className: "eidotter-inline-expand__source-favicon", src: googleUrl, alt: "", width: 16, height: 16, decoding: "async", onError: () => setFaviconFallbacks(prev => ({ ...prev, [source.url]: 'icon' })) }));
75
77
  }
76
78
  return _jsx("span", { className: "eidotter-inline-expand__source-icon", "aria-hidden": "true", children: "[\u2192]" });
77
- })(), _jsx("span", { className: "eidotter-inline-expand__source-title", children: source.title })] }) }, source.url))) }))] }) })] }));
79
+ })() }) }, source.url))) }))] })] }))] }));
78
80
  };
@@ -0,0 +1,45 @@
1
+ import React from 'react';
2
+ import type { Band, HealthLabels, Tone } from '../../../utils/health/index.js';
3
+ export interface MeterProps {
4
+ /** Full label. Always part of the accessible name. */
5
+ label: string;
6
+ /** Shown instead of `label` below the medium container width. */
7
+ shortLabel?: string;
8
+ /** Current value. `null`, `NaN` and `Infinity` render as unknown. */
9
+ value: number | null;
10
+ /** Maximum value (default 100). Zero or negative renders as unknown. */
11
+ max?: number;
12
+ /** Unit appended to the value text and used to flag mismatched band units in dev. */
13
+ unit?: string;
14
+ /** Optimal value (tick) or `[lo, hi]` range (band), on the same scale as `value`. */
15
+ optimal?: number | [number, number];
16
+ /** Bar direction. */
17
+ orientation?: 'vertical' | 'horizontal';
18
+ /** Size variant. */
19
+ size?: 'xs' | 'sm' | 'md';
20
+ /** Explicit tone. Wins over `bands`. */
21
+ tone?: Tone;
22
+ /** Fill pattern. Defaults to the pattern paired with the tone. */
23
+ pattern?: 'solid' | 'dither' | 'hatch' | 'dotted';
24
+ /** Render the bar as this many quantised blocks (round half away). */
25
+ segments?: number;
26
+ /** Consumer-supplied bands. Resolved on the raw value. */
27
+ bands?: Band[];
28
+ /** BCP 47 locale for number formatting (default `en`). */
29
+ locale?: string;
30
+ /** Overrides for the built-in label text. */
31
+ labels?: Partial<HealthLabels>;
32
+ /** Show the value text (default true). The accessible value text is unaffected. */
33
+ showValue?: boolean;
34
+ /** Additional CSS class name. */
35
+ className?: string;
36
+ /** Replaces `label` in the composed accessible name. */
37
+ 'aria-label'?: string;
38
+ }
39
+ /**
40
+ * Single-bar meter, vertical or horizontal, with label, value text, optional
41
+ * optimal tick or range, tone with a paired fill pattern, and optional
42
+ * quantised blocks. Text never sits on the fill (R13). Presentational and
43
+ * hook-free, so it stays server-safe.
44
+ */
45
+ export declare const Meter: React.ForwardRefExoticComponent<MeterProps & React.RefAttributes<HTMLDivElement>>;