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.
- package/README.md +19 -2
- package/dist/components/Brand/components/Mark.d.ts +55 -0
- package/dist/components/Brand/components/Mark.js +87 -0
- package/dist/components/Brand/components/index.d.ts +4 -0
- package/dist/components/Brand/components/index.js +2 -0
- package/dist/components/Brand/components/markGeometry.d.ts +26 -0
- package/dist/components/Brand/components/markGeometry.js +54 -0
- package/dist/components/Brand/components/markGeometry.test.d.ts +1 -0
- package/dist/components/Breadcrumb/components/Breadcrumb.js +7 -3
- package/dist/components/Connector/components/Connector.d.ts +41 -0
- package/dist/components/Connector/components/Connector.js +165 -0
- package/dist/components/Connector/components/connectorGeometry.d.ts +50 -0
- package/dist/components/Connector/components/connectorGeometry.js +102 -0
- package/dist/components/Connector/components/connectorGeometry.test.d.ts +1 -0
- package/dist/components/Connector/components/index.d.ts +4 -0
- package/dist/components/Connector/components/index.js +2 -0
- package/dist/components/Connector/index.d.ts +2 -0
- package/dist/components/Connector/index.js +1 -0
- package/dist/components/DecayStrip/components/DecayStrip.d.ts +60 -0
- package/dist/components/DecayStrip/components/DecayStrip.js +99 -0
- package/dist/components/DecayStrip/components/index.d.ts +3 -0
- package/dist/components/DecayStrip/components/index.js +1 -0
- package/dist/components/DecayStrip/components/resample.d.ts +34 -0
- package/dist/components/DecayStrip/components/resample.js +44 -0
- package/dist/components/DecayStrip/components/resample.test.d.ts +1 -0
- package/dist/components/DecayStrip/index.d.ts +2 -0
- package/dist/components/DecayStrip/index.js +1 -0
- package/dist/components/Footer/components/Footer.js +6 -1
- package/dist/components/Gauge/components/Gauge.d.ts +43 -0
- package/dist/components/Gauge/components/Gauge.js +70 -0
- package/dist/components/Gauge/components/index.d.ts +2 -0
- package/dist/components/Gauge/components/index.js +1 -0
- package/dist/components/Gauge/index.d.ts +2 -0
- package/dist/components/Gauge/index.js +1 -0
- package/dist/components/Header/components/Header.d.ts +31 -0
- package/dist/components/Header/components/Header.js +14 -3
- package/dist/components/Header/components/HeaderContextRow.d.ts +33 -0
- package/dist/components/Header/components/HeaderContextRow.js +58 -0
- package/dist/components/Header/components/index.d.ts +1 -1
- package/dist/components/Header/index.d.ts +1 -1
- package/dist/components/Icon/components/Icon.d.ts +7 -0
- package/dist/components/Icon/components/Icon.js +9 -0
- package/dist/components/Icon/components/index.d.ts +1 -1
- package/dist/components/Icon/components/index.js +1 -1
- package/dist/components/InlineExpand/components/InlineExpand.d.ts +5 -3
- package/dist/components/InlineExpand/components/InlineExpand.js +15 -13
- package/dist/components/InlineLink/components/InlineLink.js +5 -9
- package/dist/components/Meter/components/Meter.d.ts +45 -0
- package/dist/components/Meter/components/Meter.js +79 -0
- package/dist/components/Meter/components/MeterGroup.d.ts +26 -0
- package/dist/components/Meter/components/MeterGroup.js +28 -0
- package/dist/components/Meter/components/index.d.ts +4 -0
- package/dist/components/Meter/components/index.js +2 -0
- package/dist/components/Meter/index.d.ts +2 -0
- package/dist/components/Meter/index.js +1 -0
- package/dist/components/Nav/components/Nav.js +14 -2
- package/dist/components/ProgressRing/components/ProgressRing.d.ts +5 -0
- package/dist/components/ProgressRing/components/ProgressRing.js +10 -2
- package/dist/components/RangeBar/components/RangeBar.d.ts +50 -0
- package/dist/components/RangeBar/components/RangeBar.js +90 -0
- package/dist/components/RangeBar/components/index.d.ts +2 -0
- package/dist/components/RangeBar/components/index.js +1 -0
- package/dist/components/RangeBar/index.d.ts +2 -0
- package/dist/components/RangeBar/index.js +1 -0
- package/dist/components/SeriesBars/components/SeriesBars.d.ts +43 -0
- package/dist/components/SeriesBars/components/SeriesBars.js +64 -0
- package/dist/components/SeriesBars/components/index.d.ts +2 -0
- package/dist/components/SeriesBars/components/index.js +1 -0
- package/dist/components/SeriesBars/index.d.ts +2 -0
- package/dist/components/SeriesBars/index.js +1 -0
- package/dist/components/Stat/components/Stat.d.ts +6 -0
- package/dist/components/Stat/components/Stat.js +2 -2
- package/dist/components/TimelineFeed/components/TimelineFeed.d.ts +37 -0
- package/dist/components/TimelineFeed/components/TimelineFeed.js +59 -0
- package/dist/components/TimelineFeed/components/index.d.ts +2 -0
- package/dist/components/TimelineFeed/components/index.js +1 -0
- package/dist/components/TimelineFeed/index.d.ts +2 -0
- package/dist/components/TimelineFeed/index.js +1 -0
- package/dist/components/TimelinePage/components/TimelinePage.d.ts +35 -0
- package/dist/components/TimelinePage/components/TimelinePage.js +77 -0
- package/dist/components/TimelinePage/components/index.d.ts +2 -0
- package/dist/components/TimelinePage/components/index.js +1 -0
- package/dist/components/TimelinePage/index.d.ts +2 -0
- package/dist/components/TimelinePage/index.js +1 -0
- package/dist/components/VitalsStrip/components/VitalsStrip.d.ts +40 -0
- package/dist/components/VitalsStrip/components/VitalsStrip.js +73 -0
- package/dist/components/VitalsStrip/components/index.d.ts +2 -0
- package/dist/components/VitalsStrip/components/index.js +1 -0
- package/dist/components/VitalsStrip/index.d.ts +2 -0
- package/dist/components/VitalsStrip/index.js +1 -0
- package/dist/components/registry.d.ts +1 -1
- package/dist/components/registry.js +254 -2
- package/dist/eidotter.css +1 -1
- package/dist/index.d.ts +25 -5
- package/dist/index.es.js +3897 -1902
- package/dist/index.umd.js +3 -3
- package/dist/utils/health/formatValue.d.ts +29 -0
- package/dist/utils/health/formatValue.js +46 -0
- package/dist/utils/health/formatValue.test.d.ts +1 -0
- package/dist/utils/health/healthPatterns.test.d.ts +1 -0
- package/dist/utils/health/index.d.ts +6 -0
- package/dist/utils/health/index.js +3 -0
- package/dist/utils/health/labels.d.ts +6 -0
- package/dist/utils/health/labels.js +18 -0
- package/dist/utils/health/resolveBand.d.ts +12 -0
- package/dist/utils/health/resolveBand.js +29 -0
- package/dist/utils/health/resolveBand.test.d.ts +1 -0
- package/dist/utils/health/types.d.ts +25 -0
- package/dist/utils/health/types.js +2 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/isSafeHref.d.ts +26 -1
- package/dist/utils/isSafeHref.js +27 -15
- package/dist/utils/isSafeHref.test.d.ts +1 -0
- package/dist/utils/isSafeUrl.d.ts +5 -0
- package/dist/utils/isSafeUrl.js +5 -0
- package/guidelines/README.md +1 -0
- package/guidelines/components.md +25 -0
- package/guidelines/health-metabolism-patterns.md +212 -0
- package/llms.txt +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
[](./LICENSE.md)
|
|
16
16
|
[](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.
|
|
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
|
-
|
|
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 & 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 & 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';
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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:
|
|
22
|
+
return (_jsx(LinkComponent, { href: safeHref, className: linkClasses, children: linkContent }));
|
|
19
23
|
}
|
|
20
|
-
return (_jsx("a", { href:
|
|
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;
|