@liminis/diagrams 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A ready-made C4 playground: source on one side, a live diagram on the other.
3
+ *
4
+ * The editing shell this package's own documentation uses, and the one every
5
+ * Liminis documentation site used to keep its own copy of. Five identical
6
+ * copies drifted apart the moment the renderer gained a feature — zoom shipped
7
+ * in 0.1.3 and four of the five sites had no way to reach it — which is what
8
+ * moved it in here.
9
+ *
10
+ * Styling comes from `@liminis/diagrams/playground.css`, which must be imported
11
+ * separately. It ships working defaults and reads a handful of `--c4-*` custom
12
+ * properties, so a host can make it look native in a few lines rather than
13
+ * overriding rules.
14
+ *
15
+ * Mount it client-side only. The drag layer measures the live SVG through
16
+ * `getScreenCTM`, which does not exist during a server render — under Astro
17
+ * that means `client:only="react"` rather than `client:visible`.
18
+ */
19
+ export interface C4PlaygroundProps {
20
+ /** Initial C4-PlantUML source. */
21
+ source: string;
22
+ /**
23
+ * Whether the reader may drag nodes. `false` is a fixed illustration: drag is
24
+ * off and there is no control to turn it on. Anything else starts with drag
25
+ * enabled and offers the toggle.
26
+ */
27
+ editable?: boolean;
28
+ /** Hide the source pane — for diagrams that illustrate rather than invite editing. */
29
+ readOnly?: boolean;
30
+ /** Height of the diagram pane when inline, in CSS units. */
31
+ height?: string;
32
+ /**
33
+ * Whether to draw in dark mode.
34
+ *
35
+ * A prop rather than something detected here, for the same reason
36
+ * `C4InteractiveRenderer` takes one: this package has no idea how its host
37
+ * decides what "dark" means. A host following the `data-theme` convention can
38
+ * use the `useIsDarkMode` hook exported alongside this component and pass the
39
+ * result straight in; a host with its own theming passes its own answer.
40
+ */
41
+ isDarkMode?: boolean;
42
+ }
43
+ export default function C4Playground({ source, editable, readOnly, height, isDarkMode, }: C4PlaygroundProps): import("react").JSX.Element;
@@ -0,0 +1,204 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { parseC4, validateC4 } from '../core/parser.js';
5
+ import { C4InteractiveRenderer } from '../react/C4InteractiveRenderer.js';
6
+ import { C4ErrorDisplay } from '../react/renderer.js';
7
+ /**
8
+ * Zoom steps, rather than a continuous factor.
9
+ *
10
+ * A diagram is not a photograph: there is a size at which its labels are legible
11
+ * and sizes either side of it that are not, so the useful range is small and
12
+ * discrete. Steps also keep the control to two buttons and a readout, which is
13
+ * the entire UI anyone needs here.
14
+ */
15
+ /**
16
+ * Zoom steps, as multiples of the fitted size.
17
+ *
18
+ * 100% is the view that shows the whole diagram, not 1 SVG unit per pixel.
19
+ * That is the size the reader arrives at, so it is the one the number should
20
+ * describe — and it keeps the readout still when the lightbox opens, where the
21
+ * diagram is drawn larger but is still, conceptually, the same whole-diagram
22
+ * view. Reporting absolute scale made expanding look like a zoom: 57% became
23
+ * 100% while nothing about what you could see had changed.
24
+ *
25
+ * The range starts at fit because there is nothing below it worth seeing. Once
26
+ * the whole diagram is visible, smaller only buys whitespace.
27
+ */
28
+ const ZOOM_STEPS = [1, 1.5, 2, 3, 4];
29
+ /**
30
+ * The scale at which the whole diagram is visible in the pane it is given.
31
+ *
32
+ * Never above 1. A diagram larger than its pane should shrink to fit, but a
33
+ * small one blown up to fill a lightbox is a surprise: nothing was gained and
34
+ * the reader now has to work out what the size means. Fitting is about seeing
35
+ * all of it, not about filling space.
36
+ *
37
+ * Returns null when there is nothing to measure yet — before hydration, or if
38
+ * the pane has no size because it is display:none.
39
+ */
40
+ function fitScale(canvas) {
41
+ const svg = canvas?.querySelector('svg');
42
+ if (!canvas || !svg)
43
+ return null;
44
+ const { width: diagramWidth, height: diagramHeight } = svg.viewBox.baseVal;
45
+ if (!diagramWidth || !diagramHeight)
46
+ return null;
47
+ // The pane's usable space, less its own padding — measuring the border box
48
+ // would overshoot by the padding and clip what fitting is meant to reveal.
49
+ const style = getComputedStyle(canvas);
50
+ // `|| 0` on each: a computed padding is always a px string in a browser, but
51
+ // it can be empty where no stylesheet has been applied, and `parseFloat('')`
52
+ // is NaN. One NaN makes the whole fit NaN, which reads as "no fitting at all"
53
+ // rather than as an error — the diagram silently opens at actual size.
54
+ const px = (value) => parseFloat(value) || 0;
55
+ const available = {
56
+ width: canvas.clientWidth - px(style.paddingLeft) - px(style.paddingRight),
57
+ height: canvas.clientHeight - px(style.paddingTop) - px(style.paddingBottom),
58
+ };
59
+ if (!(available.width > 0) || !(available.height > 0))
60
+ return null;
61
+ return Math.min(1, available.width / diagramWidth, available.height / diagramHeight);
62
+ }
63
+ export default function C4Playground({ source, editable = true, readOnly = false, height = '22rem', isDarkMode = false, }) {
64
+ const [text, setText] = useState(source.trim());
65
+ const [positions, setPositions] = useState({});
66
+ const [isEditMode, setIsEditMode] = useState(editable);
67
+ const [isExpanded, setIsExpanded] = useState(false);
68
+ // Zoom as a multiple of the fitted size: 1 is "the whole diagram", which is
69
+ // where every view starts. The absolute scale handed to the renderer is this
70
+ // times whatever fitting currently works out to.
71
+ const [relativeZoom, setRelativeZoom] = useState(1);
72
+ const [fittedZoom, setFittedZoom] = useState(1);
73
+ const canvasRef = useRef(null);
74
+ const panelRef = useRef(null);
75
+ const returnFocusTo = useRef(null);
76
+ // Escape closes; the page behind must not scroll while the lightbox is open.
77
+ //
78
+ // Expanding is a CSS overlay rather than the Fullscreen API on purpose: iOS
79
+ // Safari implements `requestFullscreen` for video only, so the API is a no-op
80
+ // on an iPad — a device this package's touch support exists for.
81
+ useEffect(() => {
82
+ if (!isExpanded)
83
+ return;
84
+ // Where focus came from, so closing puts it back rather than dumping the
85
+ // reader at the top of the document.
86
+ returnFocusTo.current = document.activeElement;
87
+ const panel = panelRef.current;
88
+ const focusable = () => Array.from(panel?.querySelectorAll('button, [href], input, textarea, select, [tabindex]:not([tabindex="-1"])') ?? []).filter((el) => !el.hasAttribute('disabled'));
89
+ focusable()[0]?.focus();
90
+ // `aria-modal` promises the rest of the page is unreachable, and a promise
91
+ // the markup does not keep is worse than not making it: a keyboard user
92
+ // tabs into content hidden behind the backdrop with no way to tell where
93
+ // they are. Tab is cycled within the panel to make it true.
94
+ const onKey = (e) => {
95
+ if (e.key === 'Escape') {
96
+ setIsExpanded(false);
97
+ return;
98
+ }
99
+ if (e.key !== 'Tab')
100
+ return;
101
+ const items = focusable();
102
+ if (items.length === 0)
103
+ return;
104
+ const first = items[0];
105
+ const last = items[items.length - 1];
106
+ // Focus can be outside the panel entirely — the browser's own UI hands it
107
+ // back to the document, an extension moves it. Both directions have to
108
+ // catch that case, or Tab walks into the page behind the backdrop, which
109
+ // is exactly what aria-modal promises cannot happen.
110
+ const active = document.activeElement;
111
+ const escaped = !panel?.contains(active);
112
+ if (e.shiftKey && (escaped || active === first)) {
113
+ e.preventDefault();
114
+ last.focus();
115
+ }
116
+ else if (!e.shiftKey && (escaped || active === last)) {
117
+ e.preventDefault();
118
+ first.focus();
119
+ }
120
+ };
121
+ window.addEventListener('keydown', onKey);
122
+ const previous = document.body.style.overflow;
123
+ document.body.style.overflow = 'hidden';
124
+ return () => {
125
+ window.removeEventListener('keydown', onKey);
126
+ document.body.style.overflow = previous;
127
+ // Guarded: the element may have been unmounted while the panel was open.
128
+ const target = returnFocusTo.current;
129
+ if (target instanceof HTMLElement && target.isConnected)
130
+ target.focus();
131
+ };
132
+ }, [isExpanded]);
133
+ // Parse on every keystroke. The parser is pure and fast enough that
134
+ // debouncing would add latency without buying anything.
135
+ const parsed = useMemo(() => {
136
+ const result = parseC4(text);
137
+ if (result.diagram)
138
+ result.errors.push(...validateC4(result.diagram));
139
+ return result;
140
+ }, [text]);
141
+ // What the renderer is actually given. Fitting produces an arbitrary scale —
142
+ // 0.57 on this site's architecture page — and the reader's multiple applies
143
+ // on top of it.
144
+ const zoom = fittedZoom * relativeZoom;
145
+ const stepZoom = useCallback((direction) => {
146
+ setRelativeZoom((current) => {
147
+ const next = direction === 1
148
+ ? ZOOM_STEPS.find((step) => step > current + 0.001)
149
+ : [...ZOOM_STEPS].reverse().find((step) => step < current - 0.001);
150
+ return next ?? current;
151
+ });
152
+ }, []);
153
+ // Declared before the effect below, which depends on it: the source pane's
154
+ // presence changes how much width the diagram has to fit into.
155
+ const showSource = !readOnly;
156
+ // Measure after layout rather than after paint, so the diagram is never shown
157
+ // at the wrong size for a frame and then corrected — which reads as a flinch.
158
+ useLayoutEffect(() => {
159
+ const canvas = canvasRef.current;
160
+ if (!canvas)
161
+ return;
162
+ const measure = () => {
163
+ const fit = fitScale(canvas);
164
+ if (fit !== null)
165
+ setFittedZoom(fit);
166
+ };
167
+ measure();
168
+ // The pane resizes when the window does, when the lightbox opens, and when
169
+ // the source pane is shown or hidden. Observing it covers all three without
170
+ // enumerating them.
171
+ const observer = new ResizeObserver(measure);
172
+ observer.observe(canvas);
173
+ return () => observer.disconnect();
174
+ // `text` is a dependency because editing the source changes the diagram's
175
+ // dimensions, and `isExpanded` because the pane it has to fit changes.
176
+ }, [text, isExpanded, showSource]);
177
+ // Expanding is a request to see the whole diagram, so it returns to fitting
178
+ // even if the reader had zoomed in beforehand. Collapsing does the same, since
179
+ // the inline pane is a different size again.
180
+ useEffect(() => {
181
+ setRelativeZoom(1);
182
+ }, [isExpanded]);
183
+ const positionCount = Object.keys(positions).length;
184
+ const panel = (_jsxs("div", { ref: panelRef, className: 'c4-playground not-content' +
185
+ (isExpanded ? ' c4-playground--expanded' : '') +
186
+ (showSource ? '' : ' c4-playground--diagram-only'), role: isExpanded ? 'dialog' : undefined, "aria-modal": isExpanded || undefined, "aria-label": isExpanded ? 'C4 diagram, expanded' : undefined, children: [_jsxs("div", { className: "c4-playground__bar", children: [editable && (_jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: isEditMode, onChange: (e) => setIsEditMode(e.target.checked) }), ' ', "Drag to reposition"] })), editable && (_jsx("button", { type: "button", onClick: () => setPositions({}), disabled: positionCount === 0, children: "Reset layout" })), _jsx("span", { className: "c4-playground__hint", children: !editable
187
+ ? 'laid out by dagre'
188
+ : positionCount > 0
189
+ ? `${positionCount} positions held in memory`
190
+ : 'laid out by dagre' }), _jsxs("div", { className: "c4-playground__zoom", children: [_jsx("button", { type: "button", onClick: () => stepZoom(-1), disabled: relativeZoom <= ZOOM_STEPS[0] + 0.001, "aria-label": "Zoom out", title: "Zoom out", children: "\u2212" }), _jsxs("button", { type: "button", onClick: () => setRelativeZoom(1), disabled: relativeZoom === 1, "aria-label": "Zoom to fit", title: "Zoom to fit", children: [Math.round(relativeZoom * 100), "%"] }), _jsx("button", { type: "button", onClick: () => stepZoom(1), disabled: relativeZoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1] - 0.001, "aria-label": "Zoom in", title: "Zoom in", children: "+" })] }), _jsx("button", { type: "button", className: "c4-playground__expand", onClick: () => setIsExpanded((v) => !v), "aria-pressed": isExpanded, "aria-label": isExpanded ? 'Close expanded diagram' : 'Expand diagram', title: isExpanded ? 'Close (Esc)' : 'Expand', children: isExpanded ? '✕' : '⤡' })] }), _jsxs("div", { className: "c4-playground__panes", style: { minHeight: isExpanded ? undefined : height }, children: [showSource && (_jsx("textarea", { className: "c4-playground__source", value: text, spellCheck: false, onChange: (e) => setText(e.target.value), "aria-label": "C4-PlantUML source" })), _jsx("div", { className: "c4-playground__canvas", ref: canvasRef, children: parsed.diagram && parsed.errors.length === 0 ? (_jsx(C4InteractiveRenderer, { diagram: parsed.diagram, isDarkMode: isDarkMode, isEditMode: editable && isEditMode, manualPositions: positions, onPositionChange: setPositions, zoom: zoom })) : (_jsx(C4ErrorDisplay, { errors: parsed.errors, isDarkMode: isDarkMode })) })] })] }));
191
+ if (!isExpanded)
192
+ return panel;
193
+ // Portalled to <body> rather than rendered in place. `position: fixed` resolves
194
+ // against the nearest ancestor with a transform, filter or containment rather
195
+ // than the viewport, and z-index is confined to that ancestor's stacking
196
+ // context — which is why the first attempt sat *under* Starlight's header and
197
+ // table of contents no matter how high its z-index went. Escaping the content
198
+ // tree is the fix; raising the number is not.
199
+ //
200
+ // The backdrop is a real element rather than a pseudo-element so clicking
201
+ // outside closes. The panel is its sibling, not its child, so a click inside
202
+ // the panel never reaches the backdrop's handler.
203
+ return createPortal(_jsxs("div", { className: "c4-playground__lightbox", children: [_jsx("div", { className: "c4-playground__backdrop", onClick: () => setIsExpanded(false), "aria-hidden": "true" }), panel] }), document.body);
204
+ }
@@ -0,0 +1,166 @@
1
+ /* Styles for C4Playground (@liminis/diagrams/playground).
2
+ *
3
+ * Import once, anywhere in your app:
4
+ *
5
+ * import '@liminis/diagrams/playground.css'
6
+ *
7
+ * Every colour, font and size below resolves through a `--c4-*` custom property
8
+ * with a working default, so this looks reasonable in a page that does nothing.
9
+ * To make it look native, set the properties — not the rules:
10
+ *
11
+ * .c4-playground {
12
+ * --c4-border: var(--sl-color-gray-5);
13
+ * --c4-bg: var(--sl-color-bg);
14
+ * --c4-muted: var(--sl-color-gray-3);
15
+ * --c4-font-mono: var(--sl-font-mono);
16
+ * }
17
+ *
18
+ * Overriding properties survives an upgrade in a way overriding rules does not.
19
+ */
20
+
21
+ .c4-playground {
22
+ /* Defaults, deliberately plain. A host that sets nothing still gets something
23
+ legible in both colour schemes; a host that sets these gets its own look. */
24
+ --c4-border: #d4d4d8;
25
+ --c4-bg: #ffffff;
26
+ --c4-muted: #6b7280;
27
+ --c4-accent: #3b82f6;
28
+ --c4-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
29
+ --c4-text-sm: 0.875rem;
30
+ --c4-text-xs: 0.75rem;
31
+ }
32
+
33
+ @media (prefers-color-scheme: dark) {
34
+ .c4-playground {
35
+ --c4-border: #3f3f46;
36
+ --c4-bg: #18181b;
37
+ --c4-muted: #a1a1aa;
38
+ }
39
+ }
40
+
41
+ .c4-playground {
42
+ border: 1px solid var(--c4-border);
43
+ border-radius: 0.5rem;
44
+ overflow: hidden;
45
+ margin: 1.5rem 0;
46
+ background: var(--c4-bg);
47
+ }
48
+ .c4-playground__bar {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 1rem;
52
+ padding: 0.5rem 0.75rem;
53
+ border-bottom: 1px solid var(--c4-border);
54
+ font-size: var(--c4-text-sm);
55
+ flex-wrap: wrap;
56
+ }
57
+ .c4-playground__bar label { display: inline-flex; align-items: center; gap: 0.35rem; }
58
+ .c4-playground__bar button {
59
+ border: 1px solid var(--c4-border);
60
+ background: transparent;
61
+ color: inherit;
62
+ border-radius: 0.3rem;
63
+ padding: 0.15rem 0.6rem;
64
+ cursor: pointer;
65
+ font: inherit;
66
+ }
67
+ .c4-playground__bar button:disabled { opacity: 0.45; cursor: default; }
68
+ .c4-playground__hint { color: var(--c4-muted); }
69
+ .c4-playground__panes { display: grid; grid-template-columns: minmax(0, 20rem) minmax(0, 1fr); }
70
+ @media (max-width: 50rem) { .c4-playground__panes { grid-template-columns: 1fr; } }
71
+ .c4-playground__source {
72
+ border: 0;
73
+ border-right: 1px solid var(--c4-border);
74
+ padding: 0.75rem;
75
+ font-family: var(--c4-font-mono);
76
+ font-size: var(--c4-text-xs);
77
+ line-height: 1.5;
78
+ resize: vertical;
79
+ background: transparent;
80
+ color: inherit;
81
+ min-height: 100%;
82
+ }
83
+ .c4-playground__source:focus { outline: 2px solid var(--c4-accent); outline-offset: -2px; }
84
+ .c4-playground__canvas {
85
+ overflow: auto;
86
+ padding: 0.5rem;
87
+ display: flex;
88
+ }
89
+
90
+ /* Centred with auto margins, never `justify-content`/`align-items: center`.
91
+ Those centre the overflow too: a diagram wider than the pane spills equally
92
+ left and right, and the left half cannot be scrolled to — the scroll origin
93
+ is the container's start edge, which the centring has already moved content
94
+ past. It reads as a diagram with its left side cut off and no way to reach
95
+ it, because that is exactly what it is.
96
+
97
+ Auto margins collapse to zero once there is no free space, so the same rule
98
+ centres a small diagram and lets a large one start at its true origin and
99
+ scroll. The child here is the renderer's own positioning wrapper, not the
100
+ <svg>: that wrapper is the flex item, which is why a `max-width` on the svg
101
+ never constrained anything. */
102
+ .c4-playground__canvas > * { margin: auto; }
103
+
104
+ /* Lightbox. The first attempt was inset:0 with no backdrop — a takeover, not a
105
+ modal. The second used absolute + inset on the panel, which let the content
106
+ dictate the height and pushed it past the viewport.
107
+
108
+ This version centres with flex and puts the margin on the *container* as
109
+ padding, so a gap on all four sides is guaranteed regardless of content. */
110
+ .c4-playground__lightbox {
111
+ position: fixed;
112
+ inset: 0;
113
+ /* Above Starlight's header (z-index 10) and mobile nav. A body child, so
114
+ this number is not competing inside a nested stacking context. */
115
+ z-index: 9999;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ padding: 4vh 4vw;
120
+ }
121
+ .c4-playground__backdrop {
122
+ position: absolute;
123
+ inset: 0;
124
+ background: rgb(0 0 0 / 0.55);
125
+ backdrop-filter: blur(2px);
126
+ }
127
+ .c4-playground--expanded {
128
+ position: relative;
129
+ width: 100%;
130
+ height: 100%;
131
+ max-width: 90rem;
132
+ display: flex;
133
+ flex-direction: column;
134
+ border-radius: 0.75rem;
135
+ box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.45);
136
+ background: var(--c4-bg);
137
+ overflow: hidden;
138
+ }
139
+ .c4-playground--expanded .c4-playground__panes { flex: 1; min-height: 0; }
140
+ .c4-playground--expanded .c4-playground__source,
141
+ .c4-playground--expanded .c4-playground__canvas { min-height: 0; overflow: auto; }
142
+
143
+ /* Diagram-only: an illustration, not an invitation to edit. */
144
+ .c4-playground--diagram-only .c4-playground__panes { grid-template-columns: 1fr; }
145
+
146
+ .c4-playground__zoom {
147
+ display: inline-flex;
148
+ align-items: center;
149
+ gap: 0.15rem;
150
+ margin-left: auto;
151
+ }
152
+ .c4-playground__zoom button {
153
+ min-width: 2rem;
154
+ padding: 0.2rem 0.4rem;
155
+ line-height: 1;
156
+ font-variant-numeric: tabular-nums;
157
+ }
158
+ /* The readout doubles as the reset control, disabled at 100% rather than
159
+ hidden — a control that vanishes when it does nothing is one the reader has
160
+ to rediscover. Disabled styling comes from the bar's own button rule. */
161
+
162
+ .c4-playground__expand {
163
+ margin-left: 0.25rem;
164
+ line-height: 1;
165
+ padding: 0.2rem 0.5rem;
166
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Track the site's current theme.
3
+ *
4
+ * `C4InteractiveRenderer` takes `isDarkMode` as a prop rather than reading the
5
+ * document itself — deliberately, since the library has no idea how its host
6
+ * decides what "dark" means. That makes keeping it current the host's job, and
7
+ * a host that reads the theme once at mount has a diagram that keeps last
8
+ * night's colours until something else forces a re-render. Clicking one used to
9
+ * be what did it here, which is not a feature.
10
+ *
11
+ * Two sources have to be watched, because Starlight has three states:
12
+ *
13
+ * - An explicit choice sets `data-theme="dark"|"light"` on `<html>`, so the
14
+ * attribute is observed.
15
+ * - The default "auto" sets nothing, and the theme follows the OS. So
16
+ * `prefers-color-scheme` is watched too — otherwise a diagram would not
17
+ * follow the system flipping to dark at sunset.
18
+ */
19
+ export declare function useIsDarkMode(): boolean;
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from 'react';
2
+ /**
3
+ * Track the site's current theme.
4
+ *
5
+ * `C4InteractiveRenderer` takes `isDarkMode` as a prop rather than reading the
6
+ * document itself — deliberately, since the library has no idea how its host
7
+ * decides what "dark" means. That makes keeping it current the host's job, and
8
+ * a host that reads the theme once at mount has a diagram that keeps last
9
+ * night's colours until something else forces a re-render. Clicking one used to
10
+ * be what did it here, which is not a feature.
11
+ *
12
+ * Two sources have to be watched, because Starlight has three states:
13
+ *
14
+ * - An explicit choice sets `data-theme="dark"|"light"` on `<html>`, so the
15
+ * attribute is observed.
16
+ * - The default "auto" sets nothing, and the theme follows the OS. So
17
+ * `prefers-color-scheme` is watched too — otherwise a diagram would not
18
+ * follow the system flipping to dark at sunset.
19
+ */
20
+ export function useIsDarkMode() {
21
+ const [isDark, setIsDark] = useState(false);
22
+ useEffect(() => {
23
+ const read = () => {
24
+ const explicit = document.documentElement.dataset.theme;
25
+ if (explicit === 'dark')
26
+ return true;
27
+ if (explicit === 'light')
28
+ return false;
29
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
30
+ };
31
+ setIsDark(read());
32
+ const observer = new MutationObserver(() => setIsDark(read()));
33
+ observer.observe(document.documentElement, {
34
+ attributes: true,
35
+ attributeFilter: ['data-theme'],
36
+ });
37
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
38
+ const onMedia = () => setIsDark(read());
39
+ media.addEventListener('change', onMedia);
40
+ return () => {
41
+ observer.disconnect();
42
+ media.removeEventListener('change', onMedia);
43
+ };
44
+ }, []);
45
+ return isDark;
46
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@liminis/diagrams/playground` — a ready-made C4 editing surface.
3
+ *
4
+ * A React component with a source pane, a live draggable diagram, zoom, and an
5
+ * expand-to-lightbox affordance. This is the shell this package's own
6
+ * documentation uses; it lives here because five documentation sites were
7
+ * keeping identical copies of it, and they drifted the moment the renderer
8
+ * gained a feature.
9
+ *
10
+ * Requires `@liminis/diagrams/playground.css`, imported separately — bundlers
11
+ * differ too much about CSS-in-package for importing it from here to be safe.
12
+ *
13
+ * React and react-dom are optional peer dependencies, as they are for `./react`.
14
+ * Nothing in `./core` reaches this file.
15
+ */
16
+ export { default as C4Playground } from './playground/C4Playground.js';
17
+ export type { C4PlaygroundProps } from './playground/C4Playground.js';
18
+ /**
19
+ * Tracks a host that follows the `data-theme="dark"|"light"` convention, falling
20
+ * back to `prefers-color-scheme`. Entirely optional: `C4Playground` takes
21
+ * `isDarkMode` as a prop, and a host with its own theming should pass its own
22
+ * answer rather than use this.
23
+ */
24
+ export { useIsDarkMode } from './playground/useIsDarkMode.js';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@liminis/diagrams/playground` — a ready-made C4 editing surface.
3
+ *
4
+ * A React component with a source pane, a live draggable diagram, zoom, and an
5
+ * expand-to-lightbox affordance. This is the shell this package's own
6
+ * documentation uses; it lives here because five documentation sites were
7
+ * keeping identical copies of it, and they drifted the moment the renderer
8
+ * gained a feature.
9
+ *
10
+ * Requires `@liminis/diagrams/playground.css`, imported separately — bundlers
11
+ * differ too much about CSS-in-package for importing it from here to be safe.
12
+ *
13
+ * React and react-dom are optional peer dependencies, as they are for `./react`.
14
+ * Nothing in `./core` reaches this file.
15
+ */
16
+ export { default as C4Playground } from './playground/C4Playground.js';
17
+ /**
18
+ * Tracks a host that follows the `data-theme="dark"|"light"` convention, falling
19
+ * back to `prefers-color-scheme`. Entirely optional: `C4Playground` takes
20
+ * `isDarkMode` as a prop, and a host with its own theming should pass its own
21
+ * answer rather than use this.
22
+ */
23
+ export { useIsDarkMode } from './playground/useIsDarkMode.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liminis/diagrams",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "C4 architecture diagrams: parse C4-PlantUML, lay out with dagre, render to SVG",
5
5
  "license": "MIT",
6
6
  "//repository": "Not cosmetic, and not optional. npm matches this URL against the GitHub Actions OIDC claim when publishing with --provenance; without it the registry rejects the publish outright (E422) after the release tag has already been cut. That is exactly how 0.1.0's first release attempt failed (#6). The `git+https://` scheme and the `.git` suffix are both part of the match \u2014 the SSH form does not work.",
@@ -48,6 +48,11 @@
48
48
  "types": "./dist/react.d.ts",
49
49
  "default": "./dist/react.js"
50
50
  },
51
+ "./playground": {
52
+ "types": "./dist/playground.d.ts",
53
+ "default": "./dist/playground.js"
54
+ },
55
+ "./playground.css": "./dist/playground/playground.css",
51
56
  "./server": {
52
57
  "types": "./dist/server.d.ts",
53
58
  "default": "./dist/server.js"
@@ -61,7 +66,7 @@
61
66
  "dist"
62
67
  ],
63
68
  "scripts": {
64
- "build": "pnpm run clean && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths",
69
+ "build": "pnpm run clean && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && node scripts/copy-assets.mjs",
65
70
  "clean": "rm -rf dist",
66
71
  "prepack": "pnpm run build",
67
72
  "prepublishOnly": "node scripts/guard-publish.mjs",