@grimoire-rs/indexer 0.1.8 → 0.2.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/README.md +38 -0
- package/dist/cli/init.js +1 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/config.d.ts +21 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +19 -2
- package/dist/config.js.map +1 -1
- package/dist/enrich/index.d.ts.map +1 -1
- package/dist/enrich/index.js +62 -0
- package/dist/enrich/index.js.map +1 -1
- package/dist/renderer/astro/components/BrandMark.d.ts +14 -0
- package/dist/renderer/astro/components/BrandMark.d.ts.map +1 -0
- package/dist/renderer/astro/components/BrandMark.js +16 -0
- package/dist/renderer/astro/components/BrandMark.js.map +1 -0
- package/dist/renderer/astro/components/BrandMark.tsx +19 -0
- package/dist/renderer/astro/components/Catalog.d.ts.map +1 -1
- package/dist/renderer/astro/components/Catalog.js +310 -18
- package/dist/renderer/astro/components/Catalog.js.map +1 -1
- package/dist/renderer/astro/components/Catalog.tsx +445 -63
- package/dist/renderer/astro/components/CommandField.astro +43 -0
- package/dist/renderer/astro/components/PickerMenu.astro +68 -0
- package/dist/renderer/astro/components/VersionMenu.astro +6 -2
- package/dist/renderer/astro/content.config.ts +23 -5
- package/dist/renderer/astro/layouts/Base.astro +794 -66
- package/dist/renderer/astro/lib/catalog.d.ts +24 -0
- package/dist/renderer/astro/lib/catalog.d.ts.map +1 -1
- package/dist/renderer/astro/lib/catalog.js +62 -0
- package/dist/renderer/astro/lib/catalog.js.map +1 -1
- package/dist/renderer/astro/lib/catalog.ts +62 -0
- package/dist/renderer/astro/lib/code.d.ts +22 -0
- package/dist/renderer/astro/lib/code.d.ts.map +1 -0
- package/dist/renderer/astro/lib/code.js +21 -0
- package/dist/renderer/astro/lib/code.js.map +1 -0
- package/dist/renderer/astro/lib/code.ts +21 -0
- package/dist/renderer/astro/pages/index.astro +144 -36
- package/dist/renderer/astro/pages/p/[...slug].astro +403 -55
- package/dist/renderer/index.d.ts +23 -0
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +117 -30
- package/dist/renderer/index.js.map +1 -1
- package/package.json +5 -1
- package/dist/renderer/astro/components/CopyButton.astro +0 -24
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// sheet. Everything in the global block sits in `@layer grimoire`, so a
|
|
4
4
|
// user CSS file (unlayered) wins over any of it regardless of specificity
|
|
5
5
|
// or injection order — that layer is the entire theming contract.
|
|
6
|
+
import { Moon, Sun } from "lucide-preact";
|
|
6
7
|
import { withBase } from "../lib/base";
|
|
7
8
|
import { data } from "../lib/data";
|
|
8
9
|
|
|
@@ -12,7 +13,9 @@ interface Props {
|
|
|
12
13
|
image?: string;
|
|
13
14
|
}
|
|
14
15
|
const { config, css } = data;
|
|
15
|
-
|
|
16
|
+
// `og:image` falls back to the logo before the favicon: a favicon is drawn to
|
|
17
|
+
// read at 16px, and a link preview is not that.
|
|
18
|
+
const { title, description, image = config.logo ?? config.favicon } = Astro.props;
|
|
16
19
|
// Shown as well as linked: on a subpath deployment the bare path is not
|
|
17
20
|
// where the file is, so the label has to carry the prefix too.
|
|
18
21
|
const allJson = withBase("/all.json");
|
|
@@ -22,6 +25,24 @@ const brandRest =
|
|
|
22
25
|
config.brandMark && config.brand.startsWith(config.brandMark)
|
|
23
26
|
? config.brand.slice(config.brandMark.length)
|
|
24
27
|
: null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Name the forge `repoUrl` actually points at, rather than assuming one.
|
|
31
|
+
* `repoUrl` is free-form config: an index hosted on GitLab was previously
|
|
32
|
+
* labelled "GitHub" in its own header. An unrecognized host is named
|
|
33
|
+
* outright — honest, and already lowercase, which is the house style for
|
|
34
|
+
* this nav.
|
|
35
|
+
*/
|
|
36
|
+
const FORGES = ["github", "gitlab", "bitbucket", "codeberg", "sourcehut"];
|
|
37
|
+
function repoLabel(url: string): string {
|
|
38
|
+
let host: string;
|
|
39
|
+
try {
|
|
40
|
+
host = new URL(url).hostname.replace(/^www\./, "");
|
|
41
|
+
} catch {
|
|
42
|
+
return "repo";
|
|
43
|
+
}
|
|
44
|
+
return FORGES.find((forge) => host.includes(forge)) ?? host;
|
|
45
|
+
}
|
|
25
46
|
---
|
|
26
47
|
|
|
27
48
|
<html lang="en">
|
|
@@ -42,12 +63,67 @@ const brandRest =
|
|
|
42
63
|
(matchMedia("(prefers-color-scheme: dark)").matches
|
|
43
64
|
? "dark"
|
|
44
65
|
: "light");
|
|
66
|
+
|
|
67
|
+
// Also before first paint: a `?q=` deep link (a keyword chip on a
|
|
68
|
+
// package page) means the catalog the server rendered — every package,
|
|
69
|
+
// empty search box — is the wrong one. Flag it so CSS holds the
|
|
70
|
+
// catalog back rather than showing the full list and collapsing it a
|
|
71
|
+
// moment later. The island drops the flag on its first render, which
|
|
72
|
+
// is already the filtered one; the timeout is the failsafe for an
|
|
73
|
+
// island that never hydrates, where a flash beats a blank page.
|
|
74
|
+
if (new URLSearchParams(location.search).get("q")) {
|
|
75
|
+
document.documentElement.dataset.query = "";
|
|
76
|
+
setTimeout(() => delete document.documentElement.dataset.query, 3000);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Selecting one choice in a command bar. Defined here, in the head,
|
|
80
|
+
// because it has two callers that cannot share a script: the click
|
|
81
|
+
// handler at the end of the document, and the host-platform preselect
|
|
82
|
+
// that has to run while the hero is still being parsed (see
|
|
83
|
+
// `index.astro`). One definition, so the two cannot drift.
|
|
84
|
+
window.__grimPick = (bar, pick) => {
|
|
85
|
+
const command = pick.dataset.osPick;
|
|
86
|
+
const field = bar.querySelector("[data-copy]");
|
|
87
|
+
const code = field?.querySelector("code");
|
|
88
|
+
// One element is both the shown command and what gets copied, so
|
|
89
|
+
// they cannot drift apart.
|
|
90
|
+
if (code) code.textContent = command;
|
|
91
|
+
if (field) field.dataset.copy = command;
|
|
92
|
+
// Keep the toast's name in step with the choice: the bar's noun is
|
|
93
|
+
// fixed ("install command"), the choice supplies the adjective.
|
|
94
|
+
if (field && bar.dataset.osNoun) {
|
|
95
|
+
field.dataset.copyName = `${pick.dataset.osName} ${bar.dataset.osNoun}`;
|
|
96
|
+
}
|
|
97
|
+
for (const other of bar.querySelectorAll("[data-os-pick]")) {
|
|
98
|
+
other.setAttribute("aria-checked", other === pick ? "true" : "false");
|
|
99
|
+
}
|
|
100
|
+
// The trigger carries every glyph and shows one; swapping which is
|
|
101
|
+
// hidden beats rewriting an SVG path.
|
|
102
|
+
for (const glyph of bar.querySelectorAll("[data-os-glyph]")) {
|
|
103
|
+
glyph.hidden = glyph.dataset.osGlyph !== pick.dataset.osName;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// The platform a bar should start on, or null for one this visitor's
|
|
108
|
+
// host is not among. Order matters: a macOS UA string contains "Mac"
|
|
109
|
+
// but a Windows one contains "Win", and ChromeOS reports "CrOS" while
|
|
110
|
+
// running the same shell installer as Linux.
|
|
111
|
+
window.__grimHostOs = () => {
|
|
112
|
+
const ua = navigator.userAgentData?.platform || navigator.platform || navigator.userAgent;
|
|
113
|
+
if (/mac|iphone|ipad|darwin/i.test(ua)) return "macOS";
|
|
114
|
+
if (/win/i.test(ua)) return "Windows";
|
|
115
|
+
if (/linux|android|cros|x11/i.test(ua)) return "Linux";
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
45
118
|
</script>
|
|
46
119
|
</head>
|
|
47
120
|
<body>
|
|
48
121
|
<header>
|
|
49
122
|
<div class="shell header-row">
|
|
50
123
|
<a class="brand" href={withBase("/")}>
|
|
124
|
+
{/* Decorative: the brand text is right beside it, so announcing the
|
|
125
|
+
logo too would read the index's name twice. */}
|
|
126
|
+
{config.logo && <img class="brand-logo" src={withBase(config.logo)} alt="" />}
|
|
51
127
|
{
|
|
52
128
|
brandRest === null ? (
|
|
53
129
|
config.brand
|
|
@@ -64,13 +140,17 @@ const brandRest =
|
|
|
64
140
|
{
|
|
65
141
|
config.repoUrl && (
|
|
66
142
|
<a href={config.repoUrl} target="_blank" rel="noopener noreferrer">
|
|
67
|
-
|
|
143
|
+
{repoLabel(config.repoUrl)}
|
|
68
144
|
</a>
|
|
69
145
|
)
|
|
70
146
|
}
|
|
147
|
+
{/* Stroked, not filled, and inheriting the nav's own colour, so the
|
|
148
|
+
toggle reads as a third nav item rather than a control bolted
|
|
149
|
+
beside them. Rendered statically — no client directive, so no
|
|
150
|
+
JavaScript ships for the icons. */}
|
|
71
151
|
<button id="theme-toggle" type="button" aria-label="Toggle theme">
|
|
72
|
-
<
|
|
73
|
-
<
|
|
152
|
+
<Moon class="when-light" size={16} aria-hidden="true" />
|
|
153
|
+
<Sun class="when-dark" size={16} aria-hidden="true" />
|
|
74
154
|
</button>
|
|
75
155
|
</nav>
|
|
76
156
|
</div>
|
|
@@ -87,9 +167,37 @@ const brandRest =
|
|
|
87
167
|
config.footerNote ? ` · ${config.footerNote}` : ""
|
|
88
168
|
}
|
|
89
169
|
</p>
|
|
170
|
+
{/* Opt-out, not opt-in: an index runner who does not want to say
|
|
171
|
+
where the software came from sets `attribution: false`, and this
|
|
172
|
+
disappears. Nothing else on the page changes.
|
|
173
|
+
|
|
174
|
+
"using", not "by": the index runner built this site — Grimoire is
|
|
175
|
+
the tool they built it with. */}
|
|
176
|
+
{
|
|
177
|
+
config.attribution && (
|
|
178
|
+
<p class="attribution">
|
|
179
|
+
Built with <span aria-hidden="true">♥</span><span class="sr-only">love</span> using{" "}
|
|
180
|
+
{/* New tab: this is the one link that leaves the index for an
|
|
181
|
+
unrelated site, and a visitor mid-browse should not lose
|
|
182
|
+
their place to it. */}
|
|
183
|
+
<a href="https://grimoire.rs" target="_blank" rel="noopener">
|
|
184
|
+
Grimoire
|
|
185
|
+
</a>
|
|
186
|
+
</p>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
90
189
|
</div>
|
|
91
190
|
</footer>
|
|
92
191
|
|
|
192
|
+
{/* One toast for the whole page, outside every scrolling container so a
|
|
193
|
+
copy from a card at the bottom lands in the same place as one from
|
|
194
|
+
the hero. `aria-hidden`, because each copy button already carries its
|
|
195
|
+
own `role="status"` — without this it is announced twice. */}
|
|
196
|
+
<div id="copy-toast" aria-hidden="true">
|
|
197
|
+
<span class="toast-name"></span>
|
|
198
|
+
<code class="toast-value"></code>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
93
201
|
<script is:inline>
|
|
94
202
|
document.getElementById("theme-toggle").addEventListener("click", () => {
|
|
95
203
|
const root = document.documentElement;
|
|
@@ -98,18 +206,139 @@ const brandRest =
|
|
|
98
206
|
localStorage.setItem("theme", next);
|
|
99
207
|
});
|
|
100
208
|
|
|
209
|
+
// Static-page logo slots. The catalog island runs the same state
|
|
210
|
+
// machine in Preact, because it owns those nodes; this drives the ones
|
|
211
|
+
// Astro rendered. The placeholder is already in the markup, so all
|
|
212
|
+
// there is to do is move the slot from `loading` to `ready`/`broken`.
|
|
213
|
+
//
|
|
214
|
+
// The initial `complete` sweep is not redundant with the listeners: an
|
|
215
|
+
// image starts loading while the document is parsed, so a fast result
|
|
216
|
+
// can land before this script runs and fire its event at nobody.
|
|
217
|
+
// `complete` says the browser finished, not how it went — `decode()`
|
|
218
|
+
// separates them, and unlike `naturalWidth === 0` it does not
|
|
219
|
+
// misreport an SVG that has no intrinsic size.
|
|
220
|
+
// `[data-static]` is load-bearing, not decoration: the catalog island
|
|
221
|
+
// renders `.logo-slot` too, and Preact owns `data-state` there. Writing
|
|
222
|
+
// it from here made the slot flip to the script's answer, then back to
|
|
223
|
+
// Preact's `loading` on hydration, then forward again — the icon fading
|
|
224
|
+
// in and straight back out.
|
|
225
|
+
for (const slot of document.querySelectorAll(".logo-slot[data-static]")) {
|
|
226
|
+
const img = slot.querySelector("img");
|
|
227
|
+
if (!img) continue;
|
|
228
|
+
const settle = (state) => slot.setAttribute("data-state", state);
|
|
229
|
+
img.addEventListener("load", () => settle("ready"));
|
|
230
|
+
img.addEventListener("error", () => settle("broken"));
|
|
231
|
+
if (img.complete) {
|
|
232
|
+
img.decode().then(
|
|
233
|
+
() => settle("ready"),
|
|
234
|
+
() => settle("broken"),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Command-bar pickers. `__grimPick` (head) does the swap; this only
|
|
240
|
+
// wires the ways a choice gets made and the menu closed. The copy
|
|
241
|
+
// handler reads `data-copy` at click time, so it needs no part in this.
|
|
242
|
+
for (const bar of document.querySelectorAll("[data-os-switch]")) {
|
|
243
|
+
const menu = bar.querySelector(".os-menu");
|
|
244
|
+
const summary = menu?.querySelector("summary");
|
|
245
|
+
for (const pick of bar.querySelectorAll("[data-os-pick]")) {
|
|
246
|
+
pick.addEventListener("click", () => {
|
|
247
|
+
window.__grimPick(bar, pick);
|
|
248
|
+
if (menu) menu.open = false;
|
|
249
|
+
summary?.focus();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// `<details>` opens and closes itself but knows nothing about the
|
|
253
|
+
// two ways every other menu closes.
|
|
254
|
+
if (menu) {
|
|
255
|
+
document.addEventListener("click", (event) => {
|
|
256
|
+
if (!menu.contains(event.target)) menu.open = false;
|
|
257
|
+
});
|
|
258
|
+
menu.addEventListener("keydown", (event) => {
|
|
259
|
+
if (event.key !== "Escape") return;
|
|
260
|
+
menu.open = false;
|
|
261
|
+
summary?.focus();
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// One toast for every copy on the page. The in-place check confirms
|
|
267
|
+
// that *something* was copied; a card has three copy buttons a few
|
|
268
|
+
// pixels apart, all of which flash the same check, so the toast is
|
|
269
|
+
// what says WHICH. It carries both readings: a name for what was
|
|
270
|
+
// copied, and the exact string, because "the install command" is the
|
|
271
|
+
// useful part when you meant to pick a different platform and the raw
|
|
272
|
+
// text is the useful part when you are not sure what landed.
|
|
273
|
+
//
|
|
274
|
+
// Announcing is left to the per-button status span that already exists
|
|
275
|
+
// — this element is `aria-hidden`, or every copy would be read twice.
|
|
276
|
+
const toast = document.getElementById("copy-toast");
|
|
277
|
+
let toastTimer;
|
|
278
|
+
function showToast(name, value) {
|
|
279
|
+
if (!toast) return;
|
|
280
|
+
toast.querySelector(".toast-name").textContent = name
|
|
281
|
+
? `Copied ${name}`
|
|
282
|
+
: "Copied";
|
|
283
|
+
toast.querySelector(".toast-value").textContent = value;
|
|
284
|
+
// Restart the animation even mid-fade: without the reflow a second
|
|
285
|
+
// copy inside the window leaves the class already set and nothing
|
|
286
|
+
// moves.
|
|
287
|
+
toast.classList.remove("show");
|
|
288
|
+
void toast.offsetWidth;
|
|
289
|
+
toast.classList.add("show");
|
|
290
|
+
clearTimeout(toastTimer);
|
|
291
|
+
toastTimer = setTimeout(() => toast.classList.remove("show"), 2600);
|
|
292
|
+
}
|
|
293
|
+
// Hydrated islands cannot reach this function, so they say so by event.
|
|
294
|
+
document.addEventListener("grimoire:copied", (event) => {
|
|
295
|
+
showToast(event.detail.name, event.detail.value);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// A copy button on every code block. Injected rather than authored:
|
|
299
|
+
// the blocks come out of Shiki, inside rendered markdown, so there is
|
|
300
|
+
// no markup to add one to. `data-copy` is all it takes to join the
|
|
301
|
+
// handler below — same clipboard path, same flash, same toast.
|
|
302
|
+
const CLIPBOARD_ICON =
|
|
303
|
+
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
|
304
|
+
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
305
|
+
'<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>' +
|
|
306
|
+
'<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
|
|
307
|
+
|
|
308
|
+
for (const pre of document.querySelectorAll("pre.astro-code")) {
|
|
309
|
+
const wrapper = document.createElement("div");
|
|
310
|
+
wrapper.className = "code-block";
|
|
311
|
+
pre.replaceWith(wrapper);
|
|
312
|
+
wrapper.append(pre);
|
|
313
|
+
|
|
314
|
+
const btn = document.createElement("button");
|
|
315
|
+
btn.type = "button";
|
|
316
|
+
btn.className = "code-copy";
|
|
317
|
+
btn.title = "Copy";
|
|
318
|
+
btn.setAttribute("aria-label", "Copy this code block");
|
|
319
|
+
// `textContent`, not `innerText`: the block is not rendered yet at
|
|
320
|
+
// this point, and `innerText` would collapse its whitespace.
|
|
321
|
+
btn.dataset.copy = pre.textContent ?? "";
|
|
322
|
+
btn.dataset.copyName = "code block";
|
|
323
|
+
btn.innerHTML = CLIPBOARD_ICON + '<span class="sr-only" role="status"></span>';
|
|
324
|
+
wrapper.append(btn);
|
|
325
|
+
}
|
|
326
|
+
|
|
101
327
|
for (const btn of document.querySelectorAll("[data-copy]")) {
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
328
|
+
// The visible label never changes — swapping "copy" for "copied"
|
|
329
|
+
// resized the button and shifted its neighbours. `.copied` swaps the
|
|
330
|
+
// icon instead, and the status span carries the same news to a
|
|
331
|
+
// screen reader without occupying any width.
|
|
332
|
+
const status = btn.querySelector('[role="status"]');
|
|
106
333
|
btn.addEventListener("click", () => {
|
|
107
|
-
|
|
334
|
+
const value = btn.dataset.copy ?? "";
|
|
335
|
+
navigator.clipboard.writeText(value).then(() => {
|
|
108
336
|
btn.classList.add("copied");
|
|
109
|
-
if (
|
|
337
|
+
if (status) status.textContent = "copied";
|
|
338
|
+
showToast(btn.dataset.copyName ?? "", value);
|
|
110
339
|
setTimeout(() => {
|
|
111
340
|
btn.classList.remove("copied");
|
|
112
|
-
if (
|
|
341
|
+
if (status) status.textContent = "";
|
|
113
342
|
}, 1500);
|
|
114
343
|
});
|
|
115
344
|
});
|
|
@@ -222,11 +451,21 @@ const brandRest =
|
|
|
222
451
|
gap: 1rem;
|
|
223
452
|
}
|
|
224
453
|
.brand {
|
|
454
|
+
display: inline-flex;
|
|
455
|
+
align-items: center;
|
|
456
|
+
gap: 0.55rem;
|
|
225
457
|
color: var(--fg);
|
|
226
458
|
text-decoration: none;
|
|
227
459
|
font-weight: 600;
|
|
228
460
|
font-size: 1.05rem;
|
|
229
461
|
}
|
|
462
|
+
/* Height-bound, width free: a wordmark and a square icon are both
|
|
463
|
+
valid logos and neither should be squashed into the other's box. */
|
|
464
|
+
.brand-logo {
|
|
465
|
+
height: 1.5rem;
|
|
466
|
+
width: auto;
|
|
467
|
+
max-width: 12rem;
|
|
468
|
+
}
|
|
230
469
|
.brand-mark {
|
|
231
470
|
color: var(--accent);
|
|
232
471
|
font-family: ui-monospace, "SFMono-Regular", monospace;
|
|
@@ -243,45 +482,73 @@ const brandRest =
|
|
|
243
482
|
nav a:hover {
|
|
244
483
|
color: var(--fg);
|
|
245
484
|
}
|
|
485
|
+
/* Borderless and colour-inheriting: same resting and hover colours as
|
|
486
|
+
the nav links beside it, so the row reads as one set. */
|
|
246
487
|
#theme-toggle {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
border
|
|
250
|
-
|
|
488
|
+
display: inline-flex;
|
|
489
|
+
align-items: center;
|
|
490
|
+
border: none;
|
|
491
|
+
background: none;
|
|
492
|
+
padding: 0;
|
|
493
|
+
color: var(--muted);
|
|
251
494
|
cursor: pointer;
|
|
252
|
-
|
|
253
|
-
|
|
495
|
+
}
|
|
496
|
+
#theme-toggle:hover {
|
|
497
|
+
color: var(--fg);
|
|
254
498
|
}
|
|
255
499
|
[data-theme="dark"] .when-light,
|
|
256
500
|
[data-theme="light"] .when-dark {
|
|
257
501
|
display: none;
|
|
258
502
|
}
|
|
259
503
|
|
|
504
|
+
/* Description left, getting-started block right. The right column is
|
|
505
|
+
`auto` — it takes exactly what the add-index unit needs and gives
|
|
506
|
+
the rest to the text. h1 spans both. */
|
|
260
507
|
.hero {
|
|
261
508
|
display: grid;
|
|
262
|
-
|
|
263
|
-
shrink below content width (commands scroll) before squeezing
|
|
264
|
-
the text column out. h1 spans the full row; paragraph and
|
|
265
|
-
install strip share the second row, top-aligned. */
|
|
266
|
-
grid-template-columns: 1fr fit-content(44rem);
|
|
509
|
+
grid-template-columns: 1fr auto;
|
|
267
510
|
align-items: start;
|
|
268
|
-
gap: 0.
|
|
269
|
-
margin-block: 1rem 1.
|
|
511
|
+
gap: 0.6rem 2rem;
|
|
512
|
+
margin-block: 1rem 1.25rem;
|
|
270
513
|
}
|
|
271
514
|
.hero h1 {
|
|
272
515
|
grid-column: 1 / -1;
|
|
273
516
|
margin: 0;
|
|
274
517
|
font-size: 1.6rem;
|
|
275
518
|
}
|
|
276
|
-
.
|
|
277
|
-
|
|
519
|
+
/* Label column, bar column. `minmax(0, …)` on the bars is what lets
|
|
520
|
+
the commands scroll instead of widening the grid. */
|
|
521
|
+
.hero-side {
|
|
522
|
+
display: grid;
|
|
523
|
+
grid-template-columns: auto minmax(0, 34rem);
|
|
524
|
+
align-items: center;
|
|
525
|
+
gap: 0.4rem 0.7rem;
|
|
526
|
+
}
|
|
527
|
+
.side-label {
|
|
278
528
|
color: var(--muted);
|
|
279
|
-
|
|
529
|
+
font-size: 0.72rem;
|
|
530
|
+
font-weight: 600;
|
|
531
|
+
text-transform: uppercase;
|
|
532
|
+
letter-spacing: 0.04em;
|
|
533
|
+
white-space: nowrap;
|
|
280
534
|
}
|
|
281
|
-
|
|
535
|
+
/* Stacked below the description before the two get cramped rather
|
|
536
|
+
than after — a command squeezed to a few visible characters is
|
|
537
|
+
worse than one on its own row. Stacked, the bars take the whole
|
|
538
|
+
width instead of staying at their side-by-side cap, which is the
|
|
539
|
+
point of dropping down. */
|
|
540
|
+
@media (max-width: 72rem) {
|
|
282
541
|
.hero {
|
|
283
542
|
grid-template-columns: 1fr;
|
|
284
543
|
}
|
|
544
|
+
.hero-side {
|
|
545
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
.hero p {
|
|
549
|
+
margin: 0;
|
|
550
|
+
color: var(--muted);
|
|
551
|
+
max-width: 46rem;
|
|
285
552
|
}
|
|
286
553
|
.hero code {
|
|
287
554
|
background: var(--chip-bg);
|
|
@@ -290,60 +557,299 @@ const brandRest =
|
|
|
290
557
|
font-size: 0.9em;
|
|
291
558
|
}
|
|
292
559
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
gap: 0.5rem;
|
|
560
|
+
/* The command and every way to act on it inside one border — a
|
|
561
|
+
single control, not a field with buttons parked beside it. */
|
|
562
|
+
.cmd-bar {
|
|
563
|
+
display: flex;
|
|
564
|
+
align-items: stretch;
|
|
299
565
|
min-width: 0;
|
|
300
|
-
}
|
|
301
|
-
.install-row {
|
|
302
|
-
grid-column: 1 / -1;
|
|
303
|
-
display: grid;
|
|
304
|
-
grid-template-columns: subgrid;
|
|
305
|
-
align-items: center;
|
|
306
|
-
column-gap: 0.6rem;
|
|
307
566
|
background: var(--card);
|
|
308
567
|
border: 1px solid var(--border);
|
|
309
568
|
border-radius: 8px;
|
|
310
|
-
padding: 0.45rem 0.7rem;
|
|
311
|
-
}
|
|
312
|
-
.install-row .os {
|
|
313
|
-
color: var(--muted);
|
|
314
|
-
font-size: 0.78rem;
|
|
315
|
-
font-weight: 600;
|
|
316
|
-
text-transform: uppercase;
|
|
317
|
-
letter-spacing: 0.04em;
|
|
318
|
-
white-space: nowrap;
|
|
319
569
|
}
|
|
320
|
-
.
|
|
570
|
+
/* No `overflow: hidden` — that would clip the platform menu. The end
|
|
571
|
+
segments round themselves instead (8px border box, 1px border).
|
|
572
|
+
Stated at the bottom of this block, after `.seg`, because that is
|
|
573
|
+
what the cascade needs — see the note there. */
|
|
574
|
+
/* The command *is* the copy button: the whole field is clickable, so
|
|
575
|
+
there is no separate control taking width beside it. */
|
|
576
|
+
.cmd-field {
|
|
577
|
+
position: relative;
|
|
578
|
+
flex: 1;
|
|
579
|
+
min-width: 0;
|
|
580
|
+
display: flex;
|
|
581
|
+
align-items: center;
|
|
582
|
+
border: none;
|
|
321
583
|
background: none;
|
|
322
584
|
padding: 0;
|
|
585
|
+
font: inherit;
|
|
586
|
+
color: inherit;
|
|
587
|
+
text-align: left;
|
|
588
|
+
cursor: pointer;
|
|
589
|
+
}
|
|
590
|
+
.cmd-field:hover {
|
|
591
|
+
background: var(--chip-bg);
|
|
592
|
+
}
|
|
593
|
+
.cmd-field:focus-visible {
|
|
594
|
+
outline: 2px solid var(--accent);
|
|
595
|
+
outline-offset: -2px;
|
|
596
|
+
}
|
|
597
|
+
.cmd-field code {
|
|
598
|
+
flex: 1;
|
|
599
|
+
min-width: 0;
|
|
600
|
+
background: none;
|
|
601
|
+
border-radius: 0;
|
|
602
|
+
/* Right padding is the icon's lane — the text scrolls under the
|
|
603
|
+
fade rather than up against the glyph. */
|
|
604
|
+
padding: 0.4rem 2.1rem 0.4rem 0.7rem;
|
|
323
605
|
font-size: 0.85rem;
|
|
324
|
-
overflow-x: auto;
|
|
325
606
|
white-space: nowrap;
|
|
326
|
-
|
|
607
|
+
overflow-x: auto;
|
|
608
|
+
/* Scrollable, no bar: the bar is the height of the control and
|
|
609
|
+
would sit under the text. The mask says "there is more" in its
|
|
610
|
+
place — it rides the padding box, so it stays at the visible
|
|
611
|
+
edge as the text scrolls under it. */
|
|
327
612
|
scrollbar-width: none;
|
|
328
|
-
|
|
329
|
-
|
|
613
|
+
/* Fully transparent 1.8rem from the edge — where the icon lane
|
|
614
|
+
starts — not at the edge itself, so no text is ever visible
|
|
615
|
+
under the glyph. */
|
|
616
|
+
mask-image: linear-gradient(
|
|
617
|
+
to right,
|
|
618
|
+
#000 calc(100% - 3.2rem),
|
|
619
|
+
transparent calc(100% - 1.8rem)
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
.cmd-field code::-webkit-scrollbar {
|
|
330
623
|
display: none;
|
|
331
624
|
}
|
|
332
|
-
/*
|
|
333
|
-
|
|
334
|
-
.
|
|
335
|
-
|
|
336
|
-
|
|
625
|
+
/* One grid cell holds both icons, so the check lands exactly where
|
|
626
|
+
the copy glyph was and nothing reflows during the swap. */
|
|
627
|
+
.cmd-icons {
|
|
628
|
+
position: absolute;
|
|
629
|
+
right: 0.6rem;
|
|
630
|
+
display: grid;
|
|
631
|
+
place-items: center;
|
|
632
|
+
color: var(--muted);
|
|
633
|
+
}
|
|
634
|
+
.cmd-icons > * {
|
|
635
|
+
grid-area: 1 / 1;
|
|
636
|
+
transition: opacity 0.18s ease;
|
|
637
|
+
}
|
|
638
|
+
.cmd-icons .icon-done {
|
|
639
|
+
opacity: 0;
|
|
640
|
+
color: var(--kind-rule);
|
|
337
641
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
642
|
+
.cmd-field:hover .cmd-icons,
|
|
643
|
+
.cmd-field:focus-visible .cmd-icons {
|
|
644
|
+
color: var(--fg);
|
|
645
|
+
}
|
|
646
|
+
.cmd-field.copied .icon-idle {
|
|
647
|
+
opacity: 0;
|
|
648
|
+
}
|
|
649
|
+
.cmd-field.copied .icon-done {
|
|
650
|
+
opacity: 1;
|
|
651
|
+
}
|
|
652
|
+
@media (prefers-reduced-motion: reduce) {
|
|
653
|
+
.cmd-icons > * {
|
|
654
|
+
transition: none;
|
|
341
655
|
}
|
|
342
|
-
|
|
343
|
-
|
|
656
|
+
}
|
|
657
|
+
/* Segments: they drop their own border and radius, and one hairline
|
|
658
|
+
divides them. `flex: none` keeps them at full size while the
|
|
659
|
+
command scrolls. */
|
|
660
|
+
.cmd-bar .seg {
|
|
661
|
+
flex: none;
|
|
662
|
+
display: inline-flex;
|
|
663
|
+
align-items: center;
|
|
664
|
+
border: none;
|
|
665
|
+
border-left: 1px solid var(--border);
|
|
666
|
+
border-radius: 0;
|
|
667
|
+
background: none;
|
|
668
|
+
padding: 0 0.5rem;
|
|
669
|
+
color: var(--muted);
|
|
670
|
+
text-decoration: none;
|
|
671
|
+
cursor: pointer;
|
|
672
|
+
}
|
|
673
|
+
/* Platform picker, at the head of the bar: the trigger shows the
|
|
674
|
+
chosen platform's glyph, the menu names them all. */
|
|
675
|
+
.os-menu {
|
|
676
|
+
position: relative;
|
|
677
|
+
flex: none;
|
|
678
|
+
display: flex;
|
|
679
|
+
}
|
|
680
|
+
.os-menu > summary {
|
|
681
|
+
display: inline-flex;
|
|
682
|
+
align-items: center;
|
|
683
|
+
gap: 0.1rem;
|
|
684
|
+
padding: 0 0.45rem;
|
|
685
|
+
color: var(--muted);
|
|
686
|
+
cursor: pointer;
|
|
687
|
+
border-right: 1px solid var(--border);
|
|
688
|
+
/* Both spellings: the default triangle is a marker in Firefox and
|
|
689
|
+
a `::-webkit-details-marker` pseudo-element in older WebKit. */
|
|
690
|
+
list-style: none;
|
|
691
|
+
}
|
|
692
|
+
.os-menu > summary::-webkit-details-marker {
|
|
693
|
+
display: none;
|
|
694
|
+
}
|
|
695
|
+
.os-menu > summary:hover,
|
|
696
|
+
.os-menu > summary:focus-visible,
|
|
697
|
+
.os-menu[open] > summary {
|
|
698
|
+
background: var(--chip-bg);
|
|
699
|
+
color: var(--fg);
|
|
700
|
+
}
|
|
701
|
+
.os-glyph {
|
|
702
|
+
display: inline-flex;
|
|
703
|
+
}
|
|
704
|
+
/* Any author `display` beats the UA sheet's `[hidden] { display: none }`
|
|
705
|
+
regardless of specificity, so the rule above showed all three glyphs
|
|
706
|
+
at once — the `hidden` attribute the markup and the toggle both set
|
|
707
|
+
had no effect at all. Restore it for this class rather than trusting
|
|
708
|
+
the UA default. */
|
|
709
|
+
.os-glyph[hidden] {
|
|
710
|
+
display: none;
|
|
711
|
+
}
|
|
712
|
+
.os-menu ul {
|
|
713
|
+
position: absolute;
|
|
714
|
+
top: calc(100% + 4px);
|
|
715
|
+
left: 0;
|
|
716
|
+
z-index: 5;
|
|
717
|
+
min-width: 9rem;
|
|
718
|
+
margin: 0;
|
|
719
|
+
padding: 0.25rem;
|
|
720
|
+
list-style: none;
|
|
721
|
+
background: var(--card);
|
|
722
|
+
border: 1px solid var(--border);
|
|
723
|
+
border-radius: 8px;
|
|
724
|
+
box-shadow: 0 6px 20px rgb(0 0 0 / 18%);
|
|
725
|
+
}
|
|
726
|
+
.os-menu li button {
|
|
727
|
+
display: flex;
|
|
728
|
+
align-items: center;
|
|
729
|
+
gap: 0.5rem;
|
|
730
|
+
width: 100%;
|
|
731
|
+
border: none;
|
|
732
|
+
border-radius: 6px;
|
|
733
|
+
background: none;
|
|
734
|
+
padding: 0.35rem 0.5rem;
|
|
735
|
+
color: var(--fg);
|
|
736
|
+
font: inherit;
|
|
737
|
+
font-size: 0.85rem;
|
|
738
|
+
text-align: left;
|
|
739
|
+
cursor: pointer;
|
|
740
|
+
}
|
|
741
|
+
.os-menu li button:hover,
|
|
742
|
+
.os-menu li button:focus-visible {
|
|
743
|
+
background: var(--chip-bg);
|
|
744
|
+
}
|
|
745
|
+
.os-menu li button[aria-checked="true"] {
|
|
746
|
+
color: var(--accent);
|
|
747
|
+
}
|
|
748
|
+
/* Borderless segments need a different hover than the pills do — the
|
|
749
|
+
border they would have tinted is not theirs any more. */
|
|
750
|
+
.cmd-bar .seg:hover,
|
|
751
|
+
.cmd-bar .seg:focus-visible {
|
|
752
|
+
background: var(--chip-bg);
|
|
753
|
+
color: var(--fg);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/* The bar's end rounding, last so it wins.
|
|
757
|
+
Two things defeated it while it sat higher up: `.cmd-bar .seg` is
|
|
758
|
+
the same specificity and declares `border-radius: 0`, which squared
|
|
759
|
+
the trailing segment; and `<summary>` took `border-radius: inherit`
|
|
760
|
+
yet still computed 0, so the leading segment was square too. Neither
|
|
761
|
+
shows until a hover, focus or open state paints a background — then
|
|
762
|
+
square corners poke out past the bar's own 8px rounding.
|
|
763
|
+
The summary is named explicitly rather than left to inherit. */
|
|
764
|
+
.cmd-bar > :first-child,
|
|
765
|
+
.cmd-bar > :first-child > summary {
|
|
766
|
+
border-start-start-radius: 7px;
|
|
767
|
+
border-end-start-radius: 7px;
|
|
768
|
+
}
|
|
769
|
+
.cmd-bar > :last-child {
|
|
770
|
+
border-start-end-radius: 7px;
|
|
771
|
+
border-end-end-radius: 7px;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/* Attribution sits opposite the raw-data line, on the same row where
|
|
775
|
+
there is width for it and beneath it when there is not. */
|
|
776
|
+
footer .shell {
|
|
777
|
+
display: flex;
|
|
778
|
+
flex-wrap: wrap;
|
|
779
|
+
justify-content: space-between;
|
|
780
|
+
align-items: baseline;
|
|
781
|
+
gap: 0.35rem 1rem;
|
|
782
|
+
}
|
|
783
|
+
.attribution {
|
|
784
|
+
margin: 0;
|
|
785
|
+
margin-inline-start: auto;
|
|
786
|
+
text-align: end;
|
|
787
|
+
}
|
|
788
|
+
.attribution span[aria-hidden] {
|
|
789
|
+
color: var(--accent);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/* Copy toast. Fixed and centred at the bottom: a card's three copy
|
|
793
|
+
buttons sit a few pixels apart and all flash the same check, so
|
|
794
|
+
this is what says which one fired — and it has to land in the same
|
|
795
|
+
place whether the copy came from the hero or a card at the bottom
|
|
796
|
+
of a long list. */
|
|
797
|
+
#copy-toast {
|
|
798
|
+
position: fixed;
|
|
799
|
+
left: 50%;
|
|
800
|
+
bottom: 1.25rem;
|
|
801
|
+
z-index: 20;
|
|
802
|
+
display: grid;
|
|
803
|
+
gap: 0.15rem;
|
|
804
|
+
max-width: min(30rem, calc(100vw - 2rem));
|
|
805
|
+
padding: 0.5rem 0.85rem;
|
|
806
|
+
background: var(--card);
|
|
807
|
+
border: 1px solid var(--border);
|
|
808
|
+
border-radius: 10px;
|
|
809
|
+
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
|
|
810
|
+
/* Out of the layout and unhittable until shown, so it never eats a
|
|
811
|
+
click aimed at whatever is beneath it. */
|
|
812
|
+
pointer-events: none;
|
|
813
|
+
opacity: 0;
|
|
814
|
+
transform: translate(-50%, 0.5rem);
|
|
815
|
+
transition:
|
|
816
|
+
opacity 160ms ease,
|
|
817
|
+
transform 160ms ease;
|
|
818
|
+
}
|
|
819
|
+
#copy-toast.show {
|
|
820
|
+
opacity: 1;
|
|
821
|
+
transform: translate(-50%, 0);
|
|
822
|
+
}
|
|
823
|
+
#copy-toast .toast-name {
|
|
824
|
+
font-size: 0.8rem;
|
|
825
|
+
font-weight: 600;
|
|
826
|
+
color: var(--fg);
|
|
827
|
+
}
|
|
828
|
+
/* The exact string, one line, clipped rather than wrapped — a long
|
|
829
|
+
install one-liner must not grow the toast into the page. */
|
|
830
|
+
#copy-toast .toast-value {
|
|
831
|
+
font-size: 0.78rem;
|
|
832
|
+
color: var(--muted);
|
|
833
|
+
white-space: nowrap;
|
|
834
|
+
overflow: hidden;
|
|
835
|
+
text-overflow: ellipsis;
|
|
836
|
+
}
|
|
837
|
+
@media (prefers-reduced-motion: reduce) {
|
|
838
|
+
#copy-toast {
|
|
839
|
+
transition-duration: 0ms;
|
|
344
840
|
}
|
|
345
841
|
}
|
|
346
842
|
|
|
843
|
+
/* Arriving on `?q=…`: the server rendered every package and an empty
|
|
844
|
+
search box, so painting it and then collapsing to the match is a
|
|
845
|
+
flash of the wrong answer. `visibility` rather than `display` —
|
|
846
|
+
the space stays reserved, so the footer does not jump either. The
|
|
847
|
+
attribute is set before first paint and dropped by the island's
|
|
848
|
+
first render, which is already the filtered one. */
|
|
849
|
+
:root[data-query] .catalog {
|
|
850
|
+
visibility: hidden;
|
|
851
|
+
}
|
|
852
|
+
|
|
347
853
|
.controls {
|
|
348
854
|
display: flex;
|
|
349
855
|
flex-wrap: wrap;
|
|
@@ -351,24 +857,86 @@ const brandRest =
|
|
|
351
857
|
gap: 0.75rem;
|
|
352
858
|
margin-bottom: 1.25rem;
|
|
353
859
|
}
|
|
860
|
+
/* Full-width basis in a wrapping row: the search bar takes a line to
|
|
861
|
+
itself and pushes both chip groups onto the next one, which then
|
|
862
|
+
sit side by side. The container's 0.75rem gap separates the two
|
|
863
|
+
rows, and reads wider than the 0.4rem inside a group — so sort and
|
|
864
|
+
kind still group visually without a wrapper element. */
|
|
865
|
+
.search-field {
|
|
866
|
+
position: relative;
|
|
867
|
+
display: flex;
|
|
868
|
+
flex: 1 1 100%;
|
|
869
|
+
/* Breathing room above the field when `/` scrolls it to the top of
|
|
870
|
+
the viewport, so it does not sit flush against the edge. */
|
|
871
|
+
scroll-margin-top: 1.25rem;
|
|
872
|
+
}
|
|
354
873
|
.controls input[type="search"] {
|
|
355
|
-
flex: 1
|
|
874
|
+
flex: 1;
|
|
356
875
|
padding: 0.55rem 0.8rem;
|
|
876
|
+
/* Room for the key hint, so a long query never runs under it. */
|
|
877
|
+
padding-right: 2.4rem;
|
|
357
878
|
border: 1px solid var(--border);
|
|
358
879
|
border-radius: 8px;
|
|
359
880
|
background: var(--card);
|
|
360
881
|
color: var(--fg);
|
|
361
882
|
font-size: 0.95rem;
|
|
883
|
+
min-width: 0;
|
|
362
884
|
}
|
|
363
885
|
.controls input[type="search"]:focus-visible {
|
|
364
886
|
outline: 2px solid var(--accent);
|
|
365
887
|
outline-offset: 1px;
|
|
366
888
|
}
|
|
889
|
+
/* The `/` shortcut, shown as the key it is. It is a hint for a field
|
|
890
|
+
that is not yet in use, so it withdraws the moment the field is
|
|
891
|
+
focused or holds a query — at which point it would only be
|
|
892
|
+
sitting on top of what is being typed. */
|
|
893
|
+
.search-hint {
|
|
894
|
+
position: absolute;
|
|
895
|
+
right: 0.5rem;
|
|
896
|
+
top: 50%;
|
|
897
|
+
translate: 0 -50%;
|
|
898
|
+
pointer-events: none;
|
|
899
|
+
border: 1px solid var(--border);
|
|
900
|
+
border-bottom-width: 2px;
|
|
901
|
+
border-radius: 5px;
|
|
902
|
+
background: var(--chip-bg);
|
|
903
|
+
color: var(--muted);
|
|
904
|
+
font-family: ui-monospace, "SFMono-Regular", monospace;
|
|
905
|
+
font-size: 0.72rem;
|
|
906
|
+
line-height: 1;
|
|
907
|
+
padding: 0.2rem 0.4rem;
|
|
908
|
+
}
|
|
909
|
+
.search-field:focus-within .search-hint,
|
|
910
|
+
.search-field:has(input:not(:placeholder-shown)) .search-hint {
|
|
911
|
+
display: none;
|
|
912
|
+
}
|
|
367
913
|
.chips {
|
|
368
914
|
display: flex;
|
|
369
915
|
flex-wrap: wrap;
|
|
370
916
|
gap: 0.4rem;
|
|
371
917
|
}
|
|
918
|
+
/* A drawn rule rather than a "|" glyph: its height is the chip row's,
|
|
919
|
+
not whatever the font gives a pipe. `stretch` overrides the
|
|
920
|
+
container's `align-items: center` for this one item. */
|
|
921
|
+
.chip-sep {
|
|
922
|
+
align-self: stretch;
|
|
923
|
+
width: 1px;
|
|
924
|
+
background: var(--border);
|
|
925
|
+
}
|
|
926
|
+
/* Held at the far end of the chip row: it answers a different
|
|
927
|
+
question from sort and kind — what the catalog is withholding,
|
|
928
|
+
not how to arrange it. */
|
|
929
|
+
.chip.deprecated-toggle {
|
|
930
|
+
margin-left: auto;
|
|
931
|
+
}
|
|
932
|
+
/* Pressed state borrows the deprecation colour rather than the
|
|
933
|
+
accent, so "deprecated entries are in view" is signalled by the
|
|
934
|
+
same colour the badges on those entries carry. */
|
|
935
|
+
.chip.deprecated-toggle.active {
|
|
936
|
+
color: var(--deprecated);
|
|
937
|
+
border-color: var(--deprecated);
|
|
938
|
+
background: var(--chip-bg);
|
|
939
|
+
}
|
|
372
940
|
.chip {
|
|
373
941
|
border: 1px solid var(--border);
|
|
374
942
|
background: var(--chip-bg);
|
|
@@ -420,6 +988,14 @@ const brandRest =
|
|
|
420
988
|
) {
|
|
421
989
|
border-color: var(--accent);
|
|
422
990
|
}
|
|
991
|
+
/* Keyboard focus wears the hover treatment rather than an outline on
|
|
992
|
+
top of it — one affordance, whichever way the card was reached.
|
|
993
|
+
The border-colour change is still a visible focus indicator, which
|
|
994
|
+
is the part that may not be dropped. */
|
|
995
|
+
.card:focus-visible {
|
|
996
|
+
outline: none;
|
|
997
|
+
border-color: var(--accent);
|
|
998
|
+
}
|
|
423
999
|
.card h2 a::after {
|
|
424
1000
|
content: "";
|
|
425
1001
|
position: absolute;
|
|
@@ -466,6 +1042,68 @@ const brandRest =
|
|
|
466
1042
|
font-size: 0.85rem;
|
|
467
1043
|
font-family: ui-monospace, "SFMono-Regular", monospace;
|
|
468
1044
|
}
|
|
1045
|
+
/* THE LOGO SLOT — one box, three states, no layout shift.
|
|
1046
|
+
The placeholder ships in the HTML and the image sits on top of it
|
|
1047
|
+
starting fully transparent, so a failing load never gets to paint
|
|
1048
|
+
the browser's broken glyph and a slow one never leaves a hole. The
|
|
1049
|
+
image fades in only once it is known good; `loading="lazy"` keeps
|
|
1050
|
+
off-screen cards from fetching at all, which is the state this
|
|
1051
|
+
placeholder was designed to look deliberate in.
|
|
1052
|
+
|
|
1053
|
+
loading placeholder, no slash — the resting state
|
|
1054
|
+
ready image faded in, placeholder faded out
|
|
1055
|
+
broken placeholder with its slash, dashed edge
|
|
1056
|
+
|
|
1057
|
+
Kept distinct from `.card-logo-fallback`: that is a package with
|
|
1058
|
+
no logo at all, which is normal, not a fault. */
|
|
1059
|
+
.logo-slot {
|
|
1060
|
+
position: relative;
|
|
1061
|
+
display: flex;
|
|
1062
|
+
align-items: center;
|
|
1063
|
+
justify-content: center;
|
|
1064
|
+
overflow: hidden;
|
|
1065
|
+
background: var(--chip-bg);
|
|
1066
|
+
color: var(--muted);
|
|
1067
|
+
}
|
|
1068
|
+
.logo-slot .logo-mark,
|
|
1069
|
+
.logo-slot img {
|
|
1070
|
+
transition: opacity 200ms ease;
|
|
1071
|
+
}
|
|
1072
|
+
.logo-slot .logo-mark {
|
|
1073
|
+
width: 60%;
|
|
1074
|
+
height: 60%;
|
|
1075
|
+
}
|
|
1076
|
+
.logo-slot img {
|
|
1077
|
+
position: absolute;
|
|
1078
|
+
inset: 0;
|
|
1079
|
+
width: 100%;
|
|
1080
|
+
height: 100%;
|
|
1081
|
+
object-fit: contain;
|
|
1082
|
+
opacity: 0;
|
|
1083
|
+
}
|
|
1084
|
+
.logo-slot[data-state="ready"] img {
|
|
1085
|
+
opacity: 1;
|
|
1086
|
+
}
|
|
1087
|
+
.logo-slot[data-state="ready"] .logo-mark {
|
|
1088
|
+
opacity: 0;
|
|
1089
|
+
}
|
|
1090
|
+
/* Two marks, one shown per state — the static page cannot choose in
|
|
1091
|
+
markup, because nothing has run yet when it is parsed. The card
|
|
1092
|
+
grid renders only the one it needs, so these rules never apply
|
|
1093
|
+
there. */
|
|
1094
|
+
.logo-slot[data-state="broken"] .mark-loading,
|
|
1095
|
+
.logo-slot:not([data-state="broken"]) .mark-broken {
|
|
1096
|
+
display: none;
|
|
1097
|
+
}
|
|
1098
|
+
.logo-slot[data-state="broken"] {
|
|
1099
|
+
border: 1px dashed var(--border);
|
|
1100
|
+
}
|
|
1101
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1102
|
+
.logo-slot .logo-mark,
|
|
1103
|
+
.logo-slot img {
|
|
1104
|
+
transition: none;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
469
1107
|
.badge {
|
|
470
1108
|
flex-shrink: 0;
|
|
471
1109
|
font-size: 0.72rem;
|
|
@@ -589,6 +1227,96 @@ const brandRest =
|
|
|
589
1227
|
color: var(--kind-rule);
|
|
590
1228
|
border-color: var(--kind-rule);
|
|
591
1229
|
}
|
|
1230
|
+
.sr-only {
|
|
1231
|
+
position: absolute;
|
|
1232
|
+
width: 1px;
|
|
1233
|
+
height: 1px;
|
|
1234
|
+
margin: -1px;
|
|
1235
|
+
padding: 0;
|
|
1236
|
+
border: 0;
|
|
1237
|
+
overflow: hidden;
|
|
1238
|
+
clip-path: inset(50%);
|
|
1239
|
+
white-space: nowrap;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/* Code blocks — Shiki's output, wearing this site's box. Shiki owns
|
|
1243
|
+
the token colours and nothing else: the frame, the spacing and the
|
|
1244
|
+
corners are the same ones every other panel uses. */
|
|
1245
|
+
.code-block {
|
|
1246
|
+
position: relative;
|
|
1247
|
+
margin: 1rem 0;
|
|
1248
|
+
}
|
|
1249
|
+
.astro-code {
|
|
1250
|
+
margin: 0;
|
|
1251
|
+
padding: 0.9rem 1rem;
|
|
1252
|
+
border: 1px solid var(--border);
|
|
1253
|
+
border-radius: 8px;
|
|
1254
|
+
overflow-x: auto;
|
|
1255
|
+
font-size: 0.85rem;
|
|
1256
|
+
line-height: 1.5;
|
|
1257
|
+
tab-size: 2;
|
|
1258
|
+
/* Shiki colours tokens; the surface is ours. Its own background is
|
|
1259
|
+
the theme's editor chrome — GitHub's blue-grey `#24292e` — which
|
|
1260
|
+
next to this page's purple-tinted card reads as a green cast.
|
|
1261
|
+
`!important` because Shiki writes it as an inline style. */
|
|
1262
|
+
background-color: var(--chip-bg) !important;
|
|
1263
|
+
}
|
|
1264
|
+
/* The dark half of the pair. Shiki writes the light colour inline and
|
|
1265
|
+
the dark one as a custom property on the same token, so switching
|
|
1266
|
+
themes is this rule rather than a second render. `!important` for
|
|
1267
|
+
the same reason: the light value is inline. */
|
|
1268
|
+
:root[data-theme="dark"] .astro-code,
|
|
1269
|
+
:root[data-theme="dark"] .astro-code span {
|
|
1270
|
+
color: var(--shiki-dark) !important;
|
|
1271
|
+
font-style: var(--shiki-dark-font-style) !important;
|
|
1272
|
+
font-weight: var(--shiki-dark-font-weight) !important;
|
|
1273
|
+
text-decoration: var(--shiki-dark-text-decoration) !important;
|
|
1274
|
+
}
|
|
1275
|
+
/* A token can carry its own highlight background; nothing else may. */
|
|
1276
|
+
.astro-code span:not([style*="background"]) {
|
|
1277
|
+
background-color: transparent !important;
|
|
1278
|
+
}
|
|
1279
|
+
/* Reveals on hover or keyboard focus, so it is not a permanent
|
|
1280
|
+
smudge over the first line of every block. */
|
|
1281
|
+
.code-copy {
|
|
1282
|
+
position: absolute;
|
|
1283
|
+
top: 0.45rem;
|
|
1284
|
+
right: 0.45rem;
|
|
1285
|
+
display: inline-flex;
|
|
1286
|
+
align-items: center;
|
|
1287
|
+
justify-content: center;
|
|
1288
|
+
width: 1.9rem;
|
|
1289
|
+
height: 1.9rem;
|
|
1290
|
+
padding: 0;
|
|
1291
|
+
border: 1px solid var(--border);
|
|
1292
|
+
border-radius: 6px;
|
|
1293
|
+
background: var(--card);
|
|
1294
|
+
color: var(--muted);
|
|
1295
|
+
cursor: pointer;
|
|
1296
|
+
opacity: 0;
|
|
1297
|
+
transition:
|
|
1298
|
+
opacity 120ms ease,
|
|
1299
|
+
color 120ms ease,
|
|
1300
|
+
border-color 120ms ease;
|
|
1301
|
+
}
|
|
1302
|
+
.code-block:hover .code-copy,
|
|
1303
|
+
.code-copy:focus-visible {
|
|
1304
|
+
opacity: 1;
|
|
1305
|
+
}
|
|
1306
|
+
.code-copy:hover {
|
|
1307
|
+
color: var(--accent);
|
|
1308
|
+
border-color: var(--accent);
|
|
1309
|
+
}
|
|
1310
|
+
.code-copy.copied {
|
|
1311
|
+
color: var(--kind-rule);
|
|
1312
|
+
border-color: var(--kind-rule);
|
|
1313
|
+
opacity: 1;
|
|
1314
|
+
}
|
|
1315
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1316
|
+
.code-copy {
|
|
1317
|
+
transition: none;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
592
1320
|
/* card actions: icon-only square buttons. Scoped to .copy-group so the
|
|
593
1321
|
hero install buttons (also .copy, elsewhere) keep their text label. */
|
|
594
1322
|
.copy-group .copy {
|