@principal-ai/subsystems-react 0.49.0 → 0.51.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/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreFileView.d.ts +1 -1
- package/dist/pierre/PierreFileView.d.ts.map +1 -1
- package/dist/pierre/PierreFileView.js +2 -1
- package/dist/pierre/PierreFileView.js.map +1 -1
- package/dist/pierre/PierreSnippetView.d.ts +1 -1
- package/dist/pierre/PierreSnippetView.d.ts.map +1 -1
- package/dist/pierre/PierreSnippetView.js +2 -1
- package/dist/pierre/PierreSnippetView.js.map +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts +7 -1
- package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreTrailCodeView.js +71 -18
- package/dist/pierre/PierreTrailCodeView.js.map +1 -1
- package/dist/pierre/pierreBackground.d.ts +36 -2
- package/dist/pierre/pierreBackground.d.ts.map +1 -1
- package/dist/pierre/pierreBackground.js +40 -13
- package/dist/pierre/pierreBackground.js.map +1 -1
- package/dist/pierre/visibleStep.d.ts +48 -0
- package/dist/pierre/visibleStep.d.ts.map +1 -0
- package/dist/pierre/visibleStep.js +53 -0
- package/dist/pierre/visibleStep.js.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +95 -0
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +340 -0
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +4 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +1 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js +7334 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -1
- package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +2 -0
- package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/generateFixture.js +145 -0
- package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +1 -0
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +16 -60
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +84 -0
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -0
- package/dist/subsystem/C4NodeCard.js +229 -0
- package/dist/subsystem/C4NodeCard.js.map +1 -0
- package/dist/subsystem/DiagnosticToggle.d.ts +8 -1
- package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
- package/dist/subsystem/DiagnosticToggle.js +40 -6
- package/dist/subsystem/DiagnosticToggle.js.map +1 -1
- package/dist/subsystem/FileDrawer.d.ts +10 -1
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +61 -40
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +6 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +21 -10
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4Associations.d.ts +186 -0
- package/dist/subsystem/c4Associations.d.ts.map +1 -0
- package/dist/subsystem/c4Associations.js +284 -0
- package/dist/subsystem/c4Associations.js.map +1 -0
- package/dist/subsystem/model.d.ts +25 -1
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +15 -0
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +78 -7
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/toC4.d.ts +119 -0
- package/dist/subsystem/toC4.d.ts.map +1 -1
- package/dist/subsystem/toC4.js +95 -5
- package/dist/subsystem/toC4.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +39 -0
- package/src/pierre/PierreFileView.tsx +2 -1
- package/src/pierre/PierreSnippetView.tsx +2 -1
- package/src/pierre/PierreTrailCodeView.tsx +81 -22
- package/src/pierre/pierreBackground.ts +62 -12
- package/src/pierre/visibleStep.test.ts +108 -0
- package/src/pierre/visibleStep.ts +76 -0
- package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +273 -0
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +325 -0
- package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +344 -0
- package/src/stories/Subsystem/C4Graph/c4Fixture.ts +7337 -4
- package/src/stories/Subsystem/C4Graph/generateFixture.ts +161 -0
- package/src/stories/Subsystem/ComponentGraph/Deprecated.stories.tsx +358 -0
- package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +27 -2
- package/src/stories/Subsystem/ComponentGraph/{Flows.stories.tsx → Trails.stories.tsx} +10 -4
- package/src/subsystem/C4Graph.tsx +51 -92
- package/src/subsystem/C4NodeCard.test.ts +196 -0
- package/src/subsystem/C4NodeCard.tsx +307 -0
- package/src/subsystem/DiagnosticToggle.tsx +58 -4
- package/src/subsystem/FileDrawer.tsx +112 -63
- package/src/subsystem/SubsystemComponentGraph.tsx +29 -5
- package/src/subsystem/c4Associations.test.ts +484 -0
- package/src/subsystem/c4Associations.ts +398 -0
- package/src/subsystem/model.ts +34 -1
- package/src/subsystem/nodes.tsx +124 -5
- package/src/subsystem/toC4.ts +210 -5
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AT_END_TOLERANCE_PX,
|
|
5
|
+
resolveVisibleStepIndex,
|
|
6
|
+
type VisibleStepRect,
|
|
7
|
+
type VisibleStepViewport,
|
|
8
|
+
} from "./visibleStep";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Four steps in *content* coordinates. Steps 2 and 3 are short tails, which is
|
|
12
|
+
* the case that matters: neither can fill a 600px viewport, so at full scroll
|
|
13
|
+
* the topmost thing on screen is step 1 while the true position is step 3.
|
|
14
|
+
*/
|
|
15
|
+
const CONTENT_RECTS = [
|
|
16
|
+
{ index: 0, top: 0, bottom: 200 },
|
|
17
|
+
{ index: 1, top: 200, bottom: 900 },
|
|
18
|
+
{ index: 2, top: 900, bottom: 1100 },
|
|
19
|
+
{ index: 3, top: 1100, bottom: 1200 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const CONTENT_HEIGHT = 1200;
|
|
23
|
+
const CLIENT_HEIGHT = 600;
|
|
24
|
+
/** Furthest the view can scroll with this content. */
|
|
25
|
+
const MAX_SCROLL = CONTENT_HEIGHT - CLIENT_HEIGHT;
|
|
26
|
+
|
|
27
|
+
/** Project the content layout into viewport coordinates at `scrollTop`. */
|
|
28
|
+
function rectsAt(scrollTop: number): VisibleStepRect[] {
|
|
29
|
+
return CONTENT_RECTS.map((rect) => ({
|
|
30
|
+
index: rect.index,
|
|
31
|
+
top: rect.top - scrollTop,
|
|
32
|
+
bottom: rect.bottom - scrollTop,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function viewport(
|
|
37
|
+
scrollTop: number,
|
|
38
|
+
overrides: Partial<VisibleStepViewport> = {},
|
|
39
|
+
): VisibleStepViewport {
|
|
40
|
+
return {
|
|
41
|
+
top: 0,
|
|
42
|
+
bottom: CLIENT_HEIGHT,
|
|
43
|
+
scrollTop,
|
|
44
|
+
clientHeight: CLIENT_HEIGHT,
|
|
45
|
+
scrollHeight: CONTENT_HEIGHT,
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function at(scrollTop: number, overrides: Partial<VisibleStepViewport> = {}) {
|
|
51
|
+
return resolveVisibleStepIndex(
|
|
52
|
+
viewport(scrollTop, overrides),
|
|
53
|
+
rectsAt(scrollTop),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('resolveVisibleStepIndex', () => {
|
|
58
|
+
test('reports the topmost step on screen', () => {
|
|
59
|
+
expect(at(0)).toBe(0);
|
|
60
|
+
expect(at(250)).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('reports the last step once the end of the content is on screen', () => {
|
|
64
|
+
// The regression: at full scroll step 1's tail is still the topmost thing
|
|
65
|
+
// on screen, but the end of the content has been reached.
|
|
66
|
+
expect(at(MAX_SCROLL)).toBe(3);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('does not report the end while content remains below', () => {
|
|
70
|
+
expect(at(300)).toBe(1);
|
|
71
|
+
// Extra scrollable content below must not be mistaken for the end: step 3
|
|
72
|
+
// is still well below the viewport here.
|
|
73
|
+
expect(at(400, { scrollHeight: CONTENT_HEIGHT + 4000 })).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('trusts the scroll extent when the container box is not authoritative', () => {
|
|
77
|
+
// Nested scroller: the container's own box reports a bottom edge far above
|
|
78
|
+
// the real end, so only scrollTop/clientHeight/scrollHeight say we're done.
|
|
79
|
+
expect(at(MAX_SCROLL, { bottom: 100 })).toBe(3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('ignores the scroll extent when the container does not actually scroll', () => {
|
|
83
|
+
// An ancestor scrolls instead: scrollTop is pinned at 0 and scrollHeight
|
|
84
|
+
// equals clientHeight, which must not read as 'at the end' on first paint.
|
|
85
|
+
const notAScroller = { scrollTop: 0, clientHeight: 600, scrollHeight: 600 };
|
|
86
|
+
expect(at(0, { ...notAScroller, bottom: 100 })).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('tolerates a bottom edge a fraction of a pixel off', () => {
|
|
90
|
+
expect(
|
|
91
|
+
at(MAX_SCROLL, { scrollHeight: CONTENT_HEIGHT + AT_END_TOLERANCE_PX }),
|
|
92
|
+
).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('returns null when nothing is on screen', () => {
|
|
96
|
+
expect(resolveVisibleStepIndex(viewport(0), [])).toBeNull();
|
|
97
|
+
expect(
|
|
98
|
+
resolveVisibleStepIndex(viewport(0), [
|
|
99
|
+
{ index: 0, top: -800, bottom: -400 },
|
|
100
|
+
]),
|
|
101
|
+
).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('uses the highest index even when rects arrive out of order', () => {
|
|
105
|
+
const shuffled = [...rectsAt(MAX_SCROLL)].reverse();
|
|
106
|
+
expect(resolveVisibleStepIndex(viewport(MAX_SCROLL), shuffled)).toBe(3);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* visibleStep — pick which step a scroll position should report as current.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the component so the geometry can be tested directly:
|
|
5
|
+
* the answer depends on how tall the final snippet is relative to the
|
|
6
|
+
* viewport, which is not something to eyeball.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A mounted CodeView item reduced to what the choice depends on. */
|
|
10
|
+
export interface VisibleStepRect {
|
|
11
|
+
/** Step index parsed from the item id. */
|
|
12
|
+
index: number;
|
|
13
|
+
/** Viewport-relative top edge, in px. */
|
|
14
|
+
top: number;
|
|
15
|
+
/** Viewport-relative bottom edge, in px. */
|
|
16
|
+
bottom: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The scroll container's box plus its scroll metrics, in px. */
|
|
20
|
+
export interface VisibleStepViewport {
|
|
21
|
+
top: number;
|
|
22
|
+
bottom: number;
|
|
23
|
+
scrollTop: number;
|
|
24
|
+
clientHeight: number;
|
|
25
|
+
scrollHeight: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sub-pixel slack so a bottom edge that lands a hair off still counts. */
|
|
29
|
+
export const AT_END_TOLERANCE_PX = 2;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Index of the step to report as current.
|
|
33
|
+
*
|
|
34
|
+
* Normally the topmost step still on screen. The exception is the end of the
|
|
35
|
+
* content: when the final snippet is shorter than the viewport, reaching the
|
|
36
|
+
* bottom still leaves the previous step's tail on screen, and reporting that
|
|
37
|
+
* would strand a progress readout one step short of full.
|
|
38
|
+
*
|
|
39
|
+
* 'At the end' is accepted two ways, because either can be the authoritative
|
|
40
|
+
* one depending on how the view is nested:
|
|
41
|
+
* - the lowest mounted item's bottom edge has entered the container's box, or
|
|
42
|
+
* - the container's own scroll offset has reached its scroll extent.
|
|
43
|
+
*
|
|
44
|
+
* The second signal is only consulted when the container actually scrolls. If
|
|
45
|
+
* an ancestor scrolls instead, `scrollHeight` equals `clientHeight` and
|
|
46
|
+
* `scrollTop` is pinned at 0, which would otherwise read as 'at the end' from
|
|
47
|
+
* the very first pixel.
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` when nothing measurable is on screen.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveVisibleStepIndex(
|
|
52
|
+
viewport: VisibleStepViewport,
|
|
53
|
+
rects: readonly VisibleStepRect[],
|
|
54
|
+
): number | null {
|
|
55
|
+
let topIndex: number | null = null;
|
|
56
|
+
let lastIndex: number | null = null;
|
|
57
|
+
let lowestBottom = Number.NEGATIVE_INFINITY;
|
|
58
|
+
|
|
59
|
+
for (const rect of rects) {
|
|
60
|
+
if (topIndex == null && rect.bottom > viewport.top) topIndex = rect.index;
|
|
61
|
+
if (lastIndex == null || rect.index > lastIndex) lastIndex = rect.index;
|
|
62
|
+
if (rect.bottom > lowestBottom) lowestBottom = rect.bottom;
|
|
63
|
+
}
|
|
64
|
+
if (topIndex == null) return null;
|
|
65
|
+
|
|
66
|
+
const reachedLastItem = lowestBottom <= viewport.bottom + AT_END_TOLERANCE_PX;
|
|
67
|
+
const containerScrolls = viewport.scrollHeight > viewport.clientHeight;
|
|
68
|
+
const reachedScrollEnd =
|
|
69
|
+
containerScrolls &&
|
|
70
|
+
viewport.scrollTop + viewport.clientHeight >=
|
|
71
|
+
viewport.scrollHeight - AT_END_TOLERANCE_PX;
|
|
72
|
+
|
|
73
|
+
return reachedLastItem || reachedScrollEnd
|
|
74
|
+
? (lastIndex ?? topIndex)
|
|
75
|
+
: topIndex;
|
|
76
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import '@xyflow/react/dist/style.css';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
4
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
5
|
+
import { C4Graph } from '../../../subsystem/C4Graph';
|
|
6
|
+
import { toC4, type C4View } from '../../../subsystem/toC4';
|
|
7
|
+
import type { C4Association } from '../../../subsystem/toC4';
|
|
8
|
+
import { summarizeAssociations, mergeAssociations } from '../../../subsystem/c4Associations';
|
|
9
|
+
import { c4FixtureModel, c4MemberCounts } from './c4Fixture';
|
|
10
|
+
import { c4Associations, C4_MERGE_CANDIDATES, C4_CONCERNS } from './c4Associations.fixture';
|
|
11
|
+
|
|
12
|
+
const MEMBER_COUNTS = new Map(Object.entries(c4MemberCounts));
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: 'Subsystem/C4Graph/C4Associations',
|
|
16
|
+
component: C4Graph,
|
|
17
|
+
parameters: { layout: 'fullscreen' },
|
|
18
|
+
tags: ['autodocs'],
|
|
19
|
+
decorators: [
|
|
20
|
+
(Story) => (
|
|
21
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
22
|
+
<Story />
|
|
23
|
+
</ThemeProvider>
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
} satisfies Meta<typeof C4Graph>;
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
type Story = StoryObj<typeof meta>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* C4 associations — the confirmed layer over a raw derivation.
|
|
33
|
+
*
|
|
34
|
+
* `toC4()` alone rolls components up into boxes keyed by `process`. That
|
|
35
|
+
* derivation is a guess: two keys may be the same deployable, a library may be
|
|
36
|
+
* pretending to be a container, and a store may not be a C4 data store at all.
|
|
37
|
+
*
|
|
38
|
+
* This story starts from a real composite of 40 stored models and lets you act
|
|
39
|
+
* on the 13 proposed associations:
|
|
40
|
+
*
|
|
41
|
+
* accept the box goes solid — it is part of the architecture
|
|
42
|
+
* reject the box disappears, and so do the edges into it
|
|
43
|
+
* merge two keys collapse into one box
|
|
44
|
+
*
|
|
45
|
+
* Try `merge host` first: it takes `subsystems-studio/host` (113 components)
|
|
46
|
+
* and `principal-studio/host` (1 component) and folds them into one container.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
function Demo({ initialAccepted = [] as string[] }: { initialAccepted?: string[] }) {
|
|
50
|
+
const [view, setView] = useState<C4View>('container');
|
|
51
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
52
|
+
const [state, setState] = useState<Record<string, C4Association['state']>>(() =>
|
|
53
|
+
Object.fromEntries(initialAccepted.map((id) => [id, 'accepted' as const])),
|
|
54
|
+
);
|
|
55
|
+
/** Roles the user has merged. Folds the candidates into one association each. */
|
|
56
|
+
const [mergedRoles, setMergedRoles] = useState<string[]>([]);
|
|
57
|
+
|
|
58
|
+
const associations = useMemo<C4Association[]>(() => {
|
|
59
|
+
let set: C4Association[] = c4Associations;
|
|
60
|
+
for (const role of mergedRoles) {
|
|
61
|
+
const candidate = C4_MERGE_CANDIDATES.find((c) => c.role === role);
|
|
62
|
+
if (!candidate) continue;
|
|
63
|
+
// The evidence-heavy side survives automatically — see mergeAssociations.
|
|
64
|
+
set = mergeAssociations(set, candidate.keys, undefined, MEMBER_COUNTS);
|
|
65
|
+
}
|
|
66
|
+
// Apply the user's accept/reject last, over the merged set.
|
|
67
|
+
return set.map((a) => ({ ...a, state: state[a.id] ?? a.state }));
|
|
68
|
+
}, [state, mergedRoles]);
|
|
69
|
+
|
|
70
|
+
const model = useMemo(
|
|
71
|
+
() => toC4(c4FixtureModel, { view, systemLabel: 'Subsystem Modeling', associations }),
|
|
72
|
+
[view, associations],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const stats = useMemo(() => summarizeAssociations(model, associations), [model, associations]);
|
|
76
|
+
|
|
77
|
+
const concernsFor = (id: string) => {
|
|
78
|
+
const a = associations.find((x) => x.id === id);
|
|
79
|
+
if (!a) return [];
|
|
80
|
+
return C4_CONCERNS.filter((c) => a.sourceKeys.includes(c.target));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const decide = (id: string, next: C4Association['state']) =>
|
|
84
|
+
setState((prev) => ({ ...prev, [id]: next }));
|
|
85
|
+
|
|
86
|
+
const reset = () => {
|
|
87
|
+
setState({});
|
|
88
|
+
setMergedRoles([]);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const button = (label: string, value: C4View) => (
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => setView(value)}
|
|
95
|
+
style={{
|
|
96
|
+
fontFamily: 'monospace',
|
|
97
|
+
fontSize: 12,
|
|
98
|
+
padding: '2px 10px',
|
|
99
|
+
borderRadius: 4,
|
|
100
|
+
cursor: 'pointer',
|
|
101
|
+
border: '1px solid #555',
|
|
102
|
+
background: view === value ? '#1D9E75' : 'transparent',
|
|
103
|
+
color: view === value ? '#020B12' : '#ddd',
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{label}
|
|
107
|
+
</button>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const act = (label: string, onClick: () => void) => (
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={onClick}
|
|
114
|
+
style={{
|
|
115
|
+
fontFamily: 'monospace',
|
|
116
|
+
fontSize: 11,
|
|
117
|
+
padding: '2px 8px',
|
|
118
|
+
borderRadius: 4,
|
|
119
|
+
cursor: 'pointer',
|
|
120
|
+
border: '1px solid #555',
|
|
121
|
+
background: 'transparent',
|
|
122
|
+
color: '#ddd',
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{label}
|
|
126
|
+
</button>
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column', background: '#12141a' }}>
|
|
131
|
+
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
132
|
+
<C4Graph model={model} title="Subsystem Modeling" onSelectNode={setSelected} />
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div
|
|
136
|
+
style={{
|
|
137
|
+
maxHeight: 280,
|
|
138
|
+
overflowY: 'auto',
|
|
139
|
+
padding: '8px 12px',
|
|
140
|
+
display: 'flex',
|
|
141
|
+
gap: 18,
|
|
142
|
+
fontFamily: 'monospace',
|
|
143
|
+
fontSize: 12,
|
|
144
|
+
color: '#aaa',
|
|
145
|
+
borderTop: '1px solid #333',
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<div style={{ minWidth: 210 }}>
|
|
149
|
+
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
|
150
|
+
{button('container', 'container')}
|
|
151
|
+
{button('component', 'component')}
|
|
152
|
+
</div>
|
|
153
|
+
<div>
|
|
154
|
+
{stats.confirmed} confirmed · {stats.proposed} proposed
|
|
155
|
+
</div>
|
|
156
|
+
<div>{stats.rejected} rejected · {stats.mergedAway} keys merged away</div>
|
|
157
|
+
<div style={{ color: stats.missingTechnology > 0 ? '#e8a33a' : '#5a5' }}>
|
|
158
|
+
{stats.missingTechnology} containers missing technology
|
|
159
|
+
</div>
|
|
160
|
+
<div>{stats.needsDecision} still need a decision</div>
|
|
161
|
+
<div style={{ marginTop: 8 }}>{act('reset', reset)}</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{/* The merge question, with one click to try it. */}
|
|
165
|
+
<div style={{ minWidth: 300, maxWidth: 340 }}>
|
|
166
|
+
<div style={{ color: '#ddd', marginBottom: 4 }}>MERGE CANDIDATES</div>
|
|
167
|
+
{C4_MERGE_CANDIDATES.map((c) => {
|
|
168
|
+
const done = mergedRoles.includes(c.role);
|
|
169
|
+
// Show which side the evidence will keep, before you click.
|
|
170
|
+
const heaviest = c.keys.reduce((a, b) =>
|
|
171
|
+
(c4MemberCounts[b] ?? 0) > (c4MemberCounts[a] ?? 0) ? b : a,
|
|
172
|
+
);
|
|
173
|
+
return (
|
|
174
|
+
<div key={c.role} style={{ marginBottom: 8 }}>
|
|
175
|
+
{c.keys.map((k) => (
|
|
176
|
+
<div
|
|
177
|
+
key={k}
|
|
178
|
+
style={{
|
|
179
|
+
color: done && k === heaviest ? '#5a5' : '#888',
|
|
180
|
+
lineHeight: 1.35,
|
|
181
|
+
fontWeight: done && k === heaviest ? 600 : 400,
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
{k} · {c4MemberCounts[k] ?? 0}
|
|
185
|
+
</div>
|
|
186
|
+
))}
|
|
187
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 3 }}>
|
|
188
|
+
{done ? (
|
|
189
|
+
<>
|
|
190
|
+
<span style={{ color: '#5a5' }}>merged — kept {heaviest.split('/').pop()}</span>
|
|
191
|
+
{act('undo', () => setMergedRoles((prev) => prev.filter((r) => r !== c.role)))}
|
|
192
|
+
</>
|
|
193
|
+
) : (
|
|
194
|
+
act(`merge ${c.role}`, () => setMergedRoles((prev) => [...prev, c.role]))
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
})}
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* Per-association review. This is the surface a real confirm UI would use. */}
|
|
203
|
+
<div style={{ flex: 1, minWidth: 380 }}>
|
|
204
|
+
<div style={{ color: '#ddd', marginBottom: 4 }}>
|
|
205
|
+
ASSOCIATIONS · {associations.length}
|
|
206
|
+
</div>
|
|
207
|
+
{associations.map((a) => {
|
|
208
|
+
const s = state[a.id] ?? a.state;
|
|
209
|
+
const concerns = concernsFor(a.id);
|
|
210
|
+
const missing: string[] = [];
|
|
211
|
+
if (!a.technology) missing.push('technology');
|
|
212
|
+
if (!a.description) missing.push('description');
|
|
213
|
+
return (
|
|
214
|
+
<div
|
|
215
|
+
key={a.id}
|
|
216
|
+
style={{
|
|
217
|
+
display: 'flex',
|
|
218
|
+
gap: 10,
|
|
219
|
+
alignItems: 'flex-start',
|
|
220
|
+
padding: '4px 0',
|
|
221
|
+
borderBottom: '1px solid #222',
|
|
222
|
+
opacity: s === 'rejected' ? 0.45 : 1,
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
226
|
+
<div style={{ color: '#ddd' }}>
|
|
227
|
+
{a.label}
|
|
228
|
+
<span style={{ color: s === 'accepted' ? '#5a5' : s === 'proposed' ? '#e8a33a' : '#888' }}>
|
|
229
|
+
{' '}
|
|
230
|
+
· {s} · {a.type}
|
|
231
|
+
</span>
|
|
232
|
+
</div>
|
|
233
|
+
<div style={{ color: '#777', fontSize: 11 }}>
|
|
234
|
+
keys: {a.sourceKeys.join(', ')}
|
|
235
|
+
{a.technology ? ` · tech: ${a.technology}` : ''}
|
|
236
|
+
{missing.length > 0 ? ` · needs ${missing.join(' + ')}` : ''}
|
|
237
|
+
</div>
|
|
238
|
+
{concerns.length > 0 && (
|
|
239
|
+
<div style={{ color: '#c96', fontSize: 11, lineHeight: 1.35 }}>
|
|
240
|
+
{concerns.map((c) => (
|
|
241
|
+
<div key={c.kind + c.target}>⚠ {c.message}</div>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
<div style={{ display: 'flex', gap: 4 }}>
|
|
247
|
+
{act('accept', () => decide(a.id, 'accepted'))}
|
|
248
|
+
{act('reject', () => decide(a.id, 'rejected'))}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
})}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div style={{ minWidth: 130, fontSize: 11, color: '#777' }}>
|
|
256
|
+
<div>selected: {selected ?? '(none)'}</div>
|
|
257
|
+
<div style={{ marginTop: 6 }}>solid = confirmed</div>
|
|
258
|
+
<div>dashed = proposed</div>
|
|
259
|
+
<div>muted = unconfirmed</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const Associations: Story = {
|
|
267
|
+
render: () => <Demo />,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/** Start from a half-confirmed state so the visual difference is obvious. */
|
|
271
|
+
export const PartiallyConfirmed: Story = {
|
|
272
|
+
render: () => <Demo initialAccepted={['container:subsystems-studio/host', 'container:subsystems-studio/renderer']} />,
|
|
273
|
+
};
|