eidotter 0.38.1 → 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 (121) hide show
  1. package/README.md +19 -2
  2. package/dist/components/Brand/components/Mark.d.ts +55 -0
  3. package/dist/components/Brand/components/Mark.js +87 -0
  4. package/dist/components/Brand/components/index.d.ts +4 -0
  5. package/dist/components/Brand/components/index.js +2 -0
  6. package/dist/components/Brand/components/markGeometry.d.ts +26 -0
  7. package/dist/components/Brand/components/markGeometry.js +54 -0
  8. package/dist/components/Brand/components/markGeometry.test.d.ts +1 -0
  9. package/dist/components/Breadcrumb/components/Breadcrumb.js +7 -3
  10. package/dist/components/Connector/components/Connector.d.ts +41 -0
  11. package/dist/components/Connector/components/Connector.js +165 -0
  12. package/dist/components/Connector/components/connectorGeometry.d.ts +50 -0
  13. package/dist/components/Connector/components/connectorGeometry.js +102 -0
  14. package/dist/components/Connector/components/connectorGeometry.test.d.ts +1 -0
  15. package/dist/components/Connector/components/index.d.ts +4 -0
  16. package/dist/components/Connector/components/index.js +2 -0
  17. package/dist/components/Connector/index.d.ts +2 -0
  18. package/dist/components/Connector/index.js +1 -0
  19. package/dist/components/DecayStrip/components/DecayStrip.d.ts +60 -0
  20. package/dist/components/DecayStrip/components/DecayStrip.js +99 -0
  21. package/dist/components/DecayStrip/components/index.d.ts +3 -0
  22. package/dist/components/DecayStrip/components/index.js +1 -0
  23. package/dist/components/DecayStrip/components/resample.d.ts +34 -0
  24. package/dist/components/DecayStrip/components/resample.js +44 -0
  25. package/dist/components/DecayStrip/components/resample.test.d.ts +1 -0
  26. package/dist/components/DecayStrip/index.d.ts +2 -0
  27. package/dist/components/DecayStrip/index.js +1 -0
  28. package/dist/components/Footer/components/Footer.js +6 -1
  29. package/dist/components/Gauge/components/Gauge.d.ts +43 -0
  30. package/dist/components/Gauge/components/Gauge.js +70 -0
  31. package/dist/components/Gauge/components/index.d.ts +2 -0
  32. package/dist/components/Gauge/components/index.js +1 -0
  33. package/dist/components/Gauge/index.d.ts +2 -0
  34. package/dist/components/Gauge/index.js +1 -0
  35. package/dist/components/Header/components/Header.d.ts +31 -0
  36. package/dist/components/Header/components/Header.js +14 -3
  37. package/dist/components/Header/components/HeaderContextRow.d.ts +33 -0
  38. package/dist/components/Header/components/HeaderContextRow.js +58 -0
  39. package/dist/components/Header/components/index.d.ts +1 -1
  40. package/dist/components/Header/index.d.ts +1 -1
  41. package/dist/components/Icon/components/Icon.d.ts +7 -0
  42. package/dist/components/Icon/components/Icon.js +9 -0
  43. package/dist/components/Icon/components/index.d.ts +1 -1
  44. package/dist/components/Icon/components/index.js +1 -1
  45. package/dist/components/InlineExpand/components/InlineExpand.d.ts +5 -3
  46. package/dist/components/InlineExpand/components/InlineExpand.js +15 -13
  47. package/dist/components/InlineLink/components/InlineLink.js +5 -9
  48. package/dist/components/Meter/components/Meter.d.ts +45 -0
  49. package/dist/components/Meter/components/Meter.js +79 -0
  50. package/dist/components/Meter/components/MeterGroup.d.ts +26 -0
  51. package/dist/components/Meter/components/MeterGroup.js +28 -0
  52. package/dist/components/Meter/components/index.d.ts +4 -0
  53. package/dist/components/Meter/components/index.js +2 -0
  54. package/dist/components/Meter/index.d.ts +2 -0
  55. package/dist/components/Meter/index.js +1 -0
  56. package/dist/components/Nav/components/Nav.js +14 -2
  57. package/dist/components/ProgressRing/components/ProgressRing.d.ts +5 -0
  58. package/dist/components/ProgressRing/components/ProgressRing.js +10 -2
  59. package/dist/components/RangeBar/components/RangeBar.d.ts +50 -0
  60. package/dist/components/RangeBar/components/RangeBar.js +90 -0
  61. package/dist/components/RangeBar/components/index.d.ts +2 -0
  62. package/dist/components/RangeBar/components/index.js +1 -0
  63. package/dist/components/RangeBar/index.d.ts +2 -0
  64. package/dist/components/RangeBar/index.js +1 -0
  65. package/dist/components/SeriesBars/components/SeriesBars.d.ts +43 -0
  66. package/dist/components/SeriesBars/components/SeriesBars.js +64 -0
  67. package/dist/components/SeriesBars/components/index.d.ts +2 -0
  68. package/dist/components/SeriesBars/components/index.js +1 -0
  69. package/dist/components/SeriesBars/index.d.ts +2 -0
  70. package/dist/components/SeriesBars/index.js +1 -0
  71. package/dist/components/Stat/components/Stat.d.ts +6 -0
  72. package/dist/components/Stat/components/Stat.js +2 -2
  73. package/dist/components/TimelineFeed/components/TimelineFeed.d.ts +37 -0
  74. package/dist/components/TimelineFeed/components/TimelineFeed.js +59 -0
  75. package/dist/components/TimelineFeed/components/index.d.ts +2 -0
  76. package/dist/components/TimelineFeed/components/index.js +1 -0
  77. package/dist/components/TimelineFeed/index.d.ts +2 -0
  78. package/dist/components/TimelineFeed/index.js +1 -0
  79. package/dist/components/TimelinePage/components/TimelinePage.d.ts +35 -0
  80. package/dist/components/TimelinePage/components/TimelinePage.js +77 -0
  81. package/dist/components/TimelinePage/components/index.d.ts +2 -0
  82. package/dist/components/TimelinePage/components/index.js +1 -0
  83. package/dist/components/TimelinePage/index.d.ts +2 -0
  84. package/dist/components/TimelinePage/index.js +1 -0
  85. package/dist/components/VitalsStrip/components/VitalsStrip.d.ts +40 -0
  86. package/dist/components/VitalsStrip/components/VitalsStrip.js +73 -0
  87. package/dist/components/VitalsStrip/components/index.d.ts +2 -0
  88. package/dist/components/VitalsStrip/components/index.js +1 -0
  89. package/dist/components/VitalsStrip/index.d.ts +2 -0
  90. package/dist/components/VitalsStrip/index.js +1 -0
  91. package/dist/components/registry.d.ts +1 -1
  92. package/dist/components/registry.js +254 -2
  93. package/dist/eidotter.css +1 -1
  94. package/dist/index.d.ts +25 -5
  95. package/dist/index.es.js +3897 -1902
  96. package/dist/index.umd.js +3 -3
  97. package/dist/utils/health/formatValue.d.ts +29 -0
  98. package/dist/utils/health/formatValue.js +46 -0
  99. package/dist/utils/health/formatValue.test.d.ts +1 -0
  100. package/dist/utils/health/healthPatterns.test.d.ts +1 -0
  101. package/dist/utils/health/index.d.ts +6 -0
  102. package/dist/utils/health/index.js +3 -0
  103. package/dist/utils/health/labels.d.ts +6 -0
  104. package/dist/utils/health/labels.js +18 -0
  105. package/dist/utils/health/resolveBand.d.ts +12 -0
  106. package/dist/utils/health/resolveBand.js +29 -0
  107. package/dist/utils/health/resolveBand.test.d.ts +1 -0
  108. package/dist/utils/health/types.d.ts +25 -0
  109. package/dist/utils/health/types.js +2 -0
  110. package/dist/utils/index.d.ts +2 -0
  111. package/dist/utils/index.js +1 -0
  112. package/dist/utils/isSafeHref.d.ts +26 -1
  113. package/dist/utils/isSafeHref.js +27 -15
  114. package/dist/utils/isSafeHref.test.d.ts +1 -0
  115. package/dist/utils/isSafeUrl.d.ts +5 -0
  116. package/dist/utils/isSafeUrl.js +5 -0
  117. package/guidelines/README.md +1 -0
  118. package/guidelines/components.md +25 -0
  119. package/guidelines/health-metabolism-patterns.md +212 -0
  120. package/llms.txt +2 -2
  121. package/package.json +2 -2
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Pure geometry for the Connector's tapered SVG ribbons (DMNC-1388).
3
+ *
4
+ * A ribbon is a filled, closed `<path>` sampled along a cubic bezier from a
5
+ * source point to an attach point, offset by a width profile. The taper is
6
+ * deliberately SUBTLE (locked 2026-07-13): near a straight thin thread, only a
7
+ * whisper thicker where it leaves the yolk. No DOM, no React — testable with
8
+ * literal points.
9
+ */
10
+ /**
11
+ * Locked weights (2026-07-13 shape proof). `delicate` is the default — a thin
12
+ * product-chrome thread; `medium` reads a touch bolder on large surfaces.
13
+ */
14
+ export const CONNECTOR_WEIGHTS = {
15
+ delicate: { base: 5, mid: 3.2, tip: 3.6, curvature: 22 },
16
+ medium: { base: 8, mid: 4.4, tip: 5.0, curvature: 30 },
17
+ };
18
+ /** Number of samples along the centre curve. */
19
+ const SAMPLES = 28;
20
+ const r2 = (n) => Math.round(n * 100) / 100;
21
+ const lerp = (a, b, t) => a + (b - a) * t;
22
+ const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
23
+ /** Half-width at parameter t: thick base → thin middle → slight swell into tip. */
24
+ function halfWidthAt(t, w) {
25
+ if (t < 0.5)
26
+ return lerp(w.base, w.mid, easeInOut(t / 0.5));
27
+ return lerp(w.mid, w.tip, easeInOut((t - 0.5) / 0.5));
28
+ }
29
+ /**
30
+ * The point on the target rect border along the ray from the rect centre toward
31
+ * the source — where the ribbon must STOP (touching the edge, never inside).
32
+ */
33
+ export function attachPoint(center, source, rect) {
34
+ const dx = source.x - center.x;
35
+ const dy = source.y - center.y;
36
+ const hw = rect.width / 2;
37
+ const hh = rect.height / 2;
38
+ const tx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
39
+ const ty = dy !== 0 ? hh / Math.abs(dy) : Infinity;
40
+ const t = Math.min(tx, ty);
41
+ // Degenerate (zero-size rect, or source at centre) → the centre itself.
42
+ if (!Number.isFinite(t))
43
+ return { x: center.x, y: center.y };
44
+ return { x: center.x + dx * t, y: center.y + dy * t };
45
+ }
46
+ function cubic(p0, c1, c2, p3, t) {
47
+ const mt = 1 - t;
48
+ const a = mt * mt * mt;
49
+ const b = 3 * mt * mt * t;
50
+ const c = 3 * mt * t * t;
51
+ const d = t * t * t;
52
+ return { x: a * p0.x + b * c1.x + c * c2.x + d * p3.x, y: a * p0.y + b * c1.y + c * c2.y + d * p3.y };
53
+ }
54
+ function cubicTangent(p0, c1, c2, p3, t) {
55
+ const mt = 1 - t;
56
+ const a = 3 * mt * mt;
57
+ const b = 6 * mt * t;
58
+ const c = 3 * t * t;
59
+ return {
60
+ x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
61
+ y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y),
62
+ };
63
+ }
64
+ /**
65
+ * Build a closed tapered-ribbon path from `source` to `attach`. Returns an SVG
66
+ * path `d` string (empty when the endpoints coincide).
67
+ */
68
+ export function ribbonPath(source, attach, weight, options = {}) {
69
+ const { sign = 1, sway = 0 } = options;
70
+ const dx = attach.x - source.x;
71
+ const dy = attach.y - source.y;
72
+ const len = Math.hypot(dx, dy);
73
+ if (len < 0.5)
74
+ return '';
75
+ const nx = -dy / len;
76
+ const ny = dx / len;
77
+ const c = Math.min(weight.curvature, len / 3);
78
+ const off1 = (c + sway) * sign;
79
+ const off2 = (c * 0.5 - sway * 0.6) * sign;
80
+ const c1 = { x: source.x + dx * 0.33 + nx * off1, y: source.y + dy * 0.33 + ny * off1 };
81
+ const c2 = { x: source.x + dx * 0.66 + nx * off2, y: source.y + dy * 0.66 + ny * off2 };
82
+ const left = [];
83
+ const right = [];
84
+ for (let i = 0; i <= SAMPLES; i++) {
85
+ const t = i / SAMPLES;
86
+ const p = cubic(source, c1, c2, attach, t);
87
+ const tan = cubicTangent(source, c1, c2, attach, t);
88
+ const tl = Math.hypot(tan.x, tan.y) || 1;
89
+ const mx = -tan.y / tl;
90
+ const my = tan.x / tl;
91
+ const hwid = halfWidthAt(t, weight);
92
+ left.push({ x: p.x + mx * hwid, y: p.y + my * hwid });
93
+ right.push({ x: p.x - mx * hwid, y: p.y - my * hwid });
94
+ }
95
+ let d = `M ${r2(left[0].x)} ${r2(left[0].y)}`;
96
+ for (let i = 1; i <= SAMPLES; i++)
97
+ d += ` L ${r2(left[i].x)} ${r2(left[i].y)}`;
98
+ for (let i = SAMPLES; i >= 0; i--)
99
+ d += ` L ${r2(right[i].x)} ${r2(right[i].y)}`;
100
+ d += ' Z';
101
+ return d;
102
+ }
@@ -0,0 +1,4 @@
1
+ export { Connector } from './Connector.js';
2
+ export type { ConnectorProps, ConnectorTarget } from './Connector.js';
3
+ export { attachPoint, ribbonPath, CONNECTOR_WEIGHTS, } from './connectorGeometry.js';
4
+ export type { Point, Rect, RibbonWeight, RibbonOptions } from './connectorGeometry.js';
@@ -0,0 +1,2 @@
1
+ export { Connector } from './Connector.js';
2
+ export { attachPoint, ribbonPath, CONNECTOR_WEIGHTS, } from './connectorGeometry.js';
@@ -0,0 +1,2 @@
1
+ export { Connector } from './components/index.js';
2
+ export type { ConnectorProps, ConnectorTarget } from './components/index.js';
@@ -0,0 +1 @@
1
+ export { Connector } from './components/index.js';
@@ -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';
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { cn } from '../../../utils/cn.js';
4
+ import { isSafeHref } from '../../../utils/isSafeHref.js';
4
5
  /** Default legal links for German compliance (DDG § 5 / GDPR) */
5
6
  export const defaultLegalLinks = [
6
7
  { label: 'Impressum', href: '/impressum' },
@@ -14,5 +15,9 @@ export const Footer = ({ copyright, links, linkComponent, children, className, .
14
15
  const resolvedLinks = links ?? defaultLegalLinks;
15
16
  const LinkTag = linkComponent ?? 'a';
16
17
  const linkClassName = 'eidotter-footer__link text-dos-text-brand no-underline';
17
- return (_jsxs("footer", { className: cn('font-dos text-sm py-4 px-2 text-center', 'eidotter-footer', className), ...props, children: [_jsx("div", { className: "h-px bg-dos-border-default mb-4", role: "separator" }), children && _jsx("div", { className: "mb-3", children: children }), resolvedLinks.length > 0 && (_jsx("nav", { className: "flex justify-center items-center flex-wrap gap-2 mb-2", "aria-label": "Footer links", children: resolvedLinks.map((link, index) => (_jsxs(React.Fragment, { children: [index > 0 && _jsx("span", { className: "text-dos-text-muted select-none eidotter-footer__dot", "aria-hidden": "true", children: "\u00B7" }), link.external ? (_jsx("a", { className: linkClassName, href: link.href, target: "_blank", rel: "noopener noreferrer", children: link.label })) : (_jsx(LinkTag, { className: linkClassName, href: link.href, children: link.label }))] }, link.href))) })), copyright && (_jsxs("p", { className: "text-dos-text-muted m-0", children: ["\u00A9 ", copyright] }))] }));
18
+ return (_jsxs("footer", { className: cn('font-dos text-sm py-4 px-2 text-center', 'eidotter-footer', className), ...props, children: [_jsx("div", { className: "h-px bg-dos-border-default mb-4", role: "separator" }), children && _jsx("div", { className: "mb-3", children: children }), resolvedLinks.length > 0 && (_jsx("nav", { className: "flex justify-center items-center flex-wrap gap-2 mb-2", "aria-label": "Footer links", children: resolvedLinks.map((link, index) => {
19
+ // Unsafe hrefs (javascript, data, vbscript, …) render the label without an anchor.
20
+ const safeHref = isSafeHref(link.href) ? link.href : undefined;
21
+ return (_jsxs(React.Fragment, { children: [index > 0 && _jsx("span", { className: "text-dos-text-muted select-none eidotter-footer__dot", "aria-hidden": "true", children: "\u00B7" }), !safeHref ? (_jsx("span", { className: linkClassName, children: link.label })) : link.external ? (_jsx("a", { className: linkClassName, href: safeHref, target: "_blank", rel: "noopener noreferrer", children: link.label })) : (_jsx(LinkTag, { className: linkClassName, href: safeHref, children: link.label }))] }, link.href));
22
+ }) })), copyright && (_jsxs("p", { className: "text-dos-text-muted m-0", children: ["\u00A9 ", copyright] }))] }));
18
23
  };
@@ -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';
@@ -1,5 +1,26 @@
1
1
  import React from 'react';
2
2
  import type { NavItem, NavProps } from '../../Nav/index.js';
3
+ export interface HeaderCategory {
4
+ /** Stable identifier — emitted as `data-category-key` for DOM measurement (DMNC-1325 arm connectors). */
5
+ key: string;
6
+ label: string;
7
+ /** Icon name; rendered via `<Icon>` only when it matches a known IconName, otherwise label-only. */
8
+ icon: string;
9
+ href: string;
10
+ }
11
+ export interface HeaderReturnTo {
12
+ label: string;
13
+ href: string;
14
+ /**
15
+ * true → navigate via `window.open(href, 'rizomorf-shell')` to reuse a named
16
+ * tab (cross-site shell). Default: same-tab via linkComponent/anchor.
17
+ */
18
+ reuseTab?: boolean;
19
+ }
20
+ export interface HeaderContext {
21
+ categories: HeaderCategory[];
22
+ returnTo?: HeaderReturnTo;
23
+ }
3
24
  export interface HeaderProps extends React.HTMLAttributes<HTMLElement> {
4
25
  /** Site brand name displayed in the header */
5
26
  brandName?: string;
@@ -15,6 +36,16 @@ export interface HeaderProps extends React.HTMLAttributes<HTMLElement> {
15
36
  sticky?: boolean;
16
37
  /** Custom link component for framework routing (Next.js Link, React Router Link, etc.) */
17
38
  linkComponent?: NavProps['linkComponent'];
39
+ /**
40
+ * Optional cross-site context row rendered below the brand+nav row: a
41
+ * measurable category badge row + an optional "← back" pill. When present and
42
+ * non-empty, the header lays out as two stacked rows and emits the stable
43
+ * measurement classes `.eidotter-header__context` / `.eidotter-header__categories`
44
+ * / `.eidotter-header__category` + `li[data-category-key]` (the Mark
45
+ * arm-connector contract, DMNC-1325). When absent or empty, the rendered DOM
46
+ * is identical to a header without this prop.
47
+ */
48
+ context?: HeaderContext;
18
49
  /** Custom branding content — replaces brandName when provided */
19
50
  children?: React.ReactNode;
20
51
  /** Additional CSS class name */
@@ -1,13 +1,24 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from 'react';
3
3
  import { cn } from '../../../utils/cn.js';
4
+ import { isSafeHref } from '../../../utils/isSafeHref.js';
4
5
  import { DesktopNav, MobileNav } from '../../Nav/index.js';
6
+ import { HeaderContextRow } from './HeaderContextRow.js';
5
7
  const variantClasses = {
6
8
  retro: 'eidotter-header--retro',
7
9
  modern: 'eidotter-header--modern',
8
10
  };
9
- export const Header = forwardRef(({ brandName, brandHref = '/', items, activeHref, variant = 'retro', sticky = true, linkComponent, children, className, ...rest }, ref) => {
11
+ export const Header = forwardRef(({ brandName, brandHref = '/', items, activeHref, variant = 'retro', sticky = true, linkComponent, context, children, className, ...rest }, ref) => {
10
12
  const LinkTag = linkComponent ?? 'a';
11
- return (_jsxs("header", { ref: ref, className: cn('flex items-center justify-between px-4 py-3 font-dos', 'eidotter-header', variantClasses[variant], sticky && 'sticky top-0 z-50 bg-dos-bg-primary', className), ...rest, children: [_jsx(LinkTag, { href: brandHref, className: cn('no-underline text-base font-bold tracking-wide', 'eidotter-header__branding'), children: children || brandName }), _jsx(DesktopNav, { items: items, activeHref: activeHref, variant: variant, linkComponent: linkComponent }), _jsx(MobileNav, { items: items, activeHref: activeHref, variant: variant, linkComponent: linkComponent })] }));
13
+ // The brand link is structural — an unsafe brandHref falls back to '/' rather
14
+ // than unmounting the branding anchor.
15
+ const safeBrandHref = isSafeHref(brandHref) ? brandHref : '/';
16
+ // An empty context (no categories, no returnTo) is treated as absent so the
17
+ // rendered DOM stays byte-identical to a header without the prop.
18
+ const hasContext = !!context && (context.categories.length > 0 || !!context.returnTo);
19
+ const main = (_jsxs(_Fragment, { children: [_jsx(LinkTag, { href: safeBrandHref, className: cn('no-underline text-base font-bold tracking-wide', 'eidotter-header__branding'), children: children || brandName }), _jsx(DesktopNav, { items: items, activeHref: activeHref, variant: variant, linkComponent: linkComponent }), _jsx(MobileNav, { items: items, activeHref: activeHref, variant: variant, linkComponent: linkComponent })] }));
20
+ return (_jsx("header", { ref: ref, className: cn(hasContext
21
+ ? 'flex flex-col gap-2 px-4 py-3 font-dos'
22
+ : 'flex items-center justify-between px-4 py-3 font-dos', 'eidotter-header', variantClasses[variant], sticky && 'sticky top-0 z-50 bg-dos-bg-primary', className), ...rest, children: hasContext ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "eidotter-header__main flex w-full items-center justify-between", children: main }), _jsx(HeaderContextRow, { context: context, linkComponent: linkComponent, activeHref: activeHref })] })) : (main) }));
12
23
  });
13
24
  Header.displayName = 'Header';