@intentius/behold 0.6.0 → 0.8.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/AGENTS.md +9 -2
- package/README.md +12 -2
- package/demos.json +8 -0
- package/dist/cli.js +769 -139
- package/example-argo-estate/README.md +46 -0
- package/example-argo-estate/app-a/chant.config.ts +10 -0
- package/example-argo-estate/app-a/manifests/app.yaml +54 -0
- package/example-argo-estate/app-a/package.json +13 -0
- package/example-argo-estate/app-a/src/app.ts +35 -0
- package/example-argo-estate/app-a/tsconfig.json +1 -0
- package/example-argo-estate/app-b/chant.config.ts +9 -0
- package/example-argo-estate/app-b/manifests/app.yaml +54 -0
- package/example-argo-estate/app-b/package.json +13 -0
- package/example-argo-estate/app-b/src/app.ts +34 -0
- package/example-argo-estate/app-b/tsconfig.json +1 -0
- package/example-argo-estate/control-plane/chant.config.ts +14 -0
- package/example-argo-estate/control-plane/package.json +13 -0
- package/example-argo-estate/control-plane/src/argo.ts +69 -0
- package/example-argo-estate/control-plane/tsconfig.json +1 -0
- package/example-argo-estate/package-lock.json +1949 -0
- package/example-argo-estate/package.json +10 -0
- package/example-flux-estate/README.md +13 -8
- package/example-flux-estate/app-a/package.json +3 -3
- package/example-flux-estate/app-b/package.json +3 -3
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/control-plane/src/flux.ts +6 -0
- package/example-flux-estate/package-lock.json +17 -17
- package/example-k8s/README.md +16 -2
- package/example-k8s/base/deployment.yaml +21 -0
- package/example-k8s/base/kustomization.yaml +3 -0
- package/example-k8s/base/service.yaml +12 -0
- package/example-k8s/chant.config.ts +10 -0
- package/example-k8s/overlays/dev/kustomization.yaml +6 -0
- package/example-k8s/package-lock.json +43 -40
- package/example-k8s/package.json +4 -4
- package/example-writes/package-lock.json +18 -16
- package/example-writes/package.json +3 -3
- package/package.json +3 -2
- package/web/app.js +359 -21
- package/web/icons/cncf/argo.svg +1 -0
- package/web/icons/cncf/flux.svg +1 -0
- package/web/icons/cncf/helm.svg +1 -0
- package/web/icons/k8s/c-role.svg +1 -0
- package/web/icons/k8s/cm.svg +1 -0
- package/web/icons/k8s/crb.svg +1 -0
- package/web/icons/k8s/crd.svg +1 -0
- package/web/icons/k8s/cronjob.svg +1 -0
- package/web/icons/k8s/deploy.svg +1 -0
- package/web/icons/k8s/ds.svg +1 -0
- package/web/icons/k8s/ep.svg +1 -0
- package/web/icons/k8s/group.svg +1 -0
- package/web/icons/k8s/hpa.svg +1 -0
- package/web/icons/k8s/ing.svg +1 -0
- package/web/icons/k8s/job.svg +1 -0
- package/web/icons/k8s/limits.svg +1 -0
- package/web/icons/k8s/netpol.svg +1 -0
- package/web/icons/k8s/ns.svg +1 -0
- package/web/icons/k8s/pod.svg +1 -0
- package/web/icons/k8s/psp.svg +1 -0
- package/web/icons/k8s/pv.svg +1 -0
- package/web/icons/k8s/pvc.svg +1 -0
- package/web/icons/k8s/quota.svg +1 -0
- package/web/icons/k8s/rb.svg +1 -0
- package/web/icons/k8s/role.svg +1 -0
- package/web/icons/k8s/rs.svg +1 -0
- package/web/icons/k8s/sa.svg +1 -0
- package/web/icons/k8s/sc.svg +1 -0
- package/web/icons/k8s/secret.svg +1 -0
- package/web/icons/k8s/sts.svg +1 -0
- package/web/icons/k8s/svc.svg +1 -0
- package/web/icons/k8s/user.svg +1 -0
- package/web/icons/k8s/vol.svg +1 -0
- package/web/index.html +192 -84
- package/web/layout-store.js +118 -0
- package/web/layout-store.test.js +163 -0
- package/web/theme.js +58 -6
package/web/index.html
CHANGED
|
@@ -5,14 +5,42 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>behold</title>
|
|
7
7
|
<style>
|
|
8
|
+
/* Pre-paint fallback (#229). These are the DEFAULT theme's tokens —
|
|
9
|
+
Catppuccin Mocha run through theme.js's tokensFor() — not a palette of
|
|
10
|
+
their own, so the first frame is already the frame initTheme() lands
|
|
11
|
+
on and there is no flash of a different app. (It used to be GitHub
|
|
12
|
+
Primer dark, which is why behold's first paint read as every other
|
|
13
|
+
generated tool.) The ONLY hardcoded colours in the whole stylesheet
|
|
14
|
+
live in this block; smoke/ui-smoke.mjs recomputes them from theme.js
|
|
15
|
+
and fails if the two drift apart. */
|
|
8
16
|
:root {
|
|
9
|
-
--bg: #
|
|
10
|
-
--
|
|
11
|
-
--degraded: #
|
|
17
|
+
--bg: #1e1e2e; --panel: #2e2f40; --line: #3b3c4a; --fg: #cdd6f4; --muted: #777a8c;
|
|
18
|
+
--edge: #56596d; --managed: #a6e3a1; --foreign: #f9e2af; --pending: #89b4fa;
|
|
19
|
+
--degraded: #f38ba8; --neutral: #6d7187; --runtime: #94e2d5;
|
|
20
|
+
--rule: #4e4f64; --focus: #a3b4d0; --active: #3d4660; --well: #11101f; --shadow: #050507;
|
|
21
|
+
}
|
|
22
|
+
/* The non-colour half of the identity: type and radii. These can't derive
|
|
23
|
+
from a colour theme, so they're the one static token block — but they
|
|
24
|
+
ARE tokens, and nothing below hardcodes a size or a radius.
|
|
25
|
+
Type: a terminal-adjacent control plane, so mono does what mono is for —
|
|
26
|
+
ids, addresses, statuses, counts, the diagnosis lines — and the sans
|
|
27
|
+
carries labels only. Three sizes, not five near-identical ones.
|
|
28
|
+
(No vendored typeface: behold ships no binaries. A licensed .woff2 via
|
|
29
|
+
a release asset is the follow-up if the system stacks aren't enough.)
|
|
30
|
+
Radii: sharp on controls, soft on the shells they sit in. */
|
|
31
|
+
:root {
|
|
32
|
+
--font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
33
|
+
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Code", "JetBrains Mono", "IBM Plex Mono", Menlo, Consolas, monospace;
|
|
34
|
+
--t-display: 15px;
|
|
35
|
+
--t-body: 12.5px;
|
|
36
|
+
--t-caption: 10.5px;
|
|
37
|
+
--t-caps: .07em;
|
|
38
|
+
--r-ctl: 3px;
|
|
39
|
+
--r-shell: 10px;
|
|
12
40
|
}
|
|
13
41
|
* { box-sizing: border-box; }
|
|
14
42
|
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg);
|
|
15
|
-
font:
|
|
43
|
+
font: var(--t-body)/1.45 var(--font-ui); }
|
|
16
44
|
/* #186: no header row at all — the graph runs full-bleed; every piece
|
|
17
45
|
of state the header used to carry (meta line, statusbar) lives in the
|
|
18
46
|
floating panel's footer instead. */
|
|
@@ -21,31 +49,45 @@
|
|
|
21
49
|
#app.inspect-collapsed { grid-template-columns: 1fr 0; }
|
|
22
50
|
#app.inspect-collapsed #inspect { display: none; }
|
|
23
51
|
#graph { grid-row: 1; grid-column: 1; overflow: auto; position: relative; }
|
|
24
|
-
#inspect { grid-row: 1; grid-column: 2; border-left: 1px solid var(--
|
|
52
|
+
#inspect { grid-row: 1; grid-column: 2; border-left: 1px solid var(--rule); background: var(--panel);
|
|
25
53
|
padding: 14px; overflow: auto; position: relative; }
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
/* Section labels are the sans voice: small caps, tracked, muted. Every
|
|
55
|
+
heading in this pane (identity, live, drift, field ownership — app.js
|
|
56
|
+
appends them) is styled here rather than inline, so the scale stays in
|
|
57
|
+
one place. */
|
|
58
|
+
#inspect h2, #inspect h3 { color: var(--muted); font-size: var(--t-caption); font-weight: 600;
|
|
59
|
+
text-transform: uppercase; letter-spacing: var(--t-caps); }
|
|
60
|
+
#inspect h2 { margin: 0 0 10px; }
|
|
61
|
+
#inspect h3 { margin: 16px 0 6px; }
|
|
28
62
|
/* Declared/identity: key ABOVE value, value full-width — long ARNs wrap
|
|
29
|
-
readably instead of being crushed into a squeezed right column.
|
|
63
|
+
readably instead of being crushed into a squeezed right column. The
|
|
64
|
+
VALUE is data — an id, an ARN, a physical id, a status — so it's mono;
|
|
65
|
+
the key stays sans. */
|
|
30
66
|
#inspect dl { margin: 0; }
|
|
31
|
-
#inspect dt { color: var(--muted); font-size:
|
|
32
|
-
#inspect dd { margin:
|
|
67
|
+
#inspect dt { color: var(--muted); font-size: var(--t-caption); margin-top: 9px; }
|
|
68
|
+
#inspect dd { margin: 2px 0 0; font: var(--t-body)/1.45 var(--font-mono); overflow-wrap: anywhere; }
|
|
69
|
+
#inspect p { font-size: var(--t-body); }
|
|
70
|
+
/* #192: the queried-address diagnosis line — a request path, so mono. */
|
|
71
|
+
#inspect p.queried { color: var(--muted); font: var(--t-caption)/1.45 var(--font-mono);
|
|
72
|
+
overflow-wrap: anywhere; margin-top: 2px; }
|
|
33
73
|
/* Drag handle on the pane's left edge → resize; chevron → collapse. */
|
|
34
74
|
#inspect-resize { position: absolute; left: -3px; top: 0; bottom: 0; width: 7px; cursor: col-resize; z-index: 6; }
|
|
35
|
-
#inspect-resize:hover { background: var(--
|
|
75
|
+
#inspect-resize:hover { background: var(--focus); opacity: .5; }
|
|
36
76
|
#inspect-collapse { position: absolute; top: 12px; right: 12px; z-index: 6; background: none;
|
|
37
|
-
border: none; color: var(--muted); font-size:
|
|
77
|
+
border: none; color: var(--muted); font-size: var(--t-display); cursor: pointer; line-height: 1; padding: 2px; }
|
|
38
78
|
#inspect-collapse:hover { color: var(--fg); }
|
|
39
79
|
#inspect-reopen { position: fixed; right: 0; top: 50%; transform: translateY(-50%); z-index: 30; display: none;
|
|
40
|
-
background: var(--panel); color: var(--muted); border: 1px solid var(--
|
|
41
|
-
border-radius:
|
|
42
|
-
|
|
80
|
+
background: var(--panel); color: var(--muted); border: 1px solid var(--rule); border-right: none;
|
|
81
|
+
border-radius: var(--r-ctl) 0 0 var(--r-ctl); padding: 10px 5px; cursor: pointer;
|
|
82
|
+
writing-mode: vertical-rl; font-size: var(--t-caption); letter-spacing: var(--t-caps); }
|
|
83
|
+
#inspect-reopen:hover { color: var(--fg); border-color: var(--focus); }
|
|
43
84
|
#app.inspect-collapsed #inspect-reopen { display: block; }
|
|
44
85
|
/* The graph SVG is pinhole's, self-styled. It fills the pane; behold drives
|
|
45
86
|
zoom/pan by mutating its viewBox (see setupPanZoom in app.js) — pinch or
|
|
46
87
|
⌘/Ctrl+scroll to zoom at the cursor, drag to pan. It starts fit-to-pane;
|
|
47
88
|
the "⤢ fit" button resets. behold also adds a selection highlight. */
|
|
48
|
-
#graph svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none;
|
|
89
|
+
#graph svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none;
|
|
90
|
+
user-select: none; -webkit-user-select: none; }
|
|
49
91
|
#graph svg.grabbing { cursor: grabbing; }
|
|
50
92
|
/* Edge hover-highlight — trace a single edge through an overlapping bundle.
|
|
51
93
|
pinhole already paints a fat transparent hit-path per edge (pointer-events
|
|
@@ -53,6 +95,28 @@
|
|
|
53
95
|
visible line. The hovered edge is also raised to the top in JS so it isn't
|
|
54
96
|
buried under the bundle. */
|
|
55
97
|
#graph g[data-edge-from] .pin-edge-line { transition: stroke .08s ease, stroke-width .08s ease; }
|
|
98
|
+
/* ---- Motion (#229) ---------------------------------------------------
|
|
99
|
+
behold spends motion exactly twice, and never for decoration:
|
|
100
|
+
1. the graph fades through on every re-render, so a refresh reads as a
|
|
101
|
+
new frame instead of a flicker (the SVG element is replaced whole,
|
|
102
|
+
so the animation fires once per render by construction);
|
|
103
|
+
2. a node whose drift `_status` CHANGED since the last render glows
|
|
104
|
+
once in its new status colour — the diff is a module-level
|
|
105
|
+
id → _status map in app.js (markStatusChanges), and `--pulse` is
|
|
106
|
+
set per node to the status hue it just became.
|
|
107
|
+
Both are off entirely under prefers-reduced-motion. */
|
|
108
|
+
#graph svg { animation: graph-fade-through .18s ease-out; }
|
|
109
|
+
@keyframes graph-fade-through { from { opacity: 0; } to { opacity: 1; } }
|
|
110
|
+
#graph [data-node-id].status-changed { animation: status-pulse .85s ease-out 2; }
|
|
111
|
+
@keyframes status-pulse {
|
|
112
|
+
0%, 100% { filter: drop-shadow(0 0 0 var(--pulse, var(--pending))); }
|
|
113
|
+
45% { filter: drop-shadow(0 0 7px var(--pulse, var(--pending))); }
|
|
114
|
+
}
|
|
115
|
+
@media (prefers-reduced-motion: reduce) {
|
|
116
|
+
#graph svg, #graph [data-node-id].status-changed { animation: none; }
|
|
117
|
+
#graph g[data-edge-from] .pin-edge-line { transition: none; }
|
|
118
|
+
#graph [data-layout-resize] { transition: none; }
|
|
119
|
+
}
|
|
56
120
|
#graph g[data-edge-from]:hover .pin-edge-line,
|
|
57
121
|
#graph g[data-edge-from].edge-hi .pin-edge-line { stroke: var(--pending); stroke-width: 3.2; }
|
|
58
122
|
/* Edge kind by reason (logical view): a data dependency reads as a dashed
|
|
@@ -62,15 +126,27 @@
|
|
|
62
126
|
#graph g[data-edge-from][data-edge-via] { cursor: help; }
|
|
63
127
|
/* Fit reset + a zoom hint, floating top-right of the graph pane. */
|
|
64
128
|
#zoom-toggle { position: absolute; top: 8px; right: 8px; z-index: 2; background: var(--panel);
|
|
65
|
-
color: var(--muted); border: 1px solid var(--
|
|
66
|
-
font
|
|
67
|
-
#zoom-toggle:hover { border-color: var(--
|
|
129
|
+
color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 4px 10px;
|
|
130
|
+
font: var(--t-caption)/1.3 var(--font-mono); cursor: pointer; }
|
|
131
|
+
#zoom-toggle:hover { border-color: var(--focus); color: var(--fg); }
|
|
132
|
+
/* Hand layout (#228): the escape hatch, beside ⤢ fit. Hidden by app.js
|
|
133
|
+
until something on this lens is actually hand-placed — it is both the
|
|
134
|
+
reset and the only indication that dagre isn't the whole story here. */
|
|
135
|
+
#layout-reset { position: absolute; top: 8px; right: 66px; z-index: 2; background: var(--panel);
|
|
136
|
+
color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 4px 10px;
|
|
137
|
+
font: var(--t-caption)/1.3 var(--font-mono); cursor: pointer; }
|
|
138
|
+
#layout-reset:hover { border-color: var(--focus); color: var(--fg); }
|
|
139
|
+
/* A containment box's title moves it and its corner handle resizes it.
|
|
140
|
+
The handle carries opacity="0" as a presentation attribute so a static
|
|
141
|
+
export never shows it — this rule outranks the attribute. */
|
|
142
|
+
#graph [data-layout-resize] { opacity: 0; transition: opacity .1s ease; }
|
|
143
|
+
#graph [data-layout-box]:hover [data-layout-resize] { opacity: .85; }
|
|
68
144
|
/* The way out of waves view, floating top-LEFT so it sits where the eye
|
|
69
145
|
already is when observe drops you into the wave-laned graph. */
|
|
70
146
|
#back-to-infra { position: absolute; top: 8px; left: 8px; z-index: 2; background: var(--panel);
|
|
71
|
-
color: var(--muted); border: 1px solid var(--
|
|
72
|
-
font
|
|
73
|
-
#back-to-infra:hover { border-color: var(--
|
|
147
|
+
color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 4px 10px;
|
|
148
|
+
font: var(--t-caption)/1.3 var(--font-mono); cursor: pointer; }
|
|
149
|
+
#back-to-infra:hover { border-color: var(--focus); color: var(--fg); }
|
|
74
150
|
/* Blocking load scrim: covers everything (clicks can't pass through) while
|
|
75
151
|
a live view change is in flight. */
|
|
76
152
|
#loading-overlay { position: fixed; inset: 0; z-index: 100; display: flex;
|
|
@@ -78,11 +154,14 @@
|
|
|
78
154
|
background: color-mix(in srgb, var(--bg) 72%, transparent); backdrop-filter: blur(1.5px);
|
|
79
155
|
cursor: progress; }
|
|
80
156
|
#loading-overlay[hidden] { display: none; }
|
|
81
|
-
#loading-overlay #loading-msg { font
|
|
157
|
+
#loading-overlay #loading-msg { font: var(--t-body)/1.4 var(--font-mono); color: var(--muted); }
|
|
82
158
|
#loading-overlay .spinner { width: 34px; height: 34px; border-radius: 50%;
|
|
83
|
-
border: 3px solid var(--line); border-top-color: var(--
|
|
159
|
+
border: 3px solid var(--line); border-top-color: var(--focus);
|
|
84
160
|
animation: spin 0.7s linear infinite; }
|
|
85
161
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
162
|
+
/* Selection is one of the few places the FULL accent still shows — the
|
|
163
|
+
drift hues own saturation now, and "which node am I looking at" earns
|
|
164
|
+
one of them. */
|
|
86
165
|
.sel rect, .sel path, .sel > rect { stroke: var(--pending) !important; stroke-width: 2.5 !important; }
|
|
87
166
|
.err { padding: 24px; color: var(--foreign); }
|
|
88
167
|
/* #72: a classified precondition failure (chant's lint gate, a project
|
|
@@ -91,13 +170,13 @@
|
|
|
91
170
|
are usually expected states on a real (imperfect) project, not bugs.
|
|
92
171
|
Generalizes the old M2 (#54) tier-only `.tier-note`. */
|
|
93
172
|
.precondition-error { padding: 24px; max-width: 640px; }
|
|
94
|
-
.precondition-error-title { color: var(--fg); font-size:
|
|
95
|
-
.precondition-error-message { color: var(--muted); white-space: pre-wrap; font:
|
|
96
|
-
.precondition-error-remedy { color: var(--pending); margin-top: 14px; }
|
|
173
|
+
.precondition-error-title { color: var(--fg); font-size: var(--t-display); font-weight: 600; margin-bottom: 10px; }
|
|
174
|
+
.precondition-error-message { color: var(--muted); white-space: pre-wrap; font: var(--t-body)/1.55 var(--font-mono); }
|
|
175
|
+
.precondition-error-remedy { color: var(--pending); margin-top: 14px; font: var(--t-body)/1.5 var(--font-mono); }
|
|
97
176
|
.precondition-error-remedy::before { content: "→ "; }
|
|
98
|
-
#actions button, #inspect button { background: var(--panel); color: var(--fg); border: 1px solid var(--
|
|
99
|
-
border-radius:
|
|
100
|
-
#actions button:hover, #inspect button:hover { border-color: var(--
|
|
177
|
+
#actions button, #inspect button { background: var(--panel); color: var(--fg); border: 1px solid var(--rule);
|
|
178
|
+
border-radius: var(--r-ctl); padding: 4px 12px; font-size: var(--t-body); cursor: pointer; }
|
|
179
|
+
#actions button:hover, #inspect button:hover { border-color: var(--focus); }
|
|
101
180
|
#actions button.approve { border-color: var(--managed); color: var(--managed); }
|
|
102
181
|
#inspect button { border-color: var(--foreign); color: var(--foreign); }
|
|
103
182
|
|
|
@@ -113,64 +192,82 @@
|
|
|
113
192
|
is the discoverable surface, the palette the fast one. Sits above the
|
|
114
193
|
graph's own floating buttons (z 2) and below toasts (60) / the load
|
|
115
194
|
scrim (100) / ⌘K (110). */
|
|
195
|
+
/* #229: the shell is the soft thing (10px, a real drop shadow derived
|
|
196
|
+
from the theme, a palette-tinted --rule border); everything INSIDE it
|
|
197
|
+
is sharp (3px). That contrast is what makes it read as a tool palette
|
|
198
|
+
rather than a card of stacked pills. */
|
|
116
199
|
#panel { position: fixed; width: 272px; z-index: 40; background: var(--panel);
|
|
117
|
-
border: 1px solid var(--
|
|
118
|
-
box-shadow: 0 10px
|
|
200
|
+
border: 1px solid var(--rule); border-radius: var(--r-shell); display: flex; flex-direction: column;
|
|
201
|
+
box-shadow: 0 10px 30px color-mix(in srgb, var(--shadow) 55%, transparent); user-select: none; }
|
|
119
202
|
#panel.dragging { opacity: .95; }
|
|
120
203
|
/* #184: tabs-only — the strip doubles as the drag handle and carries the
|
|
121
204
|
⌘K pill + collapse chevron. Collapsing hides only the body, so the
|
|
122
|
-
panel collapses TO the strip.
|
|
205
|
+
panel collapses TO the strip.
|
|
206
|
+
#229: the strip is slightly recessed off the body, and the active tab
|
|
207
|
+
is a FILL, not an underline — an underlined tab is the single most
|
|
208
|
+
generated-looking control on the web. */
|
|
123
209
|
#panel-tabs { display: flex; align-items: stretch; border-bottom: 1px solid var(--line);
|
|
124
|
-
|
|
210
|
+
background: color-mix(in srgb, var(--well) 45%, var(--panel));
|
|
211
|
+
border-radius: calc(var(--r-shell) - 1px) calc(var(--r-shell) - 1px) 0 0;
|
|
212
|
+
cursor: grab; padding: 3px 4px 3px 3px; gap: 2px; }
|
|
125
213
|
#panel.dragging #panel-tabs { cursor: grabbing; }
|
|
126
|
-
#panel.collapsed #panel-tabs { border-bottom: none; }
|
|
214
|
+
#panel.collapsed #panel-tabs { border-bottom: none; border-radius: calc(var(--r-shell) - 1px); }
|
|
127
215
|
#panel.collapsed #panel-body { display: none; }
|
|
128
|
-
#panel-tabs button[data-tab] { flex: 1; background: none; border: none; border-
|
|
129
|
-
color: var(--muted); font
|
|
216
|
+
#panel-tabs button[data-tab] { flex: 1; background: none; border: none; border-radius: var(--r-ctl);
|
|
217
|
+
color: var(--muted); font: var(--t-caption)/1 var(--font-ui); padding: 6px 0; cursor: pointer; }
|
|
130
218
|
#panel-tabs button[data-tab]:hover { color: var(--fg); }
|
|
131
|
-
#panel-tabs button[data-tab].active { color: var(--fg);
|
|
132
|
-
#panel-tabs #hintk { flex: none; align-self: center;
|
|
219
|
+
#panel-tabs button[data-tab].active { color: var(--fg); background: var(--active); font-weight: 600; }
|
|
220
|
+
#panel-tabs #hintk { flex: none; align-self: center; padding: 2px 6px; margin-left: 2px; }
|
|
133
221
|
#panel-collapse { flex: none; align-self: center; background: none; border: none; color: var(--muted);
|
|
134
|
-
font-size:
|
|
222
|
+
font-size: var(--t-body); cursor: pointer; line-height: 1; padding: 2px 4px; }
|
|
135
223
|
#panel-collapse:hover { color: var(--fg); }
|
|
136
224
|
#panel-body { overflow-y: auto; max-height: min(62vh, 540px); padding: 10px 12px;
|
|
137
|
-
font-size:
|
|
225
|
+
font-size: var(--t-body); user-select: text; }
|
|
138
226
|
#panel-body section { display: none; }
|
|
139
227
|
#panel-body section.active { display: block; }
|
|
140
|
-
#panel h3 { margin: 14px 0 6px; font-size:
|
|
141
|
-
color: var(--muted); font-weight: 600; }
|
|
228
|
+
#panel h3 { margin: 14px 0 6px; font-size: var(--t-caption); text-transform: uppercase;
|
|
229
|
+
letter-spacing: var(--t-caps); color: var(--muted); font-weight: 600; }
|
|
142
230
|
#panel h3:first-child { margin-top: 0; }
|
|
143
|
-
#panel .panel-muted { color: var(--muted); font
|
|
231
|
+
#panel .panel-muted { color: var(--muted); font: var(--t-caption)/1.5 var(--font-mono);
|
|
232
|
+
margin: 4px 0; overflow-wrap: anywhere; }
|
|
144
233
|
#panel .prow { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 2px 0; }
|
|
145
|
-
/* A radio-like option row: the current value
|
|
234
|
+
/* A radio-like option row: the current value is FILLED, with a hairline of
|
|
235
|
+
the full accent down its leading edge — the one saturated mark in the
|
|
236
|
+
chrome, so the eye finds the current lens without the whole row
|
|
237
|
+
shouting in accent-coloured text. */
|
|
146
238
|
#panel .opt { display: block; width: 100%; text-align: left; background: none;
|
|
147
|
-
border: 1px solid transparent; border-radius:
|
|
148
|
-
padding: 4px 8px; cursor: pointer; }
|
|
239
|
+
border: 1px solid transparent; border-radius: var(--r-ctl); color: var(--fg);
|
|
240
|
+
font-size: var(--t-body); padding: 4px 8px; cursor: pointer; }
|
|
149
241
|
#panel .opt:hover { border-color: var(--line); }
|
|
150
|
-
#panel .opt.active { border-color: var(--
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
#panel
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
#panel
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
242
|
+
#panel .opt.active { background: var(--active); border-color: var(--rule); color: var(--fg);
|
|
243
|
+
box-shadow: inset 2px 0 0 var(--pending); }
|
|
244
|
+
#panel select { width: 100%; background: var(--well); color: var(--fg); border: 1px solid var(--line);
|
|
245
|
+
border-radius: var(--r-ctl); padding: 4px 8px; font-size: var(--t-body); cursor: pointer; }
|
|
246
|
+
#panel select:hover { border-color: var(--focus); }
|
|
247
|
+
#panel button.act { background: var(--well); color: var(--fg); border: 1px solid var(--rule);
|
|
248
|
+
border-radius: var(--r-ctl); padding: 3px 10px; font-size: var(--t-caption); cursor: pointer; }
|
|
249
|
+
#panel button.act:hover { border-color: var(--focus); }
|
|
250
|
+
#panel input.panel-input { flex: 1; min-width: 0; background: var(--well); color: var(--fg);
|
|
251
|
+
border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 4px 8px;
|
|
252
|
+
font: var(--t-caption)/1.4 var(--font-mono); }
|
|
253
|
+
#panel input.panel-input:focus { border-color: var(--focus); outline: none; }
|
|
254
|
+
/* Model tab rows: legend counts (static) and clickable node rows. Node
|
|
255
|
+
ids are data — mono, so they align and scan like a terminal listing. */
|
|
161
256
|
#panel .count-row, #panel .node-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
|
|
162
|
-
#panel .node-row { cursor: pointer; border-radius:
|
|
163
|
-
|
|
257
|
+
#panel .node-row { cursor: pointer; border-radius: var(--r-ctl); padding: 3px 6px;
|
|
258
|
+
font: var(--t-caption)/1.5 var(--font-mono); }
|
|
259
|
+
#panel .node-row:hover { background: var(--well); }
|
|
164
260
|
#panel .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; }
|
|
165
261
|
#panel .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
166
|
-
#panel .tag { color: var(--muted); font
|
|
262
|
+
#panel .tag { color: var(--muted); font: var(--t-caption)/1.5 var(--font-mono); white-space: nowrap; }
|
|
167
263
|
|
|
168
264
|
/* Substrate readiness (M5, #54) — one row per substrate on the panel's
|
|
169
265
|
Substrates tab: status dot + name + state, with its actions (bring up /
|
|
170
266
|
reset / run pipeline) visible inline again. #73 moved those into ⌘K;
|
|
171
267
|
they remain there too — same actions, two surfaces. */
|
|
172
268
|
#substrates { display: flex; flex-direction: column; gap: 2px; }
|
|
173
|
-
.sub { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
|
269
|
+
.sub { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
|
270
|
+
font: var(--t-caption)/1.6 var(--font-mono); }
|
|
174
271
|
.sub .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
|
175
272
|
.sub.up .dot { background: var(--managed); }
|
|
176
273
|
.sub.down .dot { background: var(--degraded); }
|
|
@@ -184,34 +281,41 @@
|
|
|
184
281
|
tab now, stacked vertically under the deploy actions. */
|
|
185
282
|
#actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
186
283
|
#dial { display: none; flex-direction: column; align-items: stretch; gap: 8px;
|
|
187
|
-
font-size:
|
|
284
|
+
font-size: var(--t-body); margin-top: 10px; }
|
|
188
285
|
.dial-track { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
189
286
|
.dial-arrow { color: var(--muted); }
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
.dial-step:
|
|
193
|
-
|
|
287
|
+
/* The lifecycle steps are controls, so they take the control radius too —
|
|
288
|
+
the pill shape was the last soft edge left inside the shell. */
|
|
289
|
+
.dial-step { background: var(--well); color: var(--fg); border: 1px solid var(--rule);
|
|
290
|
+
border-radius: var(--r-ctl); padding: 3px 12px; font-size: var(--t-body); cursor: pointer; }
|
|
291
|
+
.dial-step:hover:not(:disabled) { border-color: var(--focus); }
|
|
292
|
+
.dial-step.active { background: var(--active); border-color: var(--rule); color: var(--fg);
|
|
293
|
+
box-shadow: inset 2px 0 0 var(--pending); }
|
|
194
294
|
.dial-step:disabled { opacity: .5; cursor: not-allowed; }
|
|
195
|
-
.dial-detail { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--muted);
|
|
295
|
+
.dial-detail { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--muted);
|
|
296
|
+
font: var(--t-caption)/1.6 var(--font-mono); }
|
|
196
297
|
|
|
197
298
|
#nowline { grid-column: 1 / 3; grid-row: 2; margin: 0; max-height: 160px; overflow: auto; display: none;
|
|
198
|
-
background:
|
|
199
|
-
color: var(--fg); white-space: pre-wrap; }
|
|
299
|
+
background: var(--well); border-top: 1px solid var(--rule); padding: 8px 12px;
|
|
300
|
+
font: var(--t-caption)/1.6 var(--font-mono); color: var(--fg); white-space: pre-wrap; }
|
|
200
301
|
/* #186: the panel's footer — the state the removed header carried. The
|
|
201
302
|
statusbar (#73 "hide controls, never state": current zoom + env +
|
|
202
303
|
stack + tier, see renderStatusbar() in app.js) and the meta line.
|
|
203
304
|
Hidden when the panel collapses, like the body. */
|
|
204
|
-
|
|
205
|
-
|
|
305
|
+
/* Counts, env, zoom, tier — all of it is state read at a glance, so the
|
|
306
|
+
whole footer is mono. */
|
|
307
|
+
#panel-foot { border-top: 1px solid var(--line); padding: 7px 12px;
|
|
308
|
+
display: flex; flex-direction: column; gap: 3px;
|
|
309
|
+
font: var(--t-caption)/1.5 var(--font-mono); }
|
|
206
310
|
#panel.collapsed #panel-foot { display: none; }
|
|
207
|
-
#panel-foot .meta { color: var(--muted);
|
|
208
|
-
#statusbar { color: var(--muted);
|
|
311
|
+
#panel-foot .meta { color: var(--muted); overflow-wrap: anywhere; }
|
|
312
|
+
#statusbar { color: var(--muted); cursor: pointer; }
|
|
209
313
|
/* #131: why the current zoom rendered what it did. Same muted strip,
|
|
210
314
|
italic so it reads as a caption on the state rather than more state. */
|
|
211
315
|
.statusbar-note { font-style: italic; opacity: 0.85; }
|
|
212
|
-
#hintk { color: var(--muted); border: 1px solid var(--
|
|
213
|
-
padding: 2px 8px; font
|
|
214
|
-
#hintk:hover { border-color: var(--
|
|
316
|
+
#hintk { color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-ctl);
|
|
317
|
+
padding: 2px 8px; font: var(--t-caption)/1.4 var(--font-mono); cursor: pointer; white-space: nowrap; }
|
|
318
|
+
#hintk:hover { border-color: var(--focus); color: var(--fg); }
|
|
215
319
|
/* Command palette (⌘K / Ctrl+K, #73) — ported from spicypath's FG-036
|
|
216
320
|
pattern (../spicypath/src/index.html), theme vars mapped onto behold's
|
|
217
321
|
own: --panel/--line/--fg/--bg already exist; --pending stands in for
|
|
@@ -221,18 +325,22 @@
|
|
|
221
325
|
applyPaletteContrast() in app.js) so the selected row stays legible
|
|
222
326
|
across all 552 themes, light or dark. */
|
|
223
327
|
#palette { position: fixed; inset: 0; z-index: 110; display: none; align-items: flex-start;
|
|
224
|
-
justify-content: center; background:
|
|
328
|
+
justify-content: center; background: color-mix(in srgb, var(--shadow) 62%, transparent); }
|
|
225
329
|
#palette.on { display: flex; }
|
|
226
330
|
#palette .box { margin-top: 12vh; width: min(560px, 92vw); background: var(--panel);
|
|
227
|
-
border: 1px solid var(--
|
|
228
|
-
overflow: hidden; }
|
|
229
|
-
|
|
230
|
-
|
|
331
|
+
border: 1px solid var(--rule); border-radius: var(--r-shell);
|
|
332
|
+
box-shadow: 0 16px 44px color-mix(in srgb, var(--shadow) 62%, transparent); overflow: hidden; }
|
|
333
|
+
/* You type commands here — mono, at the one display size, is the whole
|
|
334
|
+
point of the surface. */
|
|
335
|
+
#pal-input { width: 100%; box-sizing: border-box; background: var(--well); color: var(--fg);
|
|
336
|
+
border: 0; border-bottom: 1px solid var(--rule); padding: 12px 14px;
|
|
337
|
+
font: var(--t-display)/1.3 var(--font-mono); outline: none; }
|
|
231
338
|
#pal-list { max-height: 50vh; overflow: auto; padding: 5px; }
|
|
232
|
-
#pal-list .row { padding: 7px 11px; border-radius:
|
|
339
|
+
#pal-list .row { padding: 7px 11px; border-radius: var(--r-ctl); color: var(--fg);
|
|
340
|
+
font: var(--t-body)/1.5 var(--font-mono);
|
|
233
341
|
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
234
342
|
#pal-list .row.sel { background: var(--pending); color: var(--pal-sel-fg, var(--fg)); }
|
|
235
|
-
#pal-list .empty { padding: 8px 11px; color: var(--muted); font-size:
|
|
343
|
+
#pal-list .empty { padding: 8px 11px; color: var(--muted); font-size: var(--t-body); }
|
|
236
344
|
</style>
|
|
237
345
|
</head>
|
|
238
346
|
<body>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The hand-layout delta store (#228) — what a dragged node or a resized
|
|
2
|
+
// containment box remembers, and where.
|
|
3
|
+
//
|
|
4
|
+
// Deltas, never absolute positions. The graph is chant's and the layout is
|
|
5
|
+
// yours, and that ordering must not invert: a stored `{dx,dy}` is an offset
|
|
6
|
+
// from whatever dagre decided on THIS render, so a node that legitimately
|
|
7
|
+
// moved in the source still lands under your offset instead of pinning itself
|
|
8
|
+
// to a stale coordinate. A delta whose node id is gone from the graph is
|
|
9
|
+
// dropped without a word (`applicable`).
|
|
10
|
+
//
|
|
11
|
+
// No DOM in here on purpose — this is the testable half of #228 (see
|
|
12
|
+
// web/layout-store.test.js). app.js owns the pointer work and the SVG. The
|
|
13
|
+
// second tier of the issue (a `.behold/layout.json` sidecar behind
|
|
14
|
+
// `POST /api/layout`, so exports and snapshots honour the same deltas) is the
|
|
15
|
+
// follow-up; this is the shape it will serialize.
|
|
16
|
+
|
|
17
|
+
const PREFIX = "behold.layout";
|
|
18
|
+
const NUM = ["dx", "dy", "dw", "dh"];
|
|
19
|
+
|
|
20
|
+
/** A path or a lens name → one flat, readable key segment. */
|
|
21
|
+
export function slug(s) {
|
|
22
|
+
return (
|
|
23
|
+
String(s ?? "")
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
26
|
+
.replace(/^-+|-+$/g, "") || "unknown"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The storage key for one project + lens: `behold.layout.<project>.<lens>`. */
|
|
31
|
+
export function layoutKey(projectKey, lens) {
|
|
32
|
+
return `${PREFIX}.${slug(projectKey)}.${slug(lens)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The project half of the key, from /api/project's payload (see initPickers). */
|
|
36
|
+
export function projectKeyOf(info) {
|
|
37
|
+
return slug(info && info.projectDir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The lens half of the key. `?logical=1` and the plain graph must keep
|
|
42
|
+
* independent layouts (#228's acceptance), and so must radial vs. straight and
|
|
43
|
+
* one stack vs. another — every one of those re-lays the graph out from
|
|
44
|
+
* scratch, so a delta from one means nothing in another. The env is
|
|
45
|
+
* deliberately NOT in the key: picking an env recolours the same nodes, it
|
|
46
|
+
* doesn't re-place them, and losing your layout on an overlay flip would be
|
|
47
|
+
* the wrong trade.
|
|
48
|
+
*/
|
|
49
|
+
export function lensKeyOf({ zoom, radial, stack } = {}) {
|
|
50
|
+
return [zoom || "graph", radial ? "radial" : null, stack ? `stack-${stack}` : null].filter(Boolean).join("+");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Coerce arbitrary parsed JSON into `{id: {dx,dy,dw,dh}}` of finite non-zero numbers. */
|
|
54
|
+
export function normalize(raw) {
|
|
55
|
+
const out = {};
|
|
56
|
+
if (!raw || typeof raw !== "object") return out;
|
|
57
|
+
for (const [id, d] of Object.entries(raw)) {
|
|
58
|
+
if (!id || !d || typeof d !== "object") continue;
|
|
59
|
+
const clean = {};
|
|
60
|
+
for (const k of NUM) {
|
|
61
|
+
const v = Number(d[k]);
|
|
62
|
+
if (Number.isFinite(v) && v !== 0) clean[k] = v;
|
|
63
|
+
}
|
|
64
|
+
if (Object.keys(clean).length) out[id] = clean;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True when nothing is hand-placed — drives whether the reset control shows. */
|
|
70
|
+
export function isEmpty(deltas) {
|
|
71
|
+
return Object.keys(normalize(deltas)).length === 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read one key. Unparseable, missing, or a storage that throws → `{}`. */
|
|
75
|
+
export function readLayout(storage, key) {
|
|
76
|
+
try {
|
|
77
|
+
return normalize(JSON.parse(storage.getItem(key) || "{}"));
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Write one key (an empty layout removes it rather than storing `{}`). */
|
|
84
|
+
export function writeLayout(storage, key, deltas) {
|
|
85
|
+
const d = normalize(deltas);
|
|
86
|
+
try {
|
|
87
|
+
if (Object.keys(d).length) storage.setItem(key, JSON.stringify(d));
|
|
88
|
+
else storage.removeItem(key);
|
|
89
|
+
} catch {
|
|
90
|
+
/* private mode, quota, a static export off file:// — layout is a nicety */
|
|
91
|
+
}
|
|
92
|
+
return d;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function clearLayout(storage, key) {
|
|
96
|
+
try {
|
|
97
|
+
storage.removeItem(key);
|
|
98
|
+
} catch {
|
|
99
|
+
/* see writeLayout */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Replace one id's delta, returning a NEW map. An all-zero delta is pruned. */
|
|
104
|
+
export function setDelta(deltas, id, delta) {
|
|
105
|
+
const next = normalize(deltas);
|
|
106
|
+
const clean = normalize({ [id]: delta })[id];
|
|
107
|
+
if (clean) next[id] = clean;
|
|
108
|
+
else delete next[id];
|
|
109
|
+
return next;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Drop deltas whose id is no longer in the graph — silently, per #228. */
|
|
113
|
+
export function applicable(deltas, liveIds) {
|
|
114
|
+
const live = liveIds instanceof Set ? liveIds : new Set(liveIds || []);
|
|
115
|
+
const out = {};
|
|
116
|
+
for (const [id, d] of Object.entries(normalize(deltas))) if (live.has(id)) out[id] = d;
|
|
117
|
+
return out;
|
|
118
|
+
}
|