@orbytes/astrolab 0.3.0 → 0.4.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -64
- package/defaults.mjs +65 -0
- package/dist/core/virtual-module/virtual-routes.js +12 -1
- package/docs/PIN-CONTRACT.md +8 -0
- package/docs/PIN.md +29 -19
- package/index.d.ts +40 -16
- package/index.mjs +32 -10
- package/package.json +6 -2
- package/src/Home.astro +167 -264
- package/src/LabHead.astro +37 -1047
- package/src/chrome/ComponentCard.astro +69 -0
- package/src/chrome/Icon.astro +21 -0
- package/src/chrome/LICENSE-icons +43 -0
- package/src/chrome/Nav.astro +105 -0
- package/src/chrome/Panel.astro +104 -0
- package/src/chrome/Shell.astro +106 -0
- package/src/chrome/Sprite.astro +23 -0
- package/src/chrome/StoryView.astro +251 -0
- package/src/chrome/Tree.astro +83 -0
- package/src/chrome/ViewportControls.astro +98 -0
- package/src/chrome/ViewportStage.astro +32 -0
- package/src/chrome/fonts/OFL.txt +93 -0
- package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/src/chrome/icons.ts +59 -0
- package/src/chrome/marks-client.ts +102 -0
- package/src/chrome/model.ts +142 -0
- package/src/chrome/pins-data.ts +30 -0
- package/src/chrome/shell-client.ts +372 -0
- package/src/chrome/site-data.ts +230 -0
- package/src/chrome/trees.ts +152 -0
- package/src/chrome/viewport-client.ts +579 -0
- package/src/chrome/views/Assets.astro +110 -0
- package/src/chrome/views/Pages.astro +178 -0
- package/src/chrome/views/Placeholder.astro +37 -0
- package/src/chrome/views/Tasks.astro +107 -0
- package/src/core/LICENSE-astrobook +16 -0
- package/src/core/lib/components/home.astro +4 -2
- package/src/core/lib/pages/story.astro +12 -10
- package/src/core/virtual-module/virtual-routes.ts +20 -4
- package/src/pin/board.mjs +389 -175
- package/src/pin/index.mjs +33 -2
- package/src/pin/toolbar.js +1 -1
- package/src/shell/Browse.astro +106 -353
- package/src/shell/Viewport.astro +22 -1315
- package/src/shell/lab-index.ts +23 -14
- package/src/ui/components/app.astro +5 -7
- package/src/ui/components/theme-script.astro +13 -2
- package/src/ui/lab.css +2152 -370
- package/virtual.d.ts +13 -0
- package/src/shell/CardGrid.astro +0 -297
- package/src/ui/components/build-path.ts +0 -13
- package/src/ui/components/build-tree.ts +0 -108
- package/src/ui/components/collapse-duration.ts +0 -28
- package/src/ui/components/compress-terms.ts +0 -10
- package/src/ui/components/dashboard-layout.astro +0 -39
- package/src/ui/components/home.astro +0 -65
- package/src/ui/components/layout.astro +0 -110
- package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
- package/src/ui/components/sidebar-button-search.astro +0 -23
- package/src/ui/components/sidebar-button-theme.astro +0 -9
- package/src/ui/components/sidebar-button.astro +0 -24
- package/src/ui/components/sidebar-resize-handle.astro +0 -74
- package/src/ui/components/sidebar-search-panel.astro +0 -41
- package/src/ui/components/sidebar-search-script.ts +0 -103
- package/src/ui/components/sidebar-title.astro +0 -17
- package/src/ui/components/sidebar-tree-node.astro +0 -143
- package/src/ui/components/sidebar-tree.astro +0 -84
- package/src/ui/components/sidebar.astro +0 -29
- package/src/ui/components/theme-toggle.astro +0 -63
package/src/LabHead.astro
CHANGED
|
@@ -1,110 +1,45 @@
|
|
|
1
1
|
---
|
|
2
|
-
// LabHead — the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// the Feedbucket tag.
|
|
2
|
+
// LabHead — the head of every CHROME page: ./chrome/Shell.astro mounts it, and so does the
|
|
3
|
+
// viewport redirect page. It carries only what must be in <head>: the unconditional noindex, the
|
|
4
|
+
// chrome's theme (before paint), the keyboard guard every shortcut calls, and Feedbucket when the
|
|
5
|
+
// consumer configured a key.
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
7
|
+
// REWRITTEN 2026-09-24 for the redesign. Until then this file also decorated Astrobook's sidebar
|
|
8
|
+
// after the fact — pills, cull and responsive boxes, search widening, header buttons — with 800
|
|
9
|
+
// lines of script patching a DOM it did not render, plus a copy of the whole lab index as JSON on
|
|
10
|
+
// every page. The new chrome renders all of that on the server, so none of it is needed here.
|
|
11
|
+
//
|
|
12
|
+
// It is still the package's `head` option to Astrobook (../index.mjs), and still must not mount
|
|
13
|
+
// the consumer's head: the chrome is standard on every site (decided 2026-09-22).
|
|
15
14
|
import "./ui/lab.css";
|
|
16
15
|
|
|
17
16
|
import ThemeScript from "./ui/components/theme-script.astro";
|
|
18
17
|
import labConfig from "virtual:orbytes-lab/config.mjs";
|
|
19
18
|
|
|
20
|
-
// Feedbucket on lab pages too (decided 2026-09-05
|
|
21
|
-
//
|
|
22
|
-
// nothing: the consumer keeps its own staging gate and passes the key only where the widget
|
|
23
|
-
// belongs.
|
|
19
|
+
// Feedbucket on lab pages too (decided 2026-09-05). The package takes a key or nothing: the
|
|
20
|
+
// consumer keeps its own staging gate and passes the key only where the widget belongs.
|
|
24
21
|
const feedbucketKey = labConfig.feedbucketKey;
|
|
25
|
-
|
|
26
|
-
// The lab index — every story with its module file, tier, section/version, LIVE slot (which page
|
|
27
|
-
// under src/pages/ mounts it, and where in that page), its responsive mark and the live sections
|
|
28
|
-
// that import it. Built once per process: this head renders on every lab page (130+ in a build),
|
|
29
|
-
// and the index globs every story module.
|
|
30
|
-
import { buildLabIndex, labBase, type LabIndex } from "./shell/lab-index";
|
|
31
|
-
|
|
32
|
-
const memo = globalThis as typeof globalThis & { __labIndex?: LabIndex };
|
|
33
|
-
const labIndex = (memo.__labIndex ??= buildLabIndex());
|
|
34
|
-
|
|
35
|
-
// Only what the sidebar script needs; `</` is escaped so the JSON can never close its own tag.
|
|
36
|
-
// `viewportBase` is where the viewport configurator lives (./shell/Viewport.astro, one page
|
|
37
|
-
// per story), so the sidebar's Viewport button never hard-codes the lab's base path.
|
|
38
|
-
// `editorUrl` is null in any build — the VS Code link is a local absolute path, dev only.
|
|
39
|
-
const labIndexJson = JSON.stringify({
|
|
40
|
-
viewportBase: `${labBase}/viewport`,
|
|
41
|
-
// Which tier the cull switch acts on and which carries the responsive marks — the consumer's
|
|
42
|
-
// `tiers` option, never a tier name written into the script below.
|
|
43
|
-
tiers: { cull: labConfig.cullTier, responsive: labConfig.sectionsTier },
|
|
44
|
-
items: labIndex.items.map(
|
|
45
|
-
({
|
|
46
|
-
storyId,
|
|
47
|
-
moduleId,
|
|
48
|
-
moduleFile,
|
|
49
|
-
moduleName,
|
|
50
|
-
tier,
|
|
51
|
-
section,
|
|
52
|
-
version,
|
|
53
|
-
live,
|
|
54
|
-
livePage,
|
|
55
|
-
liveOn,
|
|
56
|
-
responsive,
|
|
57
|
-
stagingUrl,
|
|
58
|
-
editorUrl,
|
|
59
|
-
usedBy,
|
|
60
|
-
tags,
|
|
61
|
-
}) => ({
|
|
62
|
-
storyId,
|
|
63
|
-
moduleId,
|
|
64
|
-
moduleFile,
|
|
65
|
-
moduleName,
|
|
66
|
-
tier,
|
|
67
|
-
section,
|
|
68
|
-
version,
|
|
69
|
-
live,
|
|
70
|
-
livePage,
|
|
71
|
-
liveOn,
|
|
72
|
-
responsive,
|
|
73
|
-
stagingUrl,
|
|
74
|
-
editorUrl,
|
|
75
|
-
usedBy,
|
|
76
|
-
tags,
|
|
77
|
-
}),
|
|
78
|
-
),
|
|
79
|
-
}).replace(/</g, "\\u003c");
|
|
80
22
|
---
|
|
81
23
|
|
|
82
|
-
{/* The
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
chrome themes on (./ui/lab.css). Exactly one per document: the bare story page mounts no
|
|
86
|
-
LabHead and carries its own copy. */}
|
|
87
|
-
<ThemeScript />
|
|
24
|
+
{/* The chrome's own theme — its own storage key, separate from the preview's (decided
|
|
25
|
+
2026-09-24; see ./ui/components/theme-script.astro). */}
|
|
26
|
+
<ThemeScript storageKey="lab-chrome-theme" />
|
|
88
27
|
|
|
89
|
-
{/* THE LAB IS STAGING-ONLY AND NEVER INDEXABLE (
|
|
28
|
+
{/* THE LAB IS STAGING-ONLY AND NEVER INDEXABLE (decided 2026-09-02: the lab is live on staging and
|
|
90
29
|
staging only, and must never go live on the main domain).
|
|
91
30
|
|
|
92
31
|
Two independent mechanisms enforce that, deliberately — one gate is not enough for a rule this
|
|
93
32
|
absolute:
|
|
94
33
|
|
|
95
34
|
1. BUILD GATE — the consumer's astro.config.mjs registers this integration for `astro dev`
|
|
96
|
-
or a staging build only, so a production build emits no lab routes at all.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
Astrobook, NOT by the site's own layout, so they never receive the sitewide staging
|
|
102
|
-
noindex that layout emits. Without this line the lab was the one crawlable corner of
|
|
103
|
-
an otherwise noindex staging deploy (verified live on 2026-09-02: home page carried
|
|
104
|
-
`noindex`, /lab/ carried no robots meta, and robots.txt has no Disallow).
|
|
35
|
+
or a staging build only, so a production build emits no lab routes at all.
|
|
36
|
+
2. THIS META — unconditional, not gated on the environment. Lab pages are not rendered by the
|
|
37
|
+
site's own layout, so they never receive the site's own staging noindex. Without this line
|
|
38
|
+
the lab was the one crawlable corner of an otherwise noindex staging deploy (verified live
|
|
39
|
+
on 2026-09-02).
|
|
105
40
|
|
|
106
41
|
It is unconditional on purpose: if the build gate ever regresses, the meta is the thing that
|
|
107
|
-
still keeps the lab out of search. Do not make it depend on
|
|
42
|
+
still keeps the lab out of search. Do not make it depend on an environment variable. */}
|
|
108
43
|
<meta name="robots" content="noindex, nofollow" />
|
|
109
44
|
|
|
110
45
|
{
|
|
@@ -120,29 +55,27 @@ const labIndexJson = JSON.stringify({
|
|
|
120
55
|
|
|
121
56
|
{/* THE KEYBOARD GUARD — one predicate, shared by every lab shortcut.
|
|
122
57
|
|
|
123
|
-
`window.__labIgnoreKey(event)` answers: is this keystroke someone else's? The lab owns ⌘B,
|
|
124
|
-
⌘K and j/k
|
|
125
|
-
None of them may fire while a character is being typed — into
|
|
126
|
-
a form on the previewed story, or into a panel belonging to
|
|
58
|
+
`window.__labIgnoreKey(event)` answers: is this keystroke someone else's? The lab owns ⌘B, ⌘⇧B,
|
|
59
|
+
⌘., ⌘K and j/k (./chrome/shell-client.ts) and the viewport's [ ] r f 0
|
|
60
|
+
(./chrome/viewport-client.ts). None of them may fire while a character is being typed — into
|
|
61
|
+
the lab's own search box, into a form on the previewed story, or into a panel belonging to
|
|
62
|
+
another tool.
|
|
127
63
|
|
|
128
|
-
WHY IT READS composedPath() AND NOT document.activeElement (
|
|
64
|
+
WHY IT READS composedPath() AND NOT document.activeElement (decided 2026-09-22: keyboard
|
|
129
65
|
shortcuts in the lab must be disabled while a pin note is being written). The pin composer's
|
|
130
66
|
textarea lives inside the Astro dev toolbar's shadow tree. A keystroke crossing a shadow
|
|
131
|
-
boundary is RETARGETED, so a `document` listener sees `event.target` — and
|
|
132
|
-
as the shadow HOST, <astro-dev-toolbar>, which is neither an input
|
|
133
|
-
Every "am I typing?" test written against either of those reads false and
|
|
134
|
-
fires. Measured: typing `j` into a pin comment
|
|
135
|
-
|
|
136
|
-
|
|
67
|
+
boundary is RETARGETED, so a `document` listener sees `event.target` — and
|
|
68
|
+
`document.activeElement` — as the shadow HOST, <astro-dev-toolbar>, which is neither an input
|
|
69
|
+
nor contentEditable. Every "am I typing?" test written against either of those reads false and
|
|
70
|
+
the shortcut fires. Measured: typing `j` into a pin comment stepped the lab to the next story,
|
|
71
|
+
ate the character and took focus off the composer — after which every remaining character
|
|
72
|
+
landed on <body> and was lost.
|
|
137
73
|
|
|
138
74
|
composedPath() is the one view that still holds the real target, because Astro's toolbar
|
|
139
75
|
shadow roots are open. A closed root would trim the path back to the host — which is why the
|
|
140
|
-
<astro-dev-toolbar> test below is a second, independent line of defence and not redundant
|
|
141
|
-
it holds whatever the toolbar does with its shadow roots, and it covers the toolbar's
|
|
142
|
-
BUTTONS too, where nothing is being typed but the keystroke is still not the lab's.
|
|
76
|
+
<astro-dev-toolbar> test below is a second, independent line of defence and not redundant.
|
|
143
77
|
|
|
144
|
-
Defined before every handler that uses it, and idempotent
|
|
145
|
-
but a view transition can re-run an inline script. */}
|
|
78
|
+
Defined before every handler that uses it, and idempotent. */}
|
|
146
79
|
<script is:inline>
|
|
147
80
|
window.__labIgnoreKey ||= (event) => {
|
|
148
81
|
const path =
|
|
@@ -157,946 +90,3 @@ const labIndexJson = JSON.stringify({
|
|
|
157
90
|
return false;
|
|
158
91
|
};
|
|
159
92
|
</script>
|
|
160
|
-
|
|
161
|
-
{/* Sidebar collapse for the Astrobook dashboard. Astrobook 0.13.3 has no built-in
|
|
162
|
-
collapse (its "fullscreen" button navigates away to the bare story), so this
|
|
163
|
-
injects one the same way Astrobook persists its sidebar width: a data attribute
|
|
164
|
-
on <html>, localStorage, and a re-apply on every view-transition swap. Bare
|
|
165
|
-
story pages render no #astrobook-sidebar, so everything below no-ops there. */}
|
|
166
|
-
<script is:inline>
|
|
167
|
-
(() => {
|
|
168
|
-
if (window.__labSidebarCollapseInit) return;
|
|
169
|
-
window.__labSidebarCollapseInit = true;
|
|
170
|
-
|
|
171
|
-
const KEY = "lab-sidebar-collapsed";
|
|
172
|
-
|
|
173
|
-
const applyState = () => {
|
|
174
|
-
if (localStorage.getItem(KEY) === "1") {
|
|
175
|
-
document.documentElement.dataset.labSidebarCollapsed = "";
|
|
176
|
-
} else {
|
|
177
|
-
delete document.documentElement.dataset.labSidebarCollapsed;
|
|
178
|
-
}
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
const toggle = () => {
|
|
182
|
-
if (localStorage.getItem(KEY) === "1") localStorage.removeItem(KEY);
|
|
183
|
-
else localStorage.setItem(KEY, "1");
|
|
184
|
-
applyState();
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
const CHEVRONS_LEFT =
|
|
188
|
-
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>';
|
|
189
|
-
const CHEVRONS_RIGHT =
|
|
190
|
-
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/></svg>';
|
|
191
|
-
|
|
192
|
-
const makeButton = (id, title, svg) => {
|
|
193
|
-
const button = document.createElement("button");
|
|
194
|
-
button.id = id;
|
|
195
|
-
button.className = "astrobook-sidebar-button";
|
|
196
|
-
button.title = title;
|
|
197
|
-
button.setAttribute("aria-label", title);
|
|
198
|
-
button.innerHTML = svg;
|
|
199
|
-
button.addEventListener("click", toggle);
|
|
200
|
-
return button;
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const setup = () => {
|
|
204
|
-
applyState();
|
|
205
|
-
const sidebar = document.getElementById("astrobook-sidebar");
|
|
206
|
-
if (!sidebar) return; // bare story page — no dashboard chrome
|
|
207
|
-
|
|
208
|
-
const header = sidebar.querySelector("nav > div");
|
|
209
|
-
if (header && !document.getElementById("lab-sidebar-collapse")) {
|
|
210
|
-
header.appendChild(
|
|
211
|
-
makeButton(
|
|
212
|
-
"lab-sidebar-collapse",
|
|
213
|
-
"Collapse sidebar (⌘B)",
|
|
214
|
-
CHEVRONS_LEFT,
|
|
215
|
-
),
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
if (!document.getElementById("lab-sidebar-expand")) {
|
|
219
|
-
document.body.appendChild(
|
|
220
|
-
makeButton(
|
|
221
|
-
"lab-sidebar-expand",
|
|
222
|
-
"Show sidebar (⌘B)",
|
|
223
|
-
CHEVRONS_RIGHT,
|
|
224
|
-
),
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
applyState(); // pre-paint, so a collapsed sidebar never flashes visible
|
|
230
|
-
document.addEventListener("astro:after-swap", setup);
|
|
231
|
-
document.addEventListener("keydown", (event) => {
|
|
232
|
-
if (window.__labIgnoreKey(event)) return; // › THE KEYBOARD GUARD, above
|
|
233
|
-
if (
|
|
234
|
-
event.key.toLowerCase() === "b" &&
|
|
235
|
-
(event.metaKey || event.ctrlKey) &&
|
|
236
|
-
document.getElementById("astrobook-sidebar")
|
|
237
|
-
) {
|
|
238
|
-
event.preventDefault();
|
|
239
|
-
toggle();
|
|
240
|
-
}
|
|
241
|
-
});
|
|
242
|
-
if (document.readyState === "loading") {
|
|
243
|
-
document.addEventListener("DOMContentLoaded", setup, { once: true });
|
|
244
|
-
} else {
|
|
245
|
-
setup();
|
|
246
|
-
}
|
|
247
|
-
})();
|
|
248
|
-
</script>
|
|
249
|
-
|
|
250
|
-
<style is:global>
|
|
251
|
-
:root[data-lab-sidebar-collapsed] #astrobook-sidebar,
|
|
252
|
-
:root[data-lab-sidebar-collapsed] astrobook-sidebar-resize-handle {
|
|
253
|
-
display: none;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
#lab-sidebar-collapse svg,
|
|
257
|
-
#lab-sidebar-expand svg {
|
|
258
|
-
display: block;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/* Floating re-open control; only exists while the sidebar is collapsed. Sits
|
|
262
|
-
over whatever the story canvas renders, so it carries its own ground. */
|
|
263
|
-
#lab-sidebar-expand {
|
|
264
|
-
display: none;
|
|
265
|
-
position: fixed;
|
|
266
|
-
top: 0.85rem;
|
|
267
|
-
left: 0.5rem;
|
|
268
|
-
z-index: 50;
|
|
269
|
-
background-color: var(--lab-color-bg-sunken);
|
|
270
|
-
border: 1px solid var(--lab-color-border);
|
|
271
|
-
}
|
|
272
|
-
:root[data-lab-sidebar-collapsed] #lab-sidebar-expand {
|
|
273
|
-
display: block;
|
|
274
|
-
}
|
|
275
|
-
</style>
|
|
276
|
-
|
|
277
|
-
{/* The lab index, for the sidebar script below. Emitted on every lab page (the bare story pages
|
|
278
|
-
included — they have no sidebar, so nothing reads it there). */}
|
|
279
|
-
<script is:inline type="application/json" id="lab-index" set:html={labIndexJson} />
|
|
280
|
-
|
|
281
|
-
{/* Lab shell — live pills, responsive marks, ⌘K search, j/k navigation, the cull switch, and the
|
|
282
|
-
Viewport / staging / VS Code buttons. Same shape as the collapse script above:
|
|
283
|
-
inline, guarded on #astrobook-sidebar (bare story pages and any other page that imports this
|
|
284
|
-
head have no sidebar, so every step below no-ops there), re-run on astro:after-swap and
|
|
285
|
-
astro:page-load, idempotent — a row is decorated once and marked, so a pill is never added
|
|
286
|
-
twice.
|
|
287
|
-
|
|
288
|
-
Astrobook 0.13.3 DOM this couples to (verified in @astrobook/ui/dist/components — the hashed
|
|
289
|
-
UnoCSS class names are NOT stable, these are):
|
|
290
|
-
#astrobook-sidebar > nav > div the header row (the collapse button lands there too)
|
|
291
|
-
<html data-active-story="<storyId>"> the story the dashboard shows, "" on the home page;
|
|
292
|
-
replaced on every swap. The Viewport button links to
|
|
293
|
-
/lab/viewport/<storyId> from it.
|
|
294
|
-
astrobook-sidebar-tree#astrobook-sidebar-tree the tree; transition:persist'ed, so it — and
|
|
295
|
-
everything this script hangs on it — survives a
|
|
296
|
-
view transition. The header does not, so the
|
|
297
|
-
cull button is re-added on every swap.
|
|
298
|
-
details[data-astrobook-collapsible][data-id="dir:<Name>"|"module:<moduleId>"][data-search-text]
|
|
299
|
-
> summary.astrobook-sidebar-link folder and module rows. dir ids are BARE folder
|
|
300
|
-
names (dir:V2 occurs in every section), so a
|
|
301
|
-
folder's path is the chain of ancestor dir ids.
|
|
302
|
-
a[data-astrobook-story-link][data-story-id][data-search-text].astrobook-sidebar-link
|
|
303
|
-
story leaves. A module with a single story named
|
|
304
|
-
like itself is hoisted: its link sits directly
|
|
305
|
-
under the folder with no module: ancestor.
|
|
306
|
-
#astrobook-search-toggle / #astrobook-search-panel[data-open] / #astrobook-search-input
|
|
307
|
-
the search. Filtering is CSS Astrobook injects:
|
|
308
|
-
`[data-search-text]:not([data-search-text*=term])
|
|
309
|
-
{ display:none }` per lower-cased query term — so
|
|
310
|
-
widening a match means appending lower-cased terms
|
|
311
|
-
to data-search-text on the row AND on every
|
|
312
|
-
details ancestor (a hidden ancestor hides the
|
|
313
|
-
row). Elements without data-search-text (the
|
|
314
|
-
pills) are never touched by that CSS.
|
|
315
|
-
|
|
316
|
-
Mark APIs (dev-only middleware, absent on staging — the switch and the boxes hide themselves
|
|
317
|
-
when the probe fails):
|
|
318
|
-
GET /__lab/cull → { marked: string[] (module files), updated }; PUT { marked } → 200
|
|
319
|
-
{ ok, marked } | 400 { error, offenders }.
|
|
320
|
-
GET /__lab/responsive → { done: string[], approved: string[], updated }; PUT
|
|
321
|
-
{ done, approved } → 200 { ok, done, approved } | 400 { error, offenders }. Section
|
|
322
|
-
versions only — components and explorations never show the boxes. */}
|
|
323
|
-
<script is:inline>
|
|
324
|
-
(() => {
|
|
325
|
-
if (window.__labShellInit) return;
|
|
326
|
-
window.__labShellInit = true;
|
|
327
|
-
|
|
328
|
-
// The tier roles, read straight off the index blob above (it is emitted before this script,
|
|
329
|
-
// so it is already in the DOM). Either may be null on a site whose `tiers` option declares no
|
|
330
|
-
// such tier, and then the matching controls simply never find a row to hang on.
|
|
331
|
-
const LAB_TIERS = (() => {
|
|
332
|
-
try {
|
|
333
|
-
const blob = document.getElementById("lab-index");
|
|
334
|
-
return (JSON.parse((blob && blob.textContent) || "{}").tiers) || {};
|
|
335
|
-
} catch {
|
|
336
|
-
return {};
|
|
337
|
-
}
|
|
338
|
-
})();
|
|
339
|
-
|
|
340
|
-
const CULL_KEY = "lab-cull-on"; // sessionStorage — the switch's on/off state
|
|
341
|
-
const CULL_ENDPOINT = "/__lab/cull";
|
|
342
|
-
const RESPONSIVE_ENDPOINT = "/__lab/responsive";
|
|
343
|
-
const TREE_ID = "astrobook-sidebar-tree";
|
|
344
|
-
|
|
345
|
-
// ---- index --------------------------------------------------------------------------------
|
|
346
|
-
let index = null;
|
|
347
|
-
const readIndex = () => {
|
|
348
|
-
if (index) return index;
|
|
349
|
-
const blob = document.getElementById("lab-index");
|
|
350
|
-
if (!blob) return null;
|
|
351
|
-
let items;
|
|
352
|
-
let viewportBase;
|
|
353
|
-
try {
|
|
354
|
-
const parsed = JSON.parse(blob.textContent || "{}");
|
|
355
|
-
items = parsed.items || [];
|
|
356
|
-
viewportBase = typeof parsed.viewportBase === "string" ? parsed.viewportBase : "/lab/viewport";
|
|
357
|
-
} catch {
|
|
358
|
-
return null;
|
|
359
|
-
}
|
|
360
|
-
const byStory = new Map();
|
|
361
|
-
const byModule = new Map();
|
|
362
|
-
for (const item of items) {
|
|
363
|
-
byStory.set(item.storyId, item);
|
|
364
|
-
if (!byModule.has(item.moduleId)) byModule.set(item.moduleId, []);
|
|
365
|
-
byModule.get(item.moduleId).push(item);
|
|
366
|
-
}
|
|
367
|
-
index = { items, byStory, byModule, viewportBase };
|
|
368
|
-
return index;
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
const directoryOf = (item) => {
|
|
372
|
-
const cut = item.moduleId.lastIndexOf("/");
|
|
373
|
-
return cut < 0 ? "" : item.moduleId.slice(0, cut);
|
|
374
|
-
};
|
|
375
|
-
const liveUnder = (path) =>
|
|
376
|
-
index.items.filter((item) => {
|
|
377
|
-
if (!item.live) return false;
|
|
378
|
-
const directory = directoryOf(item);
|
|
379
|
-
return directory === path || directory.startsWith(path + "/");
|
|
380
|
-
}).length;
|
|
381
|
-
|
|
382
|
-
// ---- DOM ----------------------------------------------------------------------------------
|
|
383
|
-
const tree = () => document.getElementById(TREE_ID);
|
|
384
|
-
const folderPath = (details) => {
|
|
385
|
-
const names = [];
|
|
386
|
-
for (let el = details; el && el.id !== TREE_ID; el = el.parentElement) {
|
|
387
|
-
if (el.tagName === "DETAILS" && (el.dataset.id || "").startsWith("dir:")) {
|
|
388
|
-
names.unshift(el.dataset.id.slice(4));
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
return names.join("/");
|
|
392
|
-
};
|
|
393
|
-
const moduleIdOf = (details) => details.dataset.id.slice("module:".length);
|
|
394
|
-
const isHoisted = (link) => !link.closest('details[data-id^="module:"]');
|
|
395
|
-
const summaryOf = (details) => details.querySelector(":scope > summary");
|
|
396
|
-
// The sidebar row a control sits in — `a.astrobook-sidebar-link` for a story leaf,
|
|
397
|
-
// `summary.astrobook-sidebar-link` for a module row. Both carry the class; the mark boxes live
|
|
398
|
-
// inside the row's pill box, so they cannot use parentElement.
|
|
399
|
-
const rowOf = (el) => el.closest(".astrobook-sidebar-link") || el.parentElement;
|
|
400
|
-
|
|
401
|
-
const pill = (text, extraClass, title) => {
|
|
402
|
-
const span = document.createElement("span");
|
|
403
|
-
span.className = extraClass ? `lab-pill ${extraClass}` : "lab-pill";
|
|
404
|
-
span.textContent = text;
|
|
405
|
-
if (title) span.title = title;
|
|
406
|
-
return span;
|
|
407
|
-
};
|
|
408
|
-
const pillsOf = (row) => {
|
|
409
|
-
let box = row.querySelector(":scope > .lab-pills");
|
|
410
|
-
if (!box) {
|
|
411
|
-
box = document.createElement("span");
|
|
412
|
-
box.className = "lab-pills";
|
|
413
|
-
row.appendChild(box);
|
|
414
|
-
}
|
|
415
|
-
return box;
|
|
416
|
-
};
|
|
417
|
-
const usedByPill = (usedBy) =>
|
|
418
|
-
pill(`used by ${usedBy.length}`, "", `used by\n${usedBy.join("\n")}`);
|
|
419
|
-
const liveCountPill = (count) => pill(String(count), "lab-pill--live lab-pill--count", `${count} live`);
|
|
420
|
-
|
|
421
|
-
// The live pill's text. A single-page site reads exactly as it always did — `live · slot 3 of
|
|
422
|
-
// 12` — and only a page that is not the home page names itself: `live · /about, slot 3`. The
|
|
423
|
-
// same three lines are livePillText() in shell/lab-index.ts, for the cards; keep them in step.
|
|
424
|
-
const livePillText = (item) =>
|
|
425
|
-
item.livePage && item.livePage !== "/"
|
|
426
|
-
? `live · ${item.livePage}, slot ${item.live.slot}`
|
|
427
|
-
: `live · slot ${item.live.slot} of ${item.live.of}`;
|
|
428
|
-
const liveTitle = (item) =>
|
|
429
|
-
(item.liveOn || []).length > 1
|
|
430
|
-
? item.liveOn.map((mount) => `${mount.page} · slot ${mount.slot} of ${mount.of}`).join("\n")
|
|
431
|
-
: null;
|
|
432
|
-
|
|
433
|
-
// ---- search widening -----------------------------------------------------------------------
|
|
434
|
-
const extraTerms = (item) => {
|
|
435
|
-
const terms = [item.tier, item.section, item.version, item.moduleName, ...(item.tags || [])];
|
|
436
|
-
if (item.live) terms.push("live", item.livePage);
|
|
437
|
-
// "unresponsive", not "not responsive": Astrobook's filter is a substring match per term, so
|
|
438
|
-
// a row that said "not responsive" would still be a hit for "responsive".
|
|
439
|
-
if (item.responsive) terms.push(item.responsive.done ? "responsive" : "unresponsive");
|
|
440
|
-
return terms.filter(Boolean).map((term) => String(term).toLowerCase());
|
|
441
|
-
};
|
|
442
|
-
const appendTerms = (el, terms) => {
|
|
443
|
-
let text = el.dataset.searchText || "";
|
|
444
|
-
for (const term of terms) if (!text.includes(term)) text += ` ${term}`;
|
|
445
|
-
el.dataset.searchText = text;
|
|
446
|
-
};
|
|
447
|
-
const widenSearch = (link, terms) => {
|
|
448
|
-
appendTerms(link, terms);
|
|
449
|
-
for (let el = link.parentElement; el && el.id !== TREE_ID; el = el.parentElement) {
|
|
450
|
-
if (el.tagName === "DETAILS" && el.hasAttribute("data-search-text")) appendTerms(el, terms);
|
|
451
|
-
}
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
// ---- pills ----------------------------------------------------------------------------------
|
|
455
|
-
const decorate = () => {
|
|
456
|
-
const root = tree();
|
|
457
|
-
if (!root || !readIndex()) return;
|
|
458
|
-
|
|
459
|
-
for (const link of root.querySelectorAll(
|
|
460
|
-
"a[data-astrobook-story-link][data-story-id]:not([data-lab-decorated])",
|
|
461
|
-
)) {
|
|
462
|
-
link.dataset.labDecorated = "";
|
|
463
|
-
const item = index.byStory.get(link.dataset.storyId);
|
|
464
|
-
if (!item) continue;
|
|
465
|
-
const box = pillsOf(link);
|
|
466
|
-
if (item.live) {
|
|
467
|
-
box.appendChild(pill(livePillText(item), "lab-pill--live", liveTitle(item)));
|
|
468
|
-
}
|
|
469
|
-
if (isHoisted(link) && item.usedBy.length) box.appendChild(usedByPill(item.usedBy));
|
|
470
|
-
widenSearch(link, extraTerms(item));
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
for (const details of root.querySelectorAll('details[data-id^="module:"]:not([data-lab-decorated])')) {
|
|
474
|
-
details.dataset.labDecorated = "";
|
|
475
|
-
const summary = summaryOf(details);
|
|
476
|
-
const items = index.byModule.get(moduleIdOf(details)) || [];
|
|
477
|
-
if (!summary || !items.length) continue;
|
|
478
|
-
const box = pillsOf(summary);
|
|
479
|
-
const liveCount = items.filter((item) => item.live).length;
|
|
480
|
-
if (liveCount) box.appendChild(liveCountPill(liveCount));
|
|
481
|
-
if (items[0].usedBy.length) box.appendChild(usedByPill(items[0].usedBy));
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
for (const details of root.querySelectorAll('details[data-id^="dir:"]:not([data-lab-decorated])')) {
|
|
485
|
-
details.dataset.labDecorated = "";
|
|
486
|
-
const summary = summaryOf(details);
|
|
487
|
-
if (!summary) continue;
|
|
488
|
-
const count = liveUnder(folderPath(details));
|
|
489
|
-
if (count) pillsOf(summary).appendChild(liveCountPill(count));
|
|
490
|
-
}
|
|
491
|
-
};
|
|
492
|
-
|
|
493
|
-
// ---- ⌘K search -------------------------------------------------------------------------------
|
|
494
|
-
document.addEventListener("keydown", (event) => {
|
|
495
|
-
if (!document.getElementById("astrobook-sidebar")) return;
|
|
496
|
-
const input = document.getElementById("astrobook-search-input");
|
|
497
|
-
// ⌘K stays live inside the lab's OWN search box, where it means "select what is there and
|
|
498
|
-
// retype it". Everywhere else that a character is being typed, the keystroke is not ours.
|
|
499
|
-
// › THE KEYBOARD GUARD at the top of this file.
|
|
500
|
-
const elsewhere = event.target !== input && window.__labIgnoreKey(event);
|
|
501
|
-
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && !elsewhere) {
|
|
502
|
-
event.preventDefault();
|
|
503
|
-
const panel = document.getElementById("astrobook-search-panel");
|
|
504
|
-
const toggle = document.getElementById("astrobook-search-toggle");
|
|
505
|
-
if (panel && toggle && !panel.hasAttribute("data-open")) {
|
|
506
|
-
toggle.click(); // Astrobook opens the panel and focuses the input itself
|
|
507
|
-
} else if (input) {
|
|
508
|
-
input.focus();
|
|
509
|
-
input.select();
|
|
510
|
-
}
|
|
511
|
-
} else if (event.key === "Escape" && input && document.activeElement === input) {
|
|
512
|
-
input.blur();
|
|
513
|
-
}
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
// ---- cull switch -----------------------------------------------------------------------------
|
|
517
|
-
let cull = { available: false, marked: new Set() };
|
|
518
|
-
let probe = null;
|
|
519
|
-
const probeCull = () => {
|
|
520
|
-
probe ??= fetch(CULL_ENDPOINT, { headers: { accept: "application/json" } })
|
|
521
|
-
.then(async (response) => {
|
|
522
|
-
if (!response.ok) throw new Error(String(response.status));
|
|
523
|
-
const body = await response.json();
|
|
524
|
-
cull = { available: true, marked: new Set(Array.isArray(body.marked) ? body.marked : []) };
|
|
525
|
-
})
|
|
526
|
-
.catch(() => {
|
|
527
|
-
cull = { available: false, marked: new Set() };
|
|
528
|
-
})
|
|
529
|
-
.then(() => {
|
|
530
|
-
setupCullButton();
|
|
531
|
-
renderCull();
|
|
532
|
-
});
|
|
533
|
-
return probe;
|
|
534
|
-
};
|
|
535
|
-
|
|
536
|
-
const cullOn = () => cull.available && sessionStorage.getItem(CULL_KEY) === "1";
|
|
537
|
-
const applyCullState = () => {
|
|
538
|
-
if (cullOn()) document.documentElement.dataset.labCull = "";
|
|
539
|
-
else delete document.documentElement.dataset.labCull;
|
|
540
|
-
const button = document.getElementById("lab-cull-toggle");
|
|
541
|
-
if (button) {
|
|
542
|
-
button.setAttribute("aria-pressed", String(cullOn()));
|
|
543
|
-
button.toggleAttribute("data-active", cullOn());
|
|
544
|
-
}
|
|
545
|
-
};
|
|
546
|
-
const toggleCull = () => {
|
|
547
|
-
if (sessionStorage.getItem(CULL_KEY) === "1") sessionStorage.removeItem(CULL_KEY);
|
|
548
|
-
else sessionStorage.setItem(CULL_KEY, "1");
|
|
549
|
-
applyCullState();
|
|
550
|
-
};
|
|
551
|
-
|
|
552
|
-
const TRASH =
|
|
553
|
-
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="m19 6-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>';
|
|
554
|
-
const setupCullButton = () => {
|
|
555
|
-
const sidebar = document.getElementById("astrobook-sidebar");
|
|
556
|
-
if (!sidebar || !cull.available) return;
|
|
557
|
-
const header = sidebar.querySelector("nav > div");
|
|
558
|
-
if (!header || document.getElementById("lab-cull-toggle")) return;
|
|
559
|
-
const button = document.createElement("button");
|
|
560
|
-
button.id = "lab-cull-toggle";
|
|
561
|
-
button.type = "button";
|
|
562
|
-
button.className = "astrobook-sidebar-button";
|
|
563
|
-
button.title = "Mark for deletion";
|
|
564
|
-
button.setAttribute("aria-label", "Mark for deletion");
|
|
565
|
-
button.setAttribute("aria-pressed", "false");
|
|
566
|
-
button.innerHTML = TRASH;
|
|
567
|
-
button.addEventListener("click", toggleCull);
|
|
568
|
-
const collapse = document.getElementById("lab-sidebar-collapse");
|
|
569
|
-
if (collapse && collapse.parentElement === header) header.insertBefore(button, collapse);
|
|
570
|
-
else header.appendChild(button);
|
|
571
|
-
applyCullState();
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
// Rows that can be marked: module rows and hoisted single-story leaves (module = the story's
|
|
575
|
-
// own moduleFile, from the index) — in the EXPLORATIONS tier only (ruled 2026-09-05: deletion
|
|
576
|
-
// is restricted to the explorations tier; section versions are the version history, components
|
|
577
|
-
// are the shared chrome, neither is ever culled). An exploration a live section imports keeps a
|
|
578
|
-
// disabled box: the cull script would refuse it anyway, so the UI says so up front.
|
|
579
|
-
const CULL_TIER = LAB_TIERS.cull;
|
|
580
|
-
const blockReason = (items) => {
|
|
581
|
-
if (items.some((item) => item.live)) return "live on the site — cannot be marked";
|
|
582
|
-
const usedBy = items[0].usedBy || [];
|
|
583
|
-
if (usedBy.length) return `used by ${usedBy.length} live section${usedBy.length === 1 ? "" : "s"} — cannot be marked`;
|
|
584
|
-
return null;
|
|
585
|
-
};
|
|
586
|
-
// Every row a mark can hang on, in one tier: module rows and hoisted single-story leaves.
|
|
587
|
-
// Shared by the cull boxes and the responsive boxes, so the two never disagree about what a
|
|
588
|
-
// markable row is — the same reason their path rules live together in shell/marks.mjs.
|
|
589
|
-
const tierRows = (tier) => {
|
|
590
|
-
const root = tree();
|
|
591
|
-
const rows = [];
|
|
592
|
-
if (!root) return rows;
|
|
593
|
-
for (const details of root.querySelectorAll('details[data-id^="module:"]')) {
|
|
594
|
-
const summary = summaryOf(details);
|
|
595
|
-
const items = index.byModule.get(moduleIdOf(details)) || [];
|
|
596
|
-
if (summary && items[0] && items[0].tier === tier) rows.push({ row: summary, items });
|
|
597
|
-
}
|
|
598
|
-
for (const link of root.querySelectorAll("a[data-astrobook-story-link][data-story-id]")) {
|
|
599
|
-
if (!isHoisted(link)) continue;
|
|
600
|
-
const item = index.byStory.get(link.dataset.storyId);
|
|
601
|
-
if (item && item.tier === tier) rows.push({ row: link, items: [item] });
|
|
602
|
-
}
|
|
603
|
-
return rows;
|
|
604
|
-
};
|
|
605
|
-
|
|
606
|
-
const cullRows = () =>
|
|
607
|
-
tierRows(CULL_TIER).map(({ row, items }) => ({
|
|
608
|
-
row,
|
|
609
|
-
item: items[0],
|
|
610
|
-
blocked: blockReason(items),
|
|
611
|
-
}));
|
|
612
|
-
|
|
613
|
-
const renderCull = () => {
|
|
614
|
-
if (!tree() || !readIndex()) return;
|
|
615
|
-
applyCullState();
|
|
616
|
-
for (const { row, item, blocked } of cullRows()) {
|
|
617
|
-
const marked = cull.marked.has(item.moduleFile);
|
|
618
|
-
row.classList.toggle("lab-row--marked", marked);
|
|
619
|
-
const box = pillsOf(row);
|
|
620
|
-
const markedPill = box.querySelector(".lab-pill--marked");
|
|
621
|
-
if (marked && !markedPill) box.appendChild(pill("marked", "lab-pill--marked", "marked for deletion"));
|
|
622
|
-
if (!marked && markedPill) markedPill.remove();
|
|
623
|
-
|
|
624
|
-
// The box lives in the pill box, not the row: the row wraps as one group, so a narrow
|
|
625
|
-
// sidebar puts pills AND boxes on a second line together instead of on three lines.
|
|
626
|
-
let check = box.querySelector(":scope > .lab-cull-box");
|
|
627
|
-
if (!cull.available) {
|
|
628
|
-
if (check) check.remove();
|
|
629
|
-
continue;
|
|
630
|
-
}
|
|
631
|
-
if (!check) {
|
|
632
|
-
check = document.createElement("input");
|
|
633
|
-
check.type = "checkbox";
|
|
634
|
-
check.className = "lab-cull-box";
|
|
635
|
-
check.addEventListener("click", onCullClick);
|
|
636
|
-
box.appendChild(check);
|
|
637
|
-
}
|
|
638
|
-
check.checked = marked;
|
|
639
|
-
check.disabled = Boolean(blocked);
|
|
640
|
-
check.dataset.moduleFile = item.moduleFile;
|
|
641
|
-
check.title = blocked ? blocked : marked ? "Unmark for deletion" : "Mark for deletion";
|
|
642
|
-
check.setAttribute("aria-label", `Mark ${item.moduleName} for deletion`);
|
|
643
|
-
}
|
|
644
|
-
};
|
|
645
|
-
|
|
646
|
-
const showRowError = (row, message, offenders) => {
|
|
647
|
-
const previous = row.nextElementSibling;
|
|
648
|
-
if (previous && previous.classList.contains("lab-row-error")) previous.remove();
|
|
649
|
-
const note = document.createElement("div");
|
|
650
|
-
note.className = "lab-row-error";
|
|
651
|
-
note.setAttribute("role", "alert");
|
|
652
|
-
const names = Array.isArray(offenders) ? offenders.map((path) => String(path).split("/").pop()) : [];
|
|
653
|
-
note.textContent = names.length ? `${message}: ${names.join(", ")}` : message;
|
|
654
|
-
row.insertAdjacentElement("afterend", note);
|
|
655
|
-
setTimeout(() => note.remove(), 4000);
|
|
656
|
-
};
|
|
657
|
-
|
|
658
|
-
const onCullClick = async (event) => {
|
|
659
|
-
// The box shows the server's state, never the click's — and preventDefault keeps the host
|
|
660
|
-
// <a> from navigating and the <summary> from toggling.
|
|
661
|
-
event.preventDefault();
|
|
662
|
-
event.stopPropagation();
|
|
663
|
-
const check = event.currentTarget;
|
|
664
|
-
if (check.disabled) return;
|
|
665
|
-
const row = rowOf(check);
|
|
666
|
-
const next = new Set(cull.marked);
|
|
667
|
-
if (next.has(check.dataset.moduleFile)) next.delete(check.dataset.moduleFile);
|
|
668
|
-
else next.add(check.dataset.moduleFile);
|
|
669
|
-
try {
|
|
670
|
-
const response = await fetch(CULL_ENDPOINT, {
|
|
671
|
-
method: "PUT",
|
|
672
|
-
headers: { "content-type": "application/json", accept: "application/json" },
|
|
673
|
-
body: JSON.stringify({ marked: [...next] }),
|
|
674
|
-
});
|
|
675
|
-
const body = await response.json().catch(() => ({}));
|
|
676
|
-
if (response.ok) {
|
|
677
|
-
cull.marked = new Set(Array.isArray(body.marked) ? body.marked : [...next]);
|
|
678
|
-
} else {
|
|
679
|
-
showRowError(row, body.error || `HTTP ${response.status}`, body.offenders);
|
|
680
|
-
}
|
|
681
|
-
} catch (error) {
|
|
682
|
-
showRowError(row, (error && error.message) || "request failed");
|
|
683
|
-
}
|
|
684
|
-
renderCull();
|
|
685
|
-
};
|
|
686
|
-
|
|
687
|
-
// ---- responsive marks ------------------------------------------------------------------------
|
|
688
|
-
// SECTION VERSIONS ONLY (decided 2026-09-06: a checkbox, not a pill, for whatever section has
|
|
689
|
-
// been made responsive). Components and explorations never get one.
|
|
690
|
-
//
|
|
691
|
-
// Two boxes, because responsive work is approval-gated (same day): `approved` says the version
|
|
692
|
-
// has been approved for the work — which is NOT the same as the version being final, and the
|
|
693
|
-
// approval can be withdrawn later — and `done` says the work is finished. Both are hand-set,
|
|
694
|
-
// because neither is readable from the code; the pill shows `done`.
|
|
695
|
-
//
|
|
696
|
-
// No switch guards them, unlike the cull boxes: ticking one is routine, marking a file for
|
|
697
|
-
// deletion is not. The pill comes from the build-time index until the API answers, so on
|
|
698
|
-
// staging (no dev server, no API) the marks still show, just without the boxes.
|
|
699
|
-
const RESPONSIVE_TIER = LAB_TIERS.responsive;
|
|
700
|
-
let responsive = { available: false, done: new Set(), approved: new Set() };
|
|
701
|
-
let responsiveProbe = null;
|
|
702
|
-
const probeResponsive = () => {
|
|
703
|
-
responsiveProbe ??= fetch(RESPONSIVE_ENDPOINT, { headers: { accept: "application/json" } })
|
|
704
|
-
.then(async (response) => {
|
|
705
|
-
if (!response.ok) throw new Error(String(response.status));
|
|
706
|
-
const body = await response.json();
|
|
707
|
-
responsive = {
|
|
708
|
-
available: true,
|
|
709
|
-
done: new Set(Array.isArray(body.done) ? body.done : []),
|
|
710
|
-
approved: new Set(Array.isArray(body.approved) ? body.approved : []),
|
|
711
|
-
};
|
|
712
|
-
})
|
|
713
|
-
.catch(() => {
|
|
714
|
-
responsive = { available: false, done: new Set(), approved: new Set() };
|
|
715
|
-
})
|
|
716
|
-
.then(() => renderResponsive());
|
|
717
|
-
return responsiveProbe;
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
// In the row's pill box, beside the pills, for the wrapping reason given on the cull box.
|
|
721
|
-
const responsiveBox = (pills, kind, file, checked, title) => {
|
|
722
|
-
let box = pills.querySelector(`:scope > .lab-resp-box[data-kind="${kind}"]`);
|
|
723
|
-
if (!box) {
|
|
724
|
-
box = document.createElement("input");
|
|
725
|
-
box.type = "checkbox";
|
|
726
|
-
box.className = `lab-resp-box lab-resp-box--${kind}`;
|
|
727
|
-
box.dataset.kind = kind;
|
|
728
|
-
box.addEventListener("click", onResponsiveClick);
|
|
729
|
-
pills.appendChild(box);
|
|
730
|
-
}
|
|
731
|
-
box.checked = checked;
|
|
732
|
-
box.dataset.moduleFile = file;
|
|
733
|
-
box.title = title;
|
|
734
|
-
box.setAttribute("aria-label", title);
|
|
735
|
-
};
|
|
736
|
-
|
|
737
|
-
const renderResponsive = () => {
|
|
738
|
-
if (!tree() || !readIndex()) return;
|
|
739
|
-
for (const { row, items } of tierRows(RESPONSIVE_TIER)) {
|
|
740
|
-
const item = items[0];
|
|
741
|
-
const file = item.moduleFile;
|
|
742
|
-
const mark = item.responsive || { done: false, approved: false };
|
|
743
|
-
const done = responsive.available ? responsive.done.has(file) : mark.done;
|
|
744
|
-
const approved = responsive.available ? responsive.approved.has(file) : mark.approved;
|
|
745
|
-
|
|
746
|
-
const box = pillsOf(row);
|
|
747
|
-
// "not responsive" is only news on a version that is actually meant to become responsive
|
|
748
|
-
// — the one a page mounts. V1s are never made responsive (ruled 2026-09-06), and an
|
|
749
|
-
// unmounted V2 is not owed the work yet, so neither carries the nag; both still show
|
|
750
|
-
// "responsive" once ticked, and both keep their boxes so a mark is always possible.
|
|
751
|
-
const owed = items.some((i) => i.live);
|
|
752
|
-
let badge = box.querySelector(".lab-pill--resp");
|
|
753
|
-
if (!done && !owed) {
|
|
754
|
-
if (badge) badge.remove();
|
|
755
|
-
} else {
|
|
756
|
-
if (!badge) {
|
|
757
|
-
badge = pill("", "lab-pill--resp");
|
|
758
|
-
box.appendChild(badge);
|
|
759
|
-
}
|
|
760
|
-
// The row's search text is written once, at decorate() — a mark ticked in this session
|
|
761
|
-
// shows up in the search box after a reload, not before.
|
|
762
|
-
badge.textContent = done ? "responsive" : "not responsive";
|
|
763
|
-
badge.classList.toggle("lab-pill--resp-done", done);
|
|
764
|
-
badge.title = approved
|
|
765
|
-
? "approved for responsive work"
|
|
766
|
-
: "not approved for responsive work yet";
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
if (!responsive.available) {
|
|
770
|
-
for (const stale of box.querySelectorAll(":scope > .lab-resp-box")) stale.remove();
|
|
771
|
-
continue;
|
|
772
|
-
}
|
|
773
|
-
responsiveBox(
|
|
774
|
-
box,
|
|
775
|
-
"approved",
|
|
776
|
-
file,
|
|
777
|
-
approved,
|
|
778
|
-
approved ? "Approved for responsive work" : "Approve for responsive work",
|
|
779
|
-
);
|
|
780
|
-
responsiveBox(
|
|
781
|
-
box,
|
|
782
|
-
"done",
|
|
783
|
-
file,
|
|
784
|
-
done,
|
|
785
|
-
done ? "Made responsive — untick to clear" : "Mark as made responsive",
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
};
|
|
789
|
-
|
|
790
|
-
const onResponsiveClick = async (event) => {
|
|
791
|
-
// Same contract as the cull box: the box shows the server's state, never the click's, and
|
|
792
|
-
// preventDefault keeps the host <a> from navigating and the <summary> from toggling.
|
|
793
|
-
event.preventDefault();
|
|
794
|
-
event.stopPropagation();
|
|
795
|
-
const box = event.currentTarget;
|
|
796
|
-
const row = rowOf(box);
|
|
797
|
-
const file = box.dataset.moduleFile;
|
|
798
|
-
const done = new Set(responsive.done);
|
|
799
|
-
const approved = new Set(responsive.approved);
|
|
800
|
-
const set = box.dataset.kind === "done" ? done : approved;
|
|
801
|
-
if (set.has(file)) set.delete(file);
|
|
802
|
-
else set.add(file);
|
|
803
|
-
try {
|
|
804
|
-
const response = await fetch(RESPONSIVE_ENDPOINT, {
|
|
805
|
-
method: "PUT",
|
|
806
|
-
headers: { "content-type": "application/json", accept: "application/json" },
|
|
807
|
-
body: JSON.stringify({ done: [...done], approved: [...approved] }),
|
|
808
|
-
});
|
|
809
|
-
const body = await response.json().catch(() => ({}));
|
|
810
|
-
if (response.ok) {
|
|
811
|
-
responsive = {
|
|
812
|
-
available: true,
|
|
813
|
-
done: new Set(Array.isArray(body.done) ? body.done : [...done]),
|
|
814
|
-
approved: new Set(Array.isArray(body.approved) ? body.approved : [...approved]),
|
|
815
|
-
};
|
|
816
|
-
} else {
|
|
817
|
-
showRowError(row, body.error || `HTTP ${response.status}`, body.offenders);
|
|
818
|
-
}
|
|
819
|
-
} catch (error) {
|
|
820
|
-
showRowError(row, (error && error.message) || "request failed");
|
|
821
|
-
}
|
|
822
|
-
renderResponsive();
|
|
823
|
-
};
|
|
824
|
-
|
|
825
|
-
// ---- header links for the active story ---------------------------------------------------------
|
|
826
|
-
// Three, rebuilt on every swap because the header and <html data-active-story> are both
|
|
827
|
-
// replaced: the viewport configurator (src/lab/shell/Viewport.astro — drag the frame's width
|
|
828
|
-
// and height, zoom, presets, the viewport in the URL), the same story on the STAGING build (the
|
|
829
|
-
// real minified output, which the dev server is not — the blur incident), and the component in
|
|
830
|
-
// VS Code. No active story (the lab home) → no buttons. The viewport link carries
|
|
831
|
-
// `data-astro-reload` because the configurator is outside Astrobook's ClientRouter: a full
|
|
832
|
-
// load, never a view transition. The VS Code link exists in dev only — `editorUrl` is null in
|
|
833
|
-
// any build, so on staging it simply never appears.
|
|
834
|
-
const VIEWPORT_ICON =
|
|
835
|
-
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="12" height="20" x="6" y="2" rx="1"/><rect width="20" height="12" x="2" y="6" rx="1"/></svg>';
|
|
836
|
-
const STAGING_ICON =
|
|
837
|
-
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>';
|
|
838
|
-
const CODE_ICON =
|
|
839
|
-
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>';
|
|
840
|
-
|
|
841
|
-
const headerOf = () => {
|
|
842
|
-
const sidebar = document.getElementById("astrobook-sidebar");
|
|
843
|
-
return sidebar ? sidebar.querySelector("nav > div") : null;
|
|
844
|
-
};
|
|
845
|
-
const headerLink = (id, title, icon, href, options) => {
|
|
846
|
-
const header = headerOf();
|
|
847
|
-
if (!header) return;
|
|
848
|
-
let link = document.getElementById(id);
|
|
849
|
-
if (!href) {
|
|
850
|
-
if (link) link.remove();
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
if (!link) {
|
|
854
|
-
link = document.createElement("a");
|
|
855
|
-
link.id = id;
|
|
856
|
-
link.className = "astrobook-sidebar-button";
|
|
857
|
-
link.innerHTML = icon;
|
|
858
|
-
if (options && options.reload) link.setAttribute("data-astro-reload", "");
|
|
859
|
-
if (options && options.blank) {
|
|
860
|
-
link.target = "_blank";
|
|
861
|
-
link.rel = "noopener";
|
|
862
|
-
}
|
|
863
|
-
// Left of whichever of the existing header controls is furthest left, so the row's order
|
|
864
|
-
// is stable however many of them exist on this page.
|
|
865
|
-
const before = ["lab-viewport-link", "lab-cull-toggle", "lab-sidebar-collapse"]
|
|
866
|
-
.map((existing) => document.getElementById(existing))
|
|
867
|
-
.find((el) => el && el.parentElement === header);
|
|
868
|
-
if (before) header.insertBefore(link, before);
|
|
869
|
-
else header.appendChild(link);
|
|
870
|
-
}
|
|
871
|
-
link.title = title;
|
|
872
|
-
link.setAttribute("aria-label", title);
|
|
873
|
-
link.href = href;
|
|
874
|
-
};
|
|
875
|
-
|
|
876
|
-
const setupStoryButtons = () => {
|
|
877
|
-
if (!readIndex() || !headerOf()) return;
|
|
878
|
-
const item = index.byStory.get(document.documentElement.dataset.activeStory || "");
|
|
879
|
-
headerLink(
|
|
880
|
-
"lab-viewport-link",
|
|
881
|
-
"Open in the viewport configurator",
|
|
882
|
-
VIEWPORT_ICON,
|
|
883
|
-
item ? `${index.viewportBase}/${item.storyId}` : null,
|
|
884
|
-
{ reload: true },
|
|
885
|
-
);
|
|
886
|
-
headerLink("lab-staging-link", "View this story on staging", STAGING_ICON, item ? item.stagingUrl : null, {
|
|
887
|
-
blank: true,
|
|
888
|
-
});
|
|
889
|
-
headerLink("lab-code-link", "Open the component in VS Code", CODE_ICON, item ? item.editorUrl : null);
|
|
890
|
-
};
|
|
891
|
-
|
|
892
|
-
// ---- j / k -------------------------------------------------------------------------------------
|
|
893
|
-
// Next / previous story in sidebar order, and navigate there. Only rows the sidebar is actually
|
|
894
|
-
// showing count, so j/k walk the search results while a filter is on and never jump into a
|
|
895
|
-
// collapsed folder. Guarded three ways so nothing collides: a modifier held is somebody else's
|
|
896
|
-
// shortcut (⌘B, ⌘K), a focused input or contenteditable means the key is being typed (the
|
|
897
|
-
// search box), and no #astrobook-sidebar means this is a bare story or the viewport
|
|
898
|
-
// configurator — which owns [ ] r f 0 on its own page.
|
|
899
|
-
const storyLinks = () => {
|
|
900
|
-
const root = tree();
|
|
901
|
-
if (!root) return [];
|
|
902
|
-
const links = [...root.querySelectorAll("a[data-astrobook-story-link][data-story-id]")];
|
|
903
|
-
const visible = links.filter((link) => link.offsetParent !== null);
|
|
904
|
-
return visible.length ? visible : links;
|
|
905
|
-
};
|
|
906
|
-
const stepStory = (delta) => {
|
|
907
|
-
const links = storyLinks();
|
|
908
|
-
if (!links.length) return;
|
|
909
|
-
const active = document.documentElement.dataset.activeStory || "";
|
|
910
|
-
const at = links.findIndex((link) => link.dataset.storyId === active);
|
|
911
|
-
const next = at === -1 ? (delta > 0 ? 0 : links.length - 1) : at + delta;
|
|
912
|
-
if (next < 0 || next >= links.length || next === at) return;
|
|
913
|
-
links[next].scrollIntoView({ block: "nearest" });
|
|
914
|
-
links[next].click(); // Astrobook's ClientRouter takes it from here
|
|
915
|
-
};
|
|
916
|
-
document.addEventListener("keydown", (event) => {
|
|
917
|
-
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.isComposing) return;
|
|
918
|
-
if (!document.getElementById("astrobook-sidebar")) return;
|
|
919
|
-
// Bare letters, so this is the handler a typed character reaches first. The local
|
|
920
|
-
// activeElement test that used to stand here could not see through a shadow boundary.
|
|
921
|
-
// › THE KEYBOARD GUARD at the top of this file.
|
|
922
|
-
if (window.__labIgnoreKey(event)) return;
|
|
923
|
-
const key = event.key.toLowerCase();
|
|
924
|
-
if (key !== "j" && key !== "k") return;
|
|
925
|
-
event.preventDefault();
|
|
926
|
-
stepStory(key === "j" ? 1 : -1);
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
// ---- lifecycle ------------------------------------------------------------------------------
|
|
930
|
-
const setup = () => {
|
|
931
|
-
applyCullState(); // <html> attributes are replaced on every swap
|
|
932
|
-
if (!document.getElementById("astrobook-sidebar")) return; // bare story page, or not the lab
|
|
933
|
-
decorate();
|
|
934
|
-
renderCull();
|
|
935
|
-
renderResponsive();
|
|
936
|
-
setupStoryButtons(); // header and data-active-story are both replaced on every swap
|
|
937
|
-
setupCullButton(); // the header is re-rendered on every swap; the probe result is not
|
|
938
|
-
probeCull(); // once per hard load
|
|
939
|
-
probeResponsive();
|
|
940
|
-
};
|
|
941
|
-
|
|
942
|
-
document.addEventListener("astro:after-swap", setup);
|
|
943
|
-
document.addEventListener("astro:page-load", setup);
|
|
944
|
-
if (document.readyState === "loading") {
|
|
945
|
-
document.addEventListener("DOMContentLoaded", setup, { once: true });
|
|
946
|
-
} else {
|
|
947
|
-
setup();
|
|
948
|
-
}
|
|
949
|
-
})();
|
|
950
|
-
</script>
|
|
951
|
-
<style is:global>
|
|
952
|
-
/* Lab shell — pills, responsive and cull marks, drawn on top of the chrome the package ships
|
|
953
|
-
(./ui/lab.css). Every token here is a `--lab-*` one, defined in that file and themed there
|
|
954
|
-
for light and dark; nothing reads a name the consumer could also define.
|
|
955
|
-
|
|
956
|
-
REWRITTEN 2026-09-22 — the lab is not styled in the styles of the website being built.
|
|
957
|
-
Until then these rules read the consumer's own token names — `--color-accent`, `--font-mono`,
|
|
958
|
-
`--radius-pill` — with a `@layer orbytes-lab-fallback` block below supplying neutral greys
|
|
959
|
-
"so a host that defines --color-hairline on a plain :root wins here every time". That layer
|
|
960
|
-
WAS the leak, written as a policy; it is gone, and the lab wins now.
|
|
961
|
-
|
|
962
|
-
Anything that has to read on both the plain row and the inverted active row still rides on
|
|
963
|
-
currentColor. The one exception is the live pill's accent, which needs the other theme's
|
|
964
|
-
accent when it lands on the inverted row — hence --lab-color-accent-inverse. */
|
|
965
|
-
|
|
966
|
-
/* A live section version carries a live pill, a responsive pill and two boxes — wider than a
|
|
967
|
-
narrow sidebar. Letting the ROW wrap drops that group onto a second line, so the story's name
|
|
968
|
-
is never truncated and nothing is cut off at the sidebar's edge. Everything the marks add
|
|
969
|
-
lives inside .lab-pills, so it wraps as one group: two lines at the default 300px width for
|
|
970
|
-
rows that carry both pills (three for the twelve live ones), and two for everything from about
|
|
971
|
-
380px, which is a sidebar drag away. */
|
|
972
|
-
#astrobook-sidebar-tree .astrobook-sidebar-link {
|
|
973
|
-
flex-wrap: wrap;
|
|
974
|
-
row-gap: 0.25em;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
#astrobook-sidebar .lab-pills {
|
|
978
|
-
display: inline-flex;
|
|
979
|
-
flex: 0 1 auto;
|
|
980
|
-
flex-wrap: wrap;
|
|
981
|
-
align-items: center;
|
|
982
|
-
justify-content: flex-end;
|
|
983
|
-
gap: 0.22em;
|
|
984
|
-
margin-left: auto;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
#astrobook-sidebar .lab-pill {
|
|
988
|
-
font-family: var(--lab-font-mono);
|
|
989
|
-
font-size: 0.65em;
|
|
990
|
-
font-weight: 500;
|
|
991
|
-
line-height: 1;
|
|
992
|
-
letter-spacing: 0.02em;
|
|
993
|
-
white-space: nowrap;
|
|
994
|
-
padding: 0.3em 0.45em;
|
|
995
|
-
border-radius: var(--lab-radius-pill);
|
|
996
|
-
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
|
997
|
-
background-color: color-mix(in srgb, currentColor 8%, transparent);
|
|
998
|
-
color: inherit;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
#astrobook-sidebar .lab-pill--live {
|
|
1002
|
-
color: var(--lab-color-accent);
|
|
1003
|
-
border-color: color-mix(in srgb, currentColor 50%, transparent);
|
|
1004
|
-
background-color: color-mix(in srgb, currentColor 12%, transparent);
|
|
1005
|
-
}
|
|
1006
|
-
/* The active row inverts its ground, so the accent has to invert with it. */
|
|
1007
|
-
#astrobook-sidebar .astrobook-sidebar-story-link-active .lab-pill--live {
|
|
1008
|
-
color: var(--lab-color-accent-inverse);
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
#astrobook-sidebar .lab-pill--count {
|
|
1012
|
-
display: inline-flex;
|
|
1013
|
-
align-items: center;
|
|
1014
|
-
gap: 0.45em;
|
|
1015
|
-
}
|
|
1016
|
-
#astrobook-sidebar .lab-pill--count::before {
|
|
1017
|
-
content: "";
|
|
1018
|
-
width: 0.55em;
|
|
1019
|
-
height: 0.55em;
|
|
1020
|
-
border-radius: 50%;
|
|
1021
|
-
background-color: currentColor;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
#astrobook-sidebar .lab-pill--marked {
|
|
1025
|
-
border-style: dashed;
|
|
1026
|
-
border-color: color-mix(in srgb, currentColor 60%, transparent);
|
|
1027
|
-
text-transform: uppercase;
|
|
1028
|
-
letter-spacing: 0.08em;
|
|
1029
|
-
}
|
|
1030
|
-
#astrobook-sidebar .lab-row--marked > span:not([class]) {
|
|
1031
|
-
text-decoration: line-through;
|
|
1032
|
-
opacity: 0.7;
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
/* The responsive mark: dashed and faint until the version has been made responsive, solid after
|
|
1036
|
-
— the same two-state reading as the cards' pill. */
|
|
1037
|
-
#astrobook-sidebar .lab-pill--resp {
|
|
1038
|
-
border-style: dashed;
|
|
1039
|
-
opacity: 0.6;
|
|
1040
|
-
}
|
|
1041
|
-
#astrobook-sidebar .lab-pill--resp-done {
|
|
1042
|
-
border-style: solid;
|
|
1043
|
-
border-color: color-mix(in srgb, currentColor 55%, transparent);
|
|
1044
|
-
opacity: 1;
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/* Responsive boxes need no switch — unlike the cull boxes they are always on. Two per row:
|
|
1048
|
-
approved for the work (hollow), and the work done (accented). */
|
|
1049
|
-
#astrobook-sidebar .lab-resp-box {
|
|
1050
|
-
flex: none;
|
|
1051
|
-
width: 0.8em;
|
|
1052
|
-
height: 0.8em;
|
|
1053
|
-
margin: 0 0 0 0.1em;
|
|
1054
|
-
cursor: pointer;
|
|
1055
|
-
}
|
|
1056
|
-
#astrobook-sidebar .lab-resp-box--approved {
|
|
1057
|
-
accent-color: var(--lab-color-border-strong);
|
|
1058
|
-
opacity: 0.6;
|
|
1059
|
-
}
|
|
1060
|
-
#astrobook-sidebar .lab-resp-box--done {
|
|
1061
|
-
accent-color: var(--lab-color-accent);
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
/* Cull boxes exist only while the switch is on; live modules keep a disabled one. */
|
|
1065
|
-
#astrobook-sidebar .lab-cull-box {
|
|
1066
|
-
display: none;
|
|
1067
|
-
flex: none;
|
|
1068
|
-
width: 0.85em;
|
|
1069
|
-
height: 0.85em;
|
|
1070
|
-
margin: 0 0 0 0.25em;
|
|
1071
|
-
accent-color: var(--lab-color-accent);
|
|
1072
|
-
cursor: pointer;
|
|
1073
|
-
}
|
|
1074
|
-
:root[data-lab-cull] #astrobook-sidebar .lab-cull-box {
|
|
1075
|
-
display: inline-block;
|
|
1076
|
-
}
|
|
1077
|
-
#astrobook-sidebar .lab-cull-box:disabled {
|
|
1078
|
-
cursor: not-allowed;
|
|
1079
|
-
opacity: 0.35;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
#lab-cull-toggle svg,
|
|
1083
|
-
#lab-viewport-link svg,
|
|
1084
|
-
#lab-staging-link svg,
|
|
1085
|
-
#lab-code-link svg {
|
|
1086
|
-
display: block;
|
|
1087
|
-
}
|
|
1088
|
-
#lab-cull-toggle[aria-pressed="true"] {
|
|
1089
|
-
color: var(--lab-color-accent);
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
#astrobook-sidebar .lab-row-error {
|
|
1093
|
-
font-family: var(--lab-font-mono);
|
|
1094
|
-
font-size: 0.7em;
|
|
1095
|
-
line-height: 1.35;
|
|
1096
|
-
margin: 0.25em 0.5em 0.25em 1.75em;
|
|
1097
|
-
padding: 0.5em 0.75em;
|
|
1098
|
-
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
|
|
1099
|
-
border-radius: 0.25rem;
|
|
1100
|
-
color: var(--lab-color-text-strong);
|
|
1101
|
-
}
|
|
1102
|
-
</style>
|