@principal-ai/subsystems-react 0.56.2 → 0.56.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.
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/DecorationPanel.d.ts +37 -0
- package/dist/subsystem/DecorationPanel.d.ts.map +1 -0
- package/dist/subsystem/DecorationPanel.js +147 -0
- package/dist/subsystem/DecorationPanel.js.map +1 -0
- package/dist/subsystem/DiagnosticToggle.d.ts +7 -1
- package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
- package/dist/subsystem/DiagnosticToggle.js +8 -7
- package/dist/subsystem/DiagnosticToggle.js.map +1 -1
- package/dist/subsystem/FileDrawer.d.ts +3 -1
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +74 -8
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/GraphSidebar.d.ts +4 -2
- package/dist/subsystem/GraphSidebar.d.ts.map +1 -1
- package/dist/subsystem/GraphSidebar.js +3 -1
- package/dist/subsystem/GraphSidebar.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -13
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +112 -272
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
- package/dist/subsystem/TrailStepsNav.js +6 -4
- package/dist/subsystem/TrailStepsNav.js.map +1 -1
- package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
- package/dist/subsystem/TrailsPanel.js +14 -10
- package/dist/subsystem/TrailsPanel.js.map +1 -1
- package/dist/subsystem/decoration.d.ts +112 -0
- package/dist/subsystem/decoration.d.ts.map +1 -0
- package/dist/subsystem/decoration.js +54 -0
- package/dist/subsystem/decoration.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +37 -0
- package/src/stories/Canvases/C4/C4ToComponentDrill.stories.tsx +38 -71
- package/src/stories/Canvases/Subsystem/GraphTitle.stories.tsx +1 -2
- package/src/stories/Canvases/Subsystem/StepEmphasis.stories.tsx +528 -0
- package/src/stories/Components/DecorationPanel.stories.tsx +160 -0
- package/src/subsystem/DecorationPanel.test.tsx +128 -0
- package/src/subsystem/DecorationPanel.tsx +334 -0
- package/src/subsystem/DiagnosticToggle.tsx +20 -5
- package/src/subsystem/FileDrawer.test.tsx +103 -0
- package/src/subsystem/FileDrawer.tsx +85 -6
- package/src/subsystem/GraphSidebar.test.tsx +18 -0
- package/src/subsystem/GraphSidebar.tsx +6 -1
- package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +210 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +185 -414
- package/src/subsystem/TrailStepsNav.test.tsx +32 -0
- package/src/subsystem/TrailStepsNav.tsx +6 -4
- package/src/subsystem/TrailsPanel.test.tsx +20 -0
- package/src/subsystem/TrailsPanel.tsx +11 -1
- package/src/subsystem/decoration.test.ts +72 -0
- package/src/subsystem/decoration.ts +151 -0
- package/src/subsystem/stepEmphasis.test.ts +110 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { Window } from 'happy-dom';
|
|
3
|
+
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
|
|
4
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
5
|
+
import { DecorationPanel, type DecorationPanelProps } from './DecorationPanel';
|
|
6
|
+
import { decorationTrailKey, type SubsystemDecoration } from './decoration';
|
|
7
|
+
|
|
8
|
+
// Same DOM shim as TrailsPanel.test: happy-dom lacks ResizeObserver, which
|
|
9
|
+
// themed-markdown uses to measure its slide container.
|
|
10
|
+
const dom = new Window();
|
|
11
|
+
const globals = globalThis as unknown as Record<string, unknown>;
|
|
12
|
+
globals.window = dom;
|
|
13
|
+
globals.document = dom.document;
|
|
14
|
+
globals.navigator = dom.navigator;
|
|
15
|
+
globals.HTMLElement = dom.HTMLElement;
|
|
16
|
+
globals.Node = dom.Node;
|
|
17
|
+
globals.Element = dom.Element;
|
|
18
|
+
globals.Event = dom.Event;
|
|
19
|
+
globals.MouseEvent = dom.MouseEvent;
|
|
20
|
+
globals.FocusEvent = dom.FocusEvent;
|
|
21
|
+
globals.KeyboardEvent = dom.KeyboardEvent;
|
|
22
|
+
globals.getComputedStyle = dom.getComputedStyle.bind(dom);
|
|
23
|
+
globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
|
|
24
|
+
globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
|
|
25
|
+
globals.localStorage = dom.localStorage;
|
|
26
|
+
globals.sessionStorage = dom.sessionStorage;
|
|
27
|
+
globals.matchMedia = dom.matchMedia.bind(dom);
|
|
28
|
+
globals.ResizeObserver = class {
|
|
29
|
+
observe() {}
|
|
30
|
+
unobserve() {}
|
|
31
|
+
disconnect() {}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
afterEach(cleanup);
|
|
35
|
+
|
|
36
|
+
const decoration: SubsystemDecoration = {
|
|
37
|
+
version: 1,
|
|
38
|
+
id: 'dec-sync',
|
|
39
|
+
title: 'Local-first sync',
|
|
40
|
+
description: 'A **reading path** for the save flow.',
|
|
41
|
+
trails: [
|
|
42
|
+
{ kind: 'base', modelId: 'sg-desktop', trailId: 'tl-save' },
|
|
43
|
+
{ kind: 'base', modelId: 'sg-server', trailId: 'tl-persist' },
|
|
44
|
+
{
|
|
45
|
+
kind: 'draft',
|
|
46
|
+
modelId: 'sg-desktop',
|
|
47
|
+
trail: { id: 'tl-planned', title: 'Planned sync', steps: [] },
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
draft: {
|
|
51
|
+
components: [
|
|
52
|
+
{ alias: 'sync-agent', name: 'SyncAgent', construct: 'class' },
|
|
53
|
+
],
|
|
54
|
+
edges: [{ from: 'tab', to: 'sync-agent', mechanism: 'produces' }],
|
|
55
|
+
},
|
|
56
|
+
provenance: { source: 'author', at: '2026-10-20T00:00:00.000Z' },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const resolveTrail: NonNullable<DecorationPanelProps['resolveTrail']> = (ref) => {
|
|
60
|
+
if (ref.kind === 'draft') return undefined;
|
|
61
|
+
const key = decorationTrailKey(ref);
|
|
62
|
+
if (key === 'sg-desktop:tl-save') return { title: 'Save drawing', stepCount: 4 };
|
|
63
|
+
if (key === 'sg-server:tl-persist') return { title: 'Persist file', stepCount: 5 };
|
|
64
|
+
return undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function renderPanel(overrides: Partial<DecorationPanelProps> = {}) {
|
|
68
|
+
const props: DecorationPanelProps = {
|
|
69
|
+
decoration,
|
|
70
|
+
active: true,
|
|
71
|
+
onToggleActive: () => {},
|
|
72
|
+
resolveTrail,
|
|
73
|
+
modelLabels: new Map([
|
|
74
|
+
['sg-desktop', 'desktop-app'],
|
|
75
|
+
['sg-server', 'sync-service'],
|
|
76
|
+
]),
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
return render(
|
|
80
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
81
|
+
<DecorationPanel {...props} />
|
|
82
|
+
</ThemeProvider>,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('DecorationPanel', () => {
|
|
87
|
+
test('renders the description, grouped trails, and draft structure', () => {
|
|
88
|
+
const { getByText, container } = renderPanel();
|
|
89
|
+
expect(container.textContent).toContain('reading path');
|
|
90
|
+
expect(getByText('desktop-app')).not.toBeNull();
|
|
91
|
+
expect(getByText('sync-service')).not.toBeNull();
|
|
92
|
+
expect(getByText('Save drawing')).not.toBeNull();
|
|
93
|
+
expect(getByText('Persist file')).not.toBeNull();
|
|
94
|
+
expect(getByText('Planned sync')).not.toBeNull();
|
|
95
|
+
expect(getByText('draft')).not.toBeNull();
|
|
96
|
+
expect(getByText('SyncAgent')).not.toBeNull();
|
|
97
|
+
expect(getByText('+ 1 draft edge')).not.toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the toggle reports the next active state', () => {
|
|
101
|
+
const onToggleActive = mock(() => {});
|
|
102
|
+
const { getByLabelText } = renderPanel({ onToggleActive });
|
|
103
|
+
fireEvent.click(getByLabelText('Turn decoration off'));
|
|
104
|
+
expect(onToggleActive).toHaveBeenCalledWith(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('selecting a trail hands the ref back to the host', () => {
|
|
108
|
+
const onSelectTrail = mock(() => {});
|
|
109
|
+
const { getByText } = renderPanel({ onSelectTrail });
|
|
110
|
+
fireEvent.click(getByText('Save drawing'));
|
|
111
|
+
expect(onSelectTrail).toHaveBeenCalledWith({
|
|
112
|
+
kind: 'base',
|
|
113
|
+
modelId: 'sg-desktop',
|
|
114
|
+
trailId: 'tl-save',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('selecting a draft component hands the component back', () => {
|
|
119
|
+
const onSelectDraftComponent = mock(() => {});
|
|
120
|
+
const { getByText } = renderPanel({ onSelectDraftComponent });
|
|
121
|
+
fireEvent.click(getByText('SyncAgent'));
|
|
122
|
+
expect(onSelectDraftComponent).toHaveBeenCalledWith({
|
|
123
|
+
alias: 'sync-agent',
|
|
124
|
+
name: 'SyncAgent',
|
|
125
|
+
construct: 'class',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DecorationPanel — the reading surface for one `SubsystemDecoration`.
|
|
3
|
+
*
|
|
4
|
+
* A decoration is an additive, unverified layer over one or more subsystem
|
|
5
|
+
* models: markdown prose, an ordered list of trails (each naming its own
|
|
6
|
+
* model), and optional draft structure. This panel is the container's own
|
|
7
|
+
* surface — it deliberately does not render the graph. Selecting a trail (or
|
|
8
|
+
* a draft component) is handed back to the host, which decides whether to
|
|
9
|
+
* focus a model, open a tab, or overlay the draft.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here writes: the first prototype is read-only plus an on/off toggle.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CSSProperties } from 'react';
|
|
15
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
16
|
+
import { Box, Eye, EyeOff, Network, Route, Sparkles } from 'lucide-react';
|
|
17
|
+
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
18
|
+
import {
|
|
19
|
+
decorationTrailId,
|
|
20
|
+
groupDecorationTrailsByModel,
|
|
21
|
+
type DecorationDraftComponent,
|
|
22
|
+
type DecorationTrailRef,
|
|
23
|
+
type SubsystemDecoration,
|
|
24
|
+
} from './decoration';
|
|
25
|
+
|
|
26
|
+
export interface DecorationPanelTrailMeta {
|
|
27
|
+
title: string;
|
|
28
|
+
stepCount?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DecorationPanelProps {
|
|
32
|
+
decoration: SubsystemDecoration;
|
|
33
|
+
/** Whether the decoration overlay is currently on. */
|
|
34
|
+
active: boolean;
|
|
35
|
+
onToggleActive: (active: boolean) => void;
|
|
36
|
+
/** Click a trail row. The host focuses the owning model/trail. */
|
|
37
|
+
onSelectTrail?: (ref: DecorationTrailRef) => void;
|
|
38
|
+
/** Click a draft component. Omit to render the draft list read-only. */
|
|
39
|
+
onSelectDraftComponent?: (component: DecorationDraftComponent) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a base trail's display title/step count from its owning model.
|
|
42
|
+
* Draft trails carry their content inline and need no resolver.
|
|
43
|
+
*/
|
|
44
|
+
resolveTrail?: (ref: DecorationTrailRef) => DecorationPanelTrailMeta | undefined;
|
|
45
|
+
/** Friendly model names, keyed by `modelId`. Falls back to the id. */
|
|
46
|
+
modelLabels?: ReadonlyMap<string, string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function DecorationPanel({
|
|
50
|
+
decoration,
|
|
51
|
+
active,
|
|
52
|
+
onToggleActive,
|
|
53
|
+
onSelectTrail,
|
|
54
|
+
onSelectDraftComponent,
|
|
55
|
+
resolveTrail,
|
|
56
|
+
modelLabels,
|
|
57
|
+
}: DecorationPanelProps) {
|
|
58
|
+
const { theme } = useTheme();
|
|
59
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
60
|
+
const secondary = theme.colors.textSecondary ?? muted;
|
|
61
|
+
const warning = theme.colors.warning ?? secondary;
|
|
62
|
+
const accent = theme.colors.accent ?? theme.colors.primary ?? theme.colors.text;
|
|
63
|
+
const groups = groupDecorationTrailsByModel(decoration.trails);
|
|
64
|
+
const draftComponents = decoration.draft?.components ?? [];
|
|
65
|
+
const draftEdges = decoration.draft?.edges ?? [];
|
|
66
|
+
const sectionLabel: CSSProperties = {
|
|
67
|
+
fontFamily: theme.fonts.monospace,
|
|
68
|
+
fontSize: theme.fontSizes[0],
|
|
69
|
+
fontWeight: theme.fontWeights.semibold,
|
|
70
|
+
letterSpacing: '0.06em',
|
|
71
|
+
textTransform: 'uppercase',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
style={{
|
|
77
|
+
display: 'flex',
|
|
78
|
+
flexDirection: 'column',
|
|
79
|
+
width: '100%',
|
|
80
|
+
height: '100%',
|
|
81
|
+
minHeight: 0,
|
|
82
|
+
background: theme.colors.background,
|
|
83
|
+
color: theme.colors.text,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<header
|
|
87
|
+
style={{
|
|
88
|
+
flexShrink: 0,
|
|
89
|
+
padding: '14px 16px 10px',
|
|
90
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
94
|
+
<Sparkles size={15} strokeWidth={2} color={accent} aria-hidden />
|
|
95
|
+
<h2
|
|
96
|
+
style={{
|
|
97
|
+
flex: 1,
|
|
98
|
+
minWidth: 0,
|
|
99
|
+
margin: 0,
|
|
100
|
+
overflowWrap: 'anywhere',
|
|
101
|
+
fontFamily: theme.fonts.monospace,
|
|
102
|
+
fontSize: theme.fontSizes[2],
|
|
103
|
+
fontWeight: theme.fontWeights.semibold,
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{decoration.title}
|
|
107
|
+
</h2>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
aria-pressed={active}
|
|
111
|
+
aria-label={active ? 'Turn decoration off' : 'Turn decoration on'}
|
|
112
|
+
title={active ? 'Turn decoration off' : 'Turn decoration on'}
|
|
113
|
+
onClick={() => onToggleActive(!active)}
|
|
114
|
+
style={{
|
|
115
|
+
display: 'inline-flex',
|
|
116
|
+
alignItems: 'center',
|
|
117
|
+
gap: 4,
|
|
118
|
+
flexShrink: 0,
|
|
119
|
+
padding: '3px 8px',
|
|
120
|
+
borderRadius: 4,
|
|
121
|
+
border: `1px solid ${active ? accent : theme.colors.border}`,
|
|
122
|
+
background: active ? `${accent}18` : 'transparent',
|
|
123
|
+
color: active ? accent : muted,
|
|
124
|
+
fontFamily: theme.fonts.monospace,
|
|
125
|
+
fontSize: theme.fontSizes[0],
|
|
126
|
+
cursor: 'pointer',
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{active ? <Eye size={12} strokeWidth={2} /> : <EyeOff size={12} strokeWidth={2} />}
|
|
130
|
+
{active ? 'On' : 'Off'}
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</header>
|
|
134
|
+
|
|
135
|
+
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 16px 18px' }}>
|
|
136
|
+
{decoration.description && (
|
|
137
|
+
<div style={{ fontSize: theme.fontSizes[1], lineHeight: theme.lineHeights.body }}>
|
|
138
|
+
<IndustryMarkdownSlide
|
|
139
|
+
content={decoration.description}
|
|
140
|
+
slideIdPrefix={`decoration-${decoration.id}`}
|
|
141
|
+
slideIndex={0}
|
|
142
|
+
isVisible={true}
|
|
143
|
+
theme={theme}
|
|
144
|
+
disableScroll={true}
|
|
145
|
+
disableBasePadding
|
|
146
|
+
enableKeyboardScrolling={false}
|
|
147
|
+
autoFocusOnVisible={false}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
<section
|
|
153
|
+
style={{
|
|
154
|
+
marginTop: 16,
|
|
155
|
+
paddingTop: 14,
|
|
156
|
+
borderTop: `1px solid ${theme.colors.border}`,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<div style={{ ...sectionLabel, color: secondary }}>
|
|
160
|
+
Trails · {decoration.trails.length}
|
|
161
|
+
</div>
|
|
162
|
+
{groups.map((group) => (
|
|
163
|
+
<div key={group.modelId}>
|
|
164
|
+
<div
|
|
165
|
+
style={{
|
|
166
|
+
display: 'flex',
|
|
167
|
+
alignItems: 'center',
|
|
168
|
+
gap: 6,
|
|
169
|
+
margin: '12px 0 3px',
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<Network size={14} strokeWidth={2} color={secondary} aria-hidden />
|
|
173
|
+
<span
|
|
174
|
+
style={{
|
|
175
|
+
minWidth: 0,
|
|
176
|
+
overflow: 'hidden',
|
|
177
|
+
textOverflow: 'ellipsis',
|
|
178
|
+
whiteSpace: 'nowrap',
|
|
179
|
+
fontFamily: theme.fonts.monospace,
|
|
180
|
+
fontSize: theme.fontSizes[1],
|
|
181
|
+
fontWeight: theme.fontWeights.semibold,
|
|
182
|
+
color: theme.colors.text,
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{modelLabels?.get(group.modelId) ?? group.modelId}
|
|
186
|
+
</span>
|
|
187
|
+
</div>
|
|
188
|
+
{group.trails.map((ref) => {
|
|
189
|
+
const meta = resolveTrail?.(ref);
|
|
190
|
+
const title =
|
|
191
|
+
meta?.title ??
|
|
192
|
+
(ref.kind === 'draft' ? ref.trail.title : ref.trailId);
|
|
193
|
+
const stepCount =
|
|
194
|
+
meta?.stepCount ??
|
|
195
|
+
(ref.kind === 'draft' ? ref.trail.steps.length : undefined);
|
|
196
|
+
return (
|
|
197
|
+
<button
|
|
198
|
+
key={decorationTrailId(ref)}
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={() => onSelectTrail?.(ref)}
|
|
201
|
+
style={{
|
|
202
|
+
display: 'flex',
|
|
203
|
+
alignItems: 'center',
|
|
204
|
+
gap: 8,
|
|
205
|
+
width: '100%',
|
|
206
|
+
minWidth: 0,
|
|
207
|
+
padding: '6px 6px 6px 0',
|
|
208
|
+
border: 'none',
|
|
209
|
+
borderRadius: 4,
|
|
210
|
+
background: 'transparent',
|
|
211
|
+
color: theme.colors.text,
|
|
212
|
+
textAlign: 'left',
|
|
213
|
+
cursor: onSelectTrail ? 'pointer' : 'default',
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
<Route size={13} strokeWidth={2} color={muted} aria-hidden />
|
|
217
|
+
<span
|
|
218
|
+
style={{
|
|
219
|
+
flex: 1,
|
|
220
|
+
minWidth: 0,
|
|
221
|
+
overflow: 'hidden',
|
|
222
|
+
textOverflow: 'ellipsis',
|
|
223
|
+
whiteSpace: 'nowrap',
|
|
224
|
+
fontFamily: theme.fonts.monospace,
|
|
225
|
+
fontSize: theme.fontSizes[1],
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
{title}
|
|
229
|
+
</span>
|
|
230
|
+
{ref.kind === 'draft' && (
|
|
231
|
+
<span
|
|
232
|
+
style={{
|
|
233
|
+
flexShrink: 0,
|
|
234
|
+
padding: '1px 5px',
|
|
235
|
+
borderRadius: 3,
|
|
236
|
+
border: `1px solid ${warning}`,
|
|
237
|
+
color: warning,
|
|
238
|
+
fontFamily: theme.fonts.monospace,
|
|
239
|
+
fontSize: theme.fontSizes[0],
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
draft
|
|
243
|
+
</span>
|
|
244
|
+
)}
|
|
245
|
+
{stepCount != null && (
|
|
246
|
+
<span
|
|
247
|
+
style={{
|
|
248
|
+
flexShrink: 0,
|
|
249
|
+
fontFamily: theme.fonts.monospace,
|
|
250
|
+
fontSize: theme.fontSizes[0],
|
|
251
|
+
color: secondary,
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{stepCount}
|
|
255
|
+
</span>
|
|
256
|
+
)}
|
|
257
|
+
</button>
|
|
258
|
+
);
|
|
259
|
+
})}
|
|
260
|
+
</div>
|
|
261
|
+
))}
|
|
262
|
+
</section>
|
|
263
|
+
|
|
264
|
+
{(draftComponents.length > 0 || draftEdges.length > 0) && (
|
|
265
|
+
<section style={{ marginTop: 18 }}>
|
|
266
|
+
<div style={{ ...sectionLabel, color: secondary }}>
|
|
267
|
+
Draft structure · not verified
|
|
268
|
+
</div>
|
|
269
|
+
{draftComponents.map((component) => (
|
|
270
|
+
<button
|
|
271
|
+
key={component.alias}
|
|
272
|
+
type="button"
|
|
273
|
+
onClick={() => onSelectDraftComponent?.(component)}
|
|
274
|
+
style={{
|
|
275
|
+
display: 'flex',
|
|
276
|
+
alignItems: 'center',
|
|
277
|
+
gap: 8,
|
|
278
|
+
width: '100%',
|
|
279
|
+
minWidth: 0,
|
|
280
|
+
padding: '6px 6px 6px 0',
|
|
281
|
+
border: 'none',
|
|
282
|
+
borderRadius: 4,
|
|
283
|
+
background: 'transparent',
|
|
284
|
+
color: theme.colors.text,
|
|
285
|
+
textAlign: 'left',
|
|
286
|
+
cursor: onSelectDraftComponent ? 'pointer' : 'default',
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
<Box size={13} strokeWidth={2} color={muted} aria-hidden />
|
|
290
|
+
<span
|
|
291
|
+
style={{
|
|
292
|
+
flex: 1,
|
|
293
|
+
minWidth: 0,
|
|
294
|
+
overflow: 'hidden',
|
|
295
|
+
textOverflow: 'ellipsis',
|
|
296
|
+
whiteSpace: 'nowrap',
|
|
297
|
+
fontFamily: theme.fonts.monospace,
|
|
298
|
+
fontSize: theme.fontSizes[1],
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
{component.name}
|
|
302
|
+
</span>
|
|
303
|
+
{component.construct && (
|
|
304
|
+
<span
|
|
305
|
+
style={{
|
|
306
|
+
flexShrink: 0,
|
|
307
|
+
fontFamily: theme.fonts.monospace,
|
|
308
|
+
fontSize: theme.fontSizes[0],
|
|
309
|
+
color: secondary,
|
|
310
|
+
}}
|
|
311
|
+
>
|
|
312
|
+
{component.construct}
|
|
313
|
+
</span>
|
|
314
|
+
)}
|
|
315
|
+
</button>
|
|
316
|
+
))}
|
|
317
|
+
{draftEdges.length > 0 && (
|
|
318
|
+
<div
|
|
319
|
+
style={{
|
|
320
|
+
marginTop: 4,
|
|
321
|
+
fontFamily: theme.fonts.monospace,
|
|
322
|
+
fontSize: theme.fontSizes[0],
|
|
323
|
+
color: secondary,
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
+ {draftEdges.length} draft edge{draftEdges.length === 1 ? '' : 's'}
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
</section>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
@@ -90,6 +90,12 @@ export function diagnosticTitle(diagnostic: SubsystemDiagnostic): string {
|
|
|
90
90
|
export interface SubsystemDiagnosticToggleProps extends SubsystemDiagnostic {
|
|
91
91
|
/** Icon size in px. @default 14 */
|
|
92
92
|
size?: number;
|
|
93
|
+
/** Optional visible label beside the status icon. */
|
|
94
|
+
labelText?: string;
|
|
95
|
+
/** Outline the button in header toolbars. */
|
|
96
|
+
bordered?: boolean;
|
|
97
|
+
/** Match an option inside a shared segmented view switch. */
|
|
98
|
+
segmented?: boolean;
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
export function SubsystemDiagnosticToggle({
|
|
@@ -100,6 +106,9 @@ export function SubsystemDiagnosticToggle({
|
|
|
100
106
|
busy = false,
|
|
101
107
|
active = false,
|
|
102
108
|
size = 14,
|
|
109
|
+
labelText,
|
|
110
|
+
bordered = false,
|
|
111
|
+
segmented = false,
|
|
103
112
|
onToggle,
|
|
104
113
|
...rest
|
|
105
114
|
}: SubsystemDiagnosticToggleProps) {
|
|
@@ -122,7 +131,7 @@ export function SubsystemDiagnosticToggle({
|
|
|
122
131
|
const showFixable = fixableCount > 0 && !busy;
|
|
123
132
|
const fixableColor = theme.colors.success ?? '#2da44e';
|
|
124
133
|
const background = active
|
|
125
|
-
? hexWithAlpha(color, hover ? 0.24 : 0.16)
|
|
134
|
+
? segmented ? theme.colors.border : hexWithAlpha(color, hover ? 0.24 : 0.16)
|
|
126
135
|
: hover
|
|
127
136
|
? theme.colors.border
|
|
128
137
|
: 'transparent';
|
|
@@ -138,15 +147,16 @@ export function SubsystemDiagnosticToggle({
|
|
|
138
147
|
onMouseEnter={() => setHover(true)}
|
|
139
148
|
onMouseLeave={() => setHover(false)}
|
|
140
149
|
title={label}
|
|
141
|
-
aria-label={label}
|
|
150
|
+
aria-label={labelText ? `${labelText}: ${label}` : label}
|
|
142
151
|
aria-pressed={active}
|
|
143
152
|
style={{
|
|
144
153
|
display: 'inline-flex',
|
|
145
154
|
alignItems: 'center',
|
|
155
|
+
justifyContent: bordered || segmented ? 'center' : undefined,
|
|
146
156
|
gap: 4,
|
|
147
|
-
height: 22,
|
|
148
|
-
padding: showCount ? '0 7px 0 6px' : '0 5px',
|
|
149
|
-
border: 'none',
|
|
157
|
+
height: bordered || segmented ? 28 : 22,
|
|
158
|
+
padding: bordered || segmented ? '4px 7px' : showCount ? '0 7px 0 6px' : '0 5px',
|
|
159
|
+
border: bordered ? `1px solid ${theme.colors.border}` : 'none',
|
|
150
160
|
borderRadius: 4,
|
|
151
161
|
background,
|
|
152
162
|
color,
|
|
@@ -206,6 +216,11 @@ export function SubsystemDiagnosticToggle({
|
|
|
206
216
|
</span>
|
|
207
217
|
)}
|
|
208
218
|
</span>
|
|
219
|
+
{labelText && (
|
|
220
|
+
<span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0] }}>
|
|
221
|
+
{labelText}
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
209
224
|
{showCount && (
|
|
210
225
|
<span
|
|
211
226
|
style={{
|
|
@@ -41,6 +41,11 @@ function parts(container: HTMLElement) {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function setDrawerGeometry(container: HTMLElement, width = 450, containerWidth = 1000) {
|
|
45
|
+
parts(container).panel.getBoundingClientRect = () => ({ width } as DOMRect);
|
|
46
|
+
container.getBoundingClientRect = () => ({ width: containerWidth } as DOMRect);
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
describe('FileDrawer docking', () => {
|
|
45
50
|
test('keeps the default bottom drawer and reserves height', () => {
|
|
46
51
|
const { container } = render(drawer());
|
|
@@ -127,4 +132,102 @@ describe('FileDrawer docking', () => {
|
|
|
127
132
|
fireEvent.keyDown(window, { key: 'Escape' });
|
|
128
133
|
expect(closes).toBe(1);
|
|
129
134
|
});
|
|
135
|
+
|
|
136
|
+
test('width dragging tracks the initial width and resizes the canvas spacer together', () => {
|
|
137
|
+
let ended = 0;
|
|
138
|
+
const { container, getByLabelText } = render(drawer({
|
|
139
|
+
placement: 'left',
|
|
140
|
+
onResizeEnd: () => { ended++; },
|
|
141
|
+
}));
|
|
142
|
+
setDrawerGeometry(container);
|
|
143
|
+
const input = getByLabelText('Code state');
|
|
144
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
145
|
+
expect(document.body.style.userSelect).toBe('none');
|
|
146
|
+
expect(parts(container).panel.style.transition).toBe('none');
|
|
147
|
+
fireEvent.mouseMove(document, { clientX: 500 });
|
|
148
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
149
|
+
expect(parts(container).spacer.style.width).toBe('50%');
|
|
150
|
+
fireEvent.mouseMove(document, { clientX: 520 });
|
|
151
|
+
expect(parts(container).panel.style.width).toBe('52%');
|
|
152
|
+
fireEvent.mouseMove(document, { clientX: 460 });
|
|
153
|
+
expect(parts(container).panel.style.width).toBe('46%');
|
|
154
|
+
expect(getByLabelText('Code state')).toBe(input);
|
|
155
|
+
fireEvent.mouseUp(document);
|
|
156
|
+
expect(ended).toBe(1);
|
|
157
|
+
expect(document.body.style.userSelect).toBe('');
|
|
158
|
+
expect(document.body.style.cursor).toBe('');
|
|
159
|
+
fireEvent.mouseMove(document, { clientX: 600 });
|
|
160
|
+
expect(parts(container).panel.style.width).toBe('46%');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('width dragging keeps space for both code and graph at either limit', () => {
|
|
164
|
+
const { container, getByLabelText } = render(drawer({ placement: 'left' }));
|
|
165
|
+
setDrawerGeometry(container);
|
|
166
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
167
|
+
fireEvent.mouseMove(document, { clientX: 1000 });
|
|
168
|
+
expect(parts(container).panel.style.width).toBe('76%');
|
|
169
|
+
fireEvent.mouseMove(document, { clientX: 0 });
|
|
170
|
+
expect(parts(container).panel.style.width).toBe('24%');
|
|
171
|
+
fireEvent.mouseMove(document, { clientX: 500 });
|
|
172
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
173
|
+
fireEvent.mouseUp(document);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('narrow containers do not produce inverted width limits', () => {
|
|
177
|
+
const { container, getByLabelText } = render(drawer({ placement: 'left' }));
|
|
178
|
+
setDrawerGeometry(container, 180, 400);
|
|
179
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 180 });
|
|
180
|
+
fireEvent.mouseMove(document, { clientX: 350 });
|
|
181
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
182
|
+
fireEvent.mouseUp(document);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('remembers dragged width across maximize, closing, and layout changes', () => {
|
|
186
|
+
const { container, getByLabelText, queryByLabelText, rerender } = render(drawer({ placement: 'left' }));
|
|
187
|
+
setDrawerGeometry(container);
|
|
188
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
189
|
+
fireEvent.mouseMove(document, { clientX: 500 });
|
|
190
|
+
fireEvent.mouseUp(document);
|
|
191
|
+
fireEvent.click(getByLabelText('Maximize code view'));
|
|
192
|
+
expect(parts(container).panel.style.width).toBe('100%');
|
|
193
|
+
expect(queryByLabelText('Resize code view')).toBeNull();
|
|
194
|
+
fireEvent.click(getByLabelText('Minimize code view'));
|
|
195
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
196
|
+
rerender(drawer({ placement: 'left', title: null }));
|
|
197
|
+
rerender(drawer({ placement: 'left' }));
|
|
198
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
199
|
+
rerender(drawer());
|
|
200
|
+
expect(parts(container).panel.style.height).toBe('45%');
|
|
201
|
+
expect(queryByLabelText('Resize code view')).toBeNull();
|
|
202
|
+
rerender(drawer({ placement: 'left' }));
|
|
203
|
+
expect(parts(container).panel.style.width).toBe('50%');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('closing mid-drag removes listeners and restores document styles', () => {
|
|
207
|
+
const { container, getByLabelText, rerender } = render(drawer({ placement: 'left' }));
|
|
208
|
+
setDrawerGeometry(container);
|
|
209
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
210
|
+
rerender(drawer({ placement: 'left', title: null }));
|
|
211
|
+
expect(document.body.style.userSelect).toBe('');
|
|
212
|
+
fireEvent.mouseMove(document, { clientX: 700 });
|
|
213
|
+
rerender(drawer({ placement: 'left' }));
|
|
214
|
+
expect(parts(container).panel.style.width).toBe('45%');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('losing window focus finishes the drag and restores existing styles', () => {
|
|
218
|
+
document.body.style.userSelect = 'text';
|
|
219
|
+
document.body.style.cursor = 'crosshair';
|
|
220
|
+
const { container, getByLabelText, unmount } = render(drawer({ placement: 'left' }));
|
|
221
|
+
setDrawerGeometry(container);
|
|
222
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
223
|
+
fireEvent(window, new dom.Event('blur') as unknown as Event);
|
|
224
|
+
expect(document.body.style.userSelect).toBe('text');
|
|
225
|
+
expect(document.body.style.cursor).toBe('crosshair');
|
|
226
|
+
fireEvent.mouseDown(getByLabelText('Resize code view'), { clientX: 450 });
|
|
227
|
+
unmount();
|
|
228
|
+
expect(document.body.style.userSelect).toBe('text');
|
|
229
|
+
expect(document.body.style.cursor).toBe('crosshair');
|
|
230
|
+
document.body.style.userSelect = '';
|
|
231
|
+
document.body.style.cursor = '';
|
|
232
|
+
});
|
|
130
233
|
});
|