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
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,55 @@
1
+ import React from 'react';
2
+ export interface MarkArmsConfig {
3
+ /** Element within which badge targets are discovered (e.g. a ref to <Header>). */
4
+ scopeRef: React.RefObject<HTMLElement | null>;
5
+ /** Explicit badge refs — takes precedence over selector discovery. */
6
+ targetRefs?: React.RefObject<HTMLElement | null>[];
7
+ /**
8
+ * Selector for badge discovery inside `scopeRef`. Default
9
+ * `.eidotter-header__category` — the stable class the Header context badge
10
+ * row emits (DMNC-1326 contract).
11
+ */
12
+ targetSelector?: string;
13
+ /** Bump when the badge set changes and DOM mutation can't be observed (escape hatch). */
14
+ refreshKey?: React.Key;
15
+ }
16
+ export interface MarkProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
17
+ /** Size in px (or CSS length). Default 48. */
18
+ size?: number | string;
19
+ /** Accessible label. Default 'eiDotter'. Empty string → decorative (aria-hidden). */
20
+ label?: string;
21
+ /** Enable the border-radius morph loop. Default true. */
22
+ morph?: boolean;
23
+ /**
24
+ * Arm-connector configuration. Omit to render the mark alone.
25
+ *
26
+ * Caveats:
27
+ * - Any `overflow: hidden` / `clip` ancestor between the Mark and the badges
28
+ * amputates the arms (paths extend beyond the Mark's own box).
29
+ * - Pure position changes with no size change anywhere are covered by
30
+ * window-resize + the scope ResizeObserver in practice; use `refreshKey` as
31
+ * the escape hatch if a layout shift is missed.
32
+ * - `arms` is for the conventional badge ROW only — it is mutually exclusive
33
+ * with the future Header `layout="tentacle"` mode (PLAN-tentacle-menu),
34
+ * which draws its own overlay: transforms fire neither ResizeObserver nor a
35
+ * childList MutationObserver, so `arms` can never track transformed tips.
36
+ */
37
+ arms?: MarkArmsConfig;
38
+ }
39
+ /**
40
+ * eiDotter Mark — a small organic blob that continuously morphs its silhouette
41
+ * (a ~6s border-radius loop) with an optional set of SVG arm connectors drawn
42
+ * from the mark to a variable set of nav badges, measured at runtime via
43
+ * getBoundingClientRect(). Arms redraw on resize and on badge-set change, never
44
+ * intercept pointer events, and are aria-hidden.
45
+ *
46
+ * The root class `eidotter-mark` is a stable contract — the future tentacle
47
+ * mode discovers the anchor via `scope.querySelector('.eidotter-mark')`; never
48
+ * rename it.
49
+ *
50
+ * There is NO rAF work in this design, so no `prefersReducedMotion()` call is
51
+ * needed. Any FUTURE rAF animation (e.g. arm draw-in) MUST gate on
52
+ * `prefersReducedMotion()` (src/utils/prefersReducedMotion.ts) plus a CSS media
53
+ * block.
54
+ */
55
+ export declare const Mark: React.ForwardRefExoticComponent<MarkProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
4
+ import { cn } from '../../../utils/cn.js';
5
+ import { computeArmPaths } from './markGeometry.js';
6
+ /** Stable empty-array reference so the no-arms early return can't loop setState. */
7
+ const EMPTY = [];
8
+ /**
9
+ * eiDotter Mark — a small organic blob that continuously morphs its silhouette
10
+ * (a ~6s border-radius loop) with an optional set of SVG arm connectors drawn
11
+ * from the mark to a variable set of nav badges, measured at runtime via
12
+ * getBoundingClientRect(). Arms redraw on resize and on badge-set change, never
13
+ * intercept pointer events, and are aria-hidden.
14
+ *
15
+ * The root class `eidotter-mark` is a stable contract — the future tentacle
16
+ * mode discovers the anchor via `scope.querySelector('.eidotter-mark')`; never
17
+ * rename it.
18
+ *
19
+ * There is NO rAF work in this design, so no `prefersReducedMotion()` call is
20
+ * needed. Any FUTURE rAF animation (e.g. arm draw-in) MUST gate on
21
+ * `prefersReducedMotion()` (src/utils/prefersReducedMotion.ts) plus a CSS media
22
+ * block.
23
+ */
24
+ export const Mark = forwardRef(({ size = 48, label = 'eiDotter', morph = true, arms, className, style, ...rest }, ref) => {
25
+ const rootRef = useRef(null);
26
+ const [paths, setPaths] = useState(EMPTY);
27
+ const setRefs = useCallback((node) => {
28
+ rootRef.current = node;
29
+ if (typeof ref === 'function')
30
+ ref(node);
31
+ else if (ref)
32
+ ref.current = node;
33
+ }, [ref]);
34
+ // useEffect (NOT useLayoutEffect): the scope is typically an ANCESTOR
35
+ // (e.g. <Header><Mark arms={{ scopeRef: headerRef }} /></Header>). Layout
36
+ // effects run bottom-up, so an ancestor's ref is still null during a child's
37
+ // layout effect — arms would never wire. Passive effects run after the whole
38
+ // commit, when every ref (scope included) is attached. The one-frame-later
39
+ // arm paint is imperceptible; the blob is already on screen.
40
+ useEffect(() => {
41
+ if (!arms) {
42
+ setPaths(EMPTY);
43
+ return;
44
+ }
45
+ const root = rootRef.current;
46
+ const scope = arms.scopeRef.current;
47
+ if (!root)
48
+ return;
49
+ let targets = [];
50
+ const update = () => {
51
+ const markRect = root.getBoundingClientRect();
52
+ const next = computeArmPaths(markRect, targets.map((t) => t.getBoundingClientRect()));
53
+ setPaths((prev) => prev.length === next.length && prev.every((p, i) => p === next[i]) ? prev : next);
54
+ };
55
+ const collectTargets = () => arms.targetRefs
56
+ ? arms.targetRefs.map((r) => r.current).filter((el) => !!el)
57
+ : scope
58
+ ? Array.from(scope.querySelectorAll(arms.targetSelector ?? '.eidotter-header__category'))
59
+ : [];
60
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : undefined;
61
+ const wire = () => {
62
+ ro?.disconnect();
63
+ targets = collectTargets();
64
+ ro?.observe(root);
65
+ if (scope)
66
+ ro?.observe(scope);
67
+ targets.forEach((t) => ro?.observe(t));
68
+ update();
69
+ };
70
+ wire();
71
+ // Badge-set change: RE-QUERY + RE-OBSERVE, never reuse the stale list.
72
+ const mo = scope && typeof MutationObserver !== 'undefined' ? new MutationObserver(wire) : undefined;
73
+ if (scope)
74
+ mo?.observe(scope, { childList: true, subtree: true });
75
+ window.addEventListener('resize', update);
76
+ return () => {
77
+ ro?.disconnect();
78
+ mo?.disconnect();
79
+ window.removeEventListener('resize', update);
80
+ };
81
+ // Depend on the arms CONFIG fields, never the arms object identity
82
+ // (consumers inline it → re-wiring every render).
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [arms?.scopeRef, arms?.targetSelector, arms?.refreshKey, arms?.targetRefs?.length, arms ? 1 : 0]);
85
+ return (_jsxs("div", { ref: setRefs, role: label ? 'img' : undefined, "aria-label": label || undefined, "aria-hidden": label ? undefined : true, className: cn('eidotter-mark relative inline-block', className), style: { width: size, height: size, ...style }, ...rest, children: [arms && (_jsx("svg", { className: "eidotter-mark__arms pointer-events-none absolute inset-0 h-full w-full overflow-visible text-dos-text-accent", "aria-hidden": "true", focusable: "false", children: paths.map((d, i) => (_jsx("path", { d: d, fill: "none", stroke: "currentColor", strokeWidth: 2 }, i))) })), _jsx("span", { className: cn('eidotter-mark__blob relative block h-full w-full', morph && 'eidotter-mark__blob--morph') })] }));
86
+ });
87
+ Mark.displayName = 'Mark';
@@ -4,3 +4,7 @@ export { Wordmark } from './Wordmark.js';
4
4
  export type { WordmarkProps } from './Wordmark.js';
5
5
  export { BrandLockup } from './BrandLockup.js';
6
6
  export type { BrandLockupProps } from './BrandLockup.js';
7
+ export { Mark } from './Mark.js';
8
+ export type { MarkProps, MarkArmsConfig } from './Mark.js';
9
+ export { computeArmPaths } from './markGeometry.js';
10
+ export type { RectLike, ArmPathOptions } from './markGeometry.js';
@@ -1,3 +1,5 @@
1
1
  export { Logo } from './Logo.js';
2
2
  export { Wordmark } from './Wordmark.js';
3
3
  export { BrandLockup } from './BrandLockup.js';
4
+ export { Mark } from './Mark.js';
5
+ export { computeArmPaths } from './markGeometry.js';
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure geometry for the Mark's SVG arm connectors (DMNC-1325).
3
+ *
4
+ * All coordinates are MARK-LOCAL: subtract `markRect.x/y` from every badge
5
+ * point so the returned paths live in the Mark's own box origin. This makes
6
+ * the overlay portal-free and scroll-invariant (viewport-relative rects shift
7
+ * equally for mark and badges), pinned by the translation-invariance test.
8
+ *
9
+ * No DOM, no React, no hooks — testable with plain rect objects.
10
+ */
11
+ export interface RectLike {
12
+ x: number;
13
+ y: number;
14
+ width: number;
15
+ height: number;
16
+ }
17
+ export interface ArmPathOptions {
18
+ /** Perpendicular control-point offset in px. Default 24. */
19
+ curvature?: number;
20
+ }
21
+ /**
22
+ * Build one quadratic-bezier arm path per badge, from the Mark centre to the
23
+ * midpoint of the badge edge facing the mark. Control points alternate sign
24
+ * for an organic branching feel, clamped so short arms don't balloon.
25
+ */
26
+ export declare function computeArmPaths(markRect: RectLike, badgeRects: RectLike[], options?: ArmPathOptions): string[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure geometry for the Mark's SVG arm connectors (DMNC-1325).
3
+ *
4
+ * All coordinates are MARK-LOCAL: subtract `markRect.x/y` from every badge
5
+ * point so the returned paths live in the Mark's own box origin. This makes
6
+ * the overlay portal-free and scroll-invariant (viewport-relative rects shift
7
+ * equally for mark and badges), pinned by the translation-invariance test.
8
+ *
9
+ * No DOM, no React, no hooks — testable with plain rect objects.
10
+ */
11
+ /** Round to 2 decimals so path strings are stable for assertions. */
12
+ const r2 = (n) => Math.round(n * 100) / 100;
13
+ /**
14
+ * Build one quadratic-bezier arm path per badge, from the Mark centre to the
15
+ * midpoint of the badge edge facing the mark. Control points alternate sign
16
+ * for an organic branching feel, clamped so short arms don't balloon.
17
+ */
18
+ export function computeArmPaths(markRect, badgeRects, options = {}) {
19
+ const { curvature = 24 } = options;
20
+ // jsdom / hidden guard — a zero-sized mark can't anchor arms.
21
+ if (markRect.width === 0 || markRect.height === 0)
22
+ return [];
23
+ const badges = badgeRects.filter((b) => !(b.width === 0 && b.height === 0));
24
+ if (badges.length === 0)
25
+ return [];
26
+ // Start anchor: mark centre in local coords (the blob paints over it).
27
+ const sx = markRect.width / 2;
28
+ const sy = markRect.height / 2;
29
+ const paths = [];
30
+ badges.forEach((badge, i) => {
31
+ const bx = badge.x - markRect.x;
32
+ const by = badge.y - markRect.y;
33
+ // Anchor on the badge edge that faces the mark: a badge to the right of the
34
+ // mark uses its LEFT edge; a badge to the left uses its RIGHT edge.
35
+ const ex = bx + badge.width / 2 >= sx ? bx : bx + badge.width;
36
+ const ey = by + badge.height / 2;
37
+ const dx = ex - sx;
38
+ const dy = ey - sy;
39
+ const dist = Math.hypot(dx, dy);
40
+ if (dist < 1)
41
+ return;
42
+ // Control point = segment midpoint offset along the perpendicular unit
43
+ // vector. Sign alternates per badge; the dist/3 clamp stops short arms from
44
+ // ballooning past the curvature default.
45
+ const sign = i % 2 === 0 ? 1 : -1;
46
+ const offset = sign * Math.min(curvature, dist / 3);
47
+ const mx = (sx + ex) / 2;
48
+ const my = (sy + ey) / 2;
49
+ const cx = mx + (-dy / dist) * offset;
50
+ const cy = my + (dx / dist) * offset;
51
+ paths.push(`M ${r2(sx)} ${r2(sy)} Q ${r2(cx)} ${r2(cy)} ${r2(ex)} ${r2(ey)}`);
52
+ });
53
+ return paths;
54
+ }
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Button as AriaButton } from 'react-aria-components';
4
4
  import { cn } from '../../../utils/cn.js';
5
+ import { isSafeHref } from '../../../utils/isSafeHref.js';
5
6
  const linkClasses = 'eidotter-breadcrumb__link inline-flex items-center gap-1 text-dos-text-accent opacity-70 no-underline';
6
7
  /**
7
8
  * DOS-styled Breadcrumb navigation component.
@@ -13,11 +14,14 @@ export const Breadcrumb = ({ trail = [], currentLabel, showBackArrow = true, sep
13
14
  if (item.onClick) {
14
15
  return (_jsx(AriaButton, { className: cn(linkClasses, 'bg-transparent border-0 cursor-pointer font-[inherit] p-0'), onPress: item.onClick, children: linkContent }));
15
16
  }
16
- if (item.href) {
17
+ // Guard author-supplied hrefs: unsafe schemes (javascript, data, vbscript, …)
18
+ // fall through to the non-link <span> branch below rather than rendering an anchor.
19
+ const safeHref = item.href && isSafeHref(item.href) ? item.href : undefined;
20
+ if (safeHref) {
17
21
  if (LinkComponent) {
18
- return (_jsx(LinkComponent, { href: item.href, className: linkClasses, children: linkContent }));
22
+ return (_jsx(LinkComponent, { href: safeHref, className: linkClasses, children: linkContent }));
19
23
  }
20
- return (_jsx("a", { href: item.href, className: linkClasses, children: linkContent }));
24
+ return (_jsx("a", { href: safeHref, className: linkClasses, children: linkContent }));
21
25
  }
22
26
  return _jsx("span", { className: linkClasses, children: linkContent });
23
27
  };
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+ export interface ConnectorTarget {
3
+ /** The anchor element the ribbon connects to. */
4
+ ref: React.RefObject<HTMLElement | null>;
5
+ /**
6
+ * End colour for the amber→category gradient (any CSS colour). Omit for a
7
+ * solid-amber ribbon (the blog-spine treatment).
8
+ */
9
+ color?: string;
10
+ }
11
+ export interface ConnectorProps extends Omit<React.SVGProps<SVGSVGElement>, 'ref'> {
12
+ /** The hub/source element the ribbons radiate from (e.g. the Mark). */
13
+ sourceRef: React.RefObject<HTMLElement | null>;
14
+ /** Targets to connect to. */
15
+ targets: ConnectorTarget[];
16
+ /** Ribbon weight. Default 'delicate'. */
17
+ weight?: 'delicate' | 'medium';
18
+ /** Idle sway. Default true; forced off under prefers-reduced-motion. */
19
+ sway?: boolean;
20
+ /** Bump to force a re-measure when layout shifts without a resize/mutation. */
21
+ refreshKey?: React.Key;
22
+ }
23
+ /**
24
+ * Connector — organic tapered SVG ribbons from a source node (the Mark) to N
25
+ * anchors, re-flowing as they move (DMNC-1388). Each ribbon is a filled tapered
26
+ * `<path>` (CSS strokes can't taper): amber at the yolk base → the target's
27
+ * category colour near the attach (solid amber when no colour). Aims at each
28
+ * target's centre but STOPS at the border — touching the edge, never inside.
29
+ *
30
+ * The overlay is purely decorative (`aria-hidden`, `focusable="false"`,
31
+ * `pointer-events: none`) — navigation lives in the real DOM links underneath.
32
+ * Render it inside a `position: relative` container that spans the source +
33
+ * targets; the SVG fills it (`absolute inset-0`) and its own box is the
34
+ * coordinate origin, so everything is scroll-invariant.
35
+ *
36
+ * Paths mutate imperatively (per frame during sway); React only owns the
37
+ * structure (one `<path>` + gradient per target). The amber base is
38
+ * `currentColor` (themeable via `text-dos-text-accent`); the sway loop is
39
+ * compositor-friendly and disabled under `prefers-reduced-motion`.
40
+ */
41
+ export declare const Connector: React.ForwardRefExoticComponent<ConnectorProps & React.RefAttributes<SVGSVGElement>>;
@@ -0,0 +1,165 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
4
+ import { cn } from '../../../utils/cn.js';
5
+ import { prefersReducedMotion } from '../../../utils/prefersReducedMotion.js';
6
+ import { attachPoint, ribbonPath, CONNECTOR_WEIGHTS } from './connectorGeometry.js';
7
+ const SWAY_SPEED = 0.0011;
8
+ const SWAY_AMPLITUDE = 4;
9
+ /**
10
+ * Connector — organic tapered SVG ribbons from a source node (the Mark) to N
11
+ * anchors, re-flowing as they move (DMNC-1388). Each ribbon is a filled tapered
12
+ * `<path>` (CSS strokes can't taper): amber at the yolk base → the target's
13
+ * category colour near the attach (solid amber when no colour). Aims at each
14
+ * target's centre but STOPS at the border — touching the edge, never inside.
15
+ *
16
+ * The overlay is purely decorative (`aria-hidden`, `focusable="false"`,
17
+ * `pointer-events: none`) — navigation lives in the real DOM links underneath.
18
+ * Render it inside a `position: relative` container that spans the source +
19
+ * targets; the SVG fills it (`absolute inset-0`) and its own box is the
20
+ * coordinate origin, so everything is scroll-invariant.
21
+ *
22
+ * Paths mutate imperatively (per frame during sway); React only owns the
23
+ * structure (one `<path>` + gradient per target). The amber base is
24
+ * `currentColor` (themeable via `text-dos-text-accent`); the sway loop is
25
+ * compositor-friendly and disabled under `prefers-reduced-motion`.
26
+ */
27
+ export const Connector = forwardRef(({ sourceRef, targets, weight = 'delicate', sway = true, refreshKey, className, ...rest }, ref) => {
28
+ const uid = useId().replace(/[:]/g, '');
29
+ const svgRef = useRef(null);
30
+ const pathRefs = useRef([]);
31
+ const gradRefs = useRef([]);
32
+ const rafRef = useRef(null);
33
+ // Read the live targets/weight in draw so a prop change never leaves a stale
34
+ // closure (targets objects are typically inlined by consumers).
35
+ const targetsRef = useRef(targets);
36
+ targetsRef.current = targets;
37
+ const weightRef = useRef(weight);
38
+ weightRef.current = weight;
39
+ const setSvgRef = useCallback((node) => {
40
+ svgRef.current = node;
41
+ if (typeof ref === 'function')
42
+ ref(node);
43
+ else if (ref)
44
+ ref.current = node;
45
+ }, [ref]);
46
+ const gradientId = (i) => `eidotter-connector-${uid}-${i}`;
47
+ useEffect(() => {
48
+ const svg = svgRef.current;
49
+ if (!svg)
50
+ return;
51
+ const draw = (swayT) => {
52
+ const svgRect = svg.getBoundingClientRect();
53
+ const src = sourceRef.current;
54
+ if (!src)
55
+ return;
56
+ const sr = src.getBoundingClientRect();
57
+ const source = {
58
+ x: sr.left - svgRect.left + sr.width / 2,
59
+ y: sr.top - svgRect.top + sr.height / 2,
60
+ };
61
+ const w = CONNECTOR_WEIGHTS[weightRef.current] ?? CONNECTOR_WEIGHTS.delicate;
62
+ const swaying = sway && !prefersReducedMotion();
63
+ targetsRef.current.forEach((t, i) => {
64
+ const el = t.ref.current;
65
+ const path = pathRefs.current[i];
66
+ if (!path)
67
+ return;
68
+ if (!el) {
69
+ path.setAttribute('d', '');
70
+ return;
71
+ }
72
+ const r = el.getBoundingClientRect();
73
+ if (r.width === 0 && r.height === 0) {
74
+ path.setAttribute('d', '');
75
+ return;
76
+ }
77
+ const rect = { x: r.left - svgRect.left, y: r.top - svgRect.top, width: r.width, height: r.height };
78
+ const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
79
+ const attach = attachPoint(center, source, rect);
80
+ const sign = i % 2 === 0 ? 1 : -1;
81
+ const swayAmt = swaying ? Math.sin(swayT * SWAY_SPEED + i * 1.7) * SWAY_AMPLITUDE : 0;
82
+ path.setAttribute('d', ribbonPath(source, attach, w, { sign, sway: swayAmt }));
83
+ const grad = gradRefs.current[i];
84
+ if (grad) {
85
+ grad.setAttribute('x1', String(source.x));
86
+ grad.setAttribute('y1', String(source.y));
87
+ grad.setAttribute('x2', String(attach.x));
88
+ grad.setAttribute('y2', String(attach.y));
89
+ }
90
+ });
91
+ };
92
+ let running = false;
93
+ const stop = () => {
94
+ running = false;
95
+ if (rafRef.current != null && typeof cancelAnimationFrame !== 'undefined') {
96
+ cancelAnimationFrame(rafRef.current);
97
+ rafRef.current = null;
98
+ }
99
+ };
100
+ const frame = (ts) => {
101
+ draw(ts);
102
+ if (running && typeof requestAnimationFrame !== 'undefined') {
103
+ rafRef.current = requestAnimationFrame(frame);
104
+ }
105
+ };
106
+ const start = () => {
107
+ if (sway && !prefersReducedMotion() && typeof requestAnimationFrame !== 'undefined') {
108
+ if (!running) {
109
+ running = true;
110
+ rafRef.current = requestAnimationFrame(frame);
111
+ }
112
+ }
113
+ else {
114
+ draw(0);
115
+ }
116
+ };
117
+ const remeasure = () => {
118
+ // Static re-measure (resize / mutation). The sway loop, when active,
119
+ // already redraws every frame off live rects.
120
+ if (!running)
121
+ draw(0);
122
+ };
123
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(remeasure) : undefined;
124
+ const observeTargets = () => {
125
+ ro?.disconnect();
126
+ ro?.observe(svg);
127
+ if (sourceRef.current)
128
+ ro?.observe(sourceRef.current);
129
+ targetsRef.current.forEach((t) => t.ref.current && ro?.observe(t.ref.current));
130
+ };
131
+ observeTargets();
132
+ // Catch position shifts (siblings reflowing, badges reordering) AND target
133
+ // element swaps that keep the same count: re-observe the CURRENT targets
134
+ // (mirrors Mark's re-wire) so a resize of a swapped element is still caught,
135
+ // then redraw. Only childList/subtree is watched — the draw loop's
136
+ // attribute writes (`d`, gradient coords) aren't observed, so this can't loop.
137
+ const container = svg.parentElement;
138
+ const mo = container && typeof MutationObserver !== 'undefined'
139
+ ? new MutationObserver(() => {
140
+ observeTargets();
141
+ remeasure();
142
+ })
143
+ : undefined;
144
+ if (container)
145
+ mo?.observe(container, { childList: true, subtree: true });
146
+ window.addEventListener('resize', remeasure);
147
+ start();
148
+ return () => {
149
+ stop();
150
+ ro?.disconnect();
151
+ mo?.disconnect();
152
+ window.removeEventListener('resize', remeasure);
153
+ };
154
+ // Re-wire on target-count / weight / sway / refreshKey changes. Colour
155
+ // changes flow through render (gradient stops) + the live targetsRef;
156
+ // target element swaps at the same count are re-observed by the MO above
157
+ // (or bump refreshKey for a full re-wire).
158
+ }, [sourceRef, targets.length, weight, sway, refreshKey]);
159
+ return (_jsxs("svg", { ref: setSvgRef, className: cn('eidotter-connector pointer-events-none absolute inset-0 h-full w-full overflow-visible text-dos-text-accent', className), "aria-hidden": "true", focusable: "false", ...rest, children: [_jsx("defs", { children: targets.map((t, i) => (_jsxs("linearGradient", { id: gradientId(i), gradientUnits: "userSpaceOnUse", ref: (node) => {
160
+ gradRefs.current[i] = node;
161
+ }, children: [_jsx("stop", { offset: "0%", stopColor: "currentColor" }), _jsx("stop", { offset: "78%", stopColor: t.color ?? 'currentColor' })] }, i))) }), targets.map((t, i) => (_jsx("path", { d: "", fill: `url(#${gradientId(i)})`, ref: (node) => {
162
+ pathRefs.current[i] = node;
163
+ } }, i)))] }));
164
+ });
165
+ Connector.displayName = 'Connector';
@@ -0,0 +1,50 @@
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
+ export interface Point {
11
+ x: number;
12
+ y: number;
13
+ }
14
+ export interface Rect {
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ }
20
+ export interface RibbonWeight {
21
+ /** Half-width where the ribbon leaves the yolk (thickest). */
22
+ base: number;
23
+ /** Half-width through the middle (thinnest). */
24
+ mid: number;
25
+ /** Half-width swelling back into the attach point. */
26
+ tip: number;
27
+ /** Perpendicular control-point offset for the organic bend. */
28
+ curvature: number;
29
+ }
30
+ /**
31
+ * Locked weights (2026-07-13 shape proof). `delicate` is the default — a thin
32
+ * product-chrome thread; `medium` reads a touch bolder on large surfaces.
33
+ */
34
+ export declare const CONNECTOR_WEIGHTS: Record<'delicate' | 'medium', RibbonWeight>;
35
+ /**
36
+ * The point on the target rect border along the ray from the rect centre toward
37
+ * the source — where the ribbon must STOP (touching the edge, never inside).
38
+ */
39
+ export declare function attachPoint(center: Point, source: Point, rect: Rect): Point;
40
+ export interface RibbonOptions {
41
+ /** Bend direction for the organic branch. Default 1. */
42
+ sign?: number;
43
+ /** Idle-sway perpendicular offset in px, added to the control points. Default 0. */
44
+ sway?: number;
45
+ }
46
+ /**
47
+ * Build a closed tapered-ribbon path from `source` to `attach`. Returns an SVG
48
+ * path `d` string (empty when the endpoints coincide).
49
+ */
50
+ export declare function ribbonPath(source: Point, attach: Point, weight: RibbonWeight, options?: RibbonOptions): string;