chore: merge main

This commit is contained in:
Abdessamad Derraz committed 2026-10-27 22:05:47 +01:00
commit dd64434122
782 files changed
+13985 -1777

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
{
"platforms/batocera-acorn-bbc-micro.md": "platforms/batocera.md#acorn-bbc-micro",
"platforms/batocera-apple-ii.md": "platforms/batocera.md#apple-ii",
"platforms/batocera-apple-macintosh-ii.md": "platforms/batocera.md#apple-macintosh-ii",
"platforms/batocera-archimedes.md": "platforms/batocera.md#archimedes",
"platforms/batocera-atari-st.md": "platforms/batocera.md#atari-st",
"platforms/batocera-commodore-amiga.md": "platforms/batocera.md#commodore-amiga",
"platforms/batocera-elektronika-bk.md": "platforms/batocera.md#elektronika-bk",
"platforms/batocera-microsoft-msx.md": "platforms/batocera.md#microsoft-msx",
"platforms/batocera-nintendo-ds.md": "platforms/batocera.md#nintendo-ds",
"platforms/batocera-sega-dreamcast-arcade.md": "platforms/batocera.md#sega-dreamcast-arcade",
"platforms/batocera-sony-playstation.md": "platforms/batocera.md#sony-playstation",
"platforms/emuelec-microsoft-msx.md": "platforms/emuelec.md#microsoft-msx",
"platforms/knulli-acorn-bbc-micro.md": "platforms/knulli.md#acorn-bbc-micro",
"platforms/knulli-apple-macintosh-ii.md": "platforms/knulli.md#apple-macintosh-ii",
"platforms/knulli-archimedes.md": "platforms/knulli.md#archimedes",
"platforms/knulli-atari-st.md": "platforms/knulli.md#atari-st",
"platforms/knulli-commodore-amiga.md": "platforms/knulli.md#commodore-amiga",
"platforms/knulli-sega-dreamcast-arcade.md": "platforms/knulli.md#sega-dreamcast-arcade",
"platforms/lakka-arcade.md": "platforms/lakka.md#arcade",
"platforms/lakka-commodore-amiga.md": "platforms/lakka.md#commodore-amiga",
"platforms/lakka-nec-pc-98.md": "platforms/lakka.md#nec-pc-98",
"platforms/lakka-nintendo-ds.md": "platforms/lakka.md#nintendo-ds",
"platforms/lakka-nintendo-gamecube.md": "platforms/lakka.md#nintendo-gamecube",
"platforms/lakka-nintendo-sgb.md": "platforms/lakka.md#nintendo-sgb",
"platforms/lakka-nintendo-snes.md": "platforms/lakka.md#nintendo-snes",
"platforms/lakka-scummvm.md": "platforms/lakka.md#scummvm",
"platforms/lakka-sega-dreamcast-arcade.md": "platforms/lakka.md#sega-dreamcast-arcade",
"platforms/lakka-sega-saturn.md": "platforms/lakka.md#sega-saturn",
"platforms/lakka-sinclair-zx-spectrum.md": "platforms/lakka.md#sinclair-zx-spectrum",
"platforms/lakka-sony-playstation-2.md": "platforms/lakka.md#sony-playstation-2",
"platforms/lakka-sony-playstation.md": "platforms/lakka.md#sony-playstation",
"platforms/recalbox-atari-st.md": "platforms/recalbox.md#atari-st",
"platforms/recalbox-commodore-amiga.md": "platforms/recalbox.md#commodore-amiga",
"platforms/recalbox-microsoft-msx.md": "platforms/recalbox.md#microsoft-msx",
"platforms/retroarch-arcade.md": "platforms/retroarch.md#arcade",
"platforms/retroarch-commodore-amiga.md": "platforms/retroarch.md#commodore-amiga",
"platforms/retroarch-nec-pc-engine.md": "platforms/retroarch.md#nec-pc-engine",
"platforms/retroarch-nintendo-ds.md": "platforms/retroarch.md#nintendo-ds",
"platforms/retroarch-nintendo-gamecube.md": "platforms/retroarch.md#nintendo-gamecube",
"platforms/retroarch-nintendo-sgb.md": "platforms/retroarch.md#nintendo-sgb",
"platforms/retroarch-nintendo-snes.md": "platforms/retroarch.md#nintendo-snes",
"platforms/retroarch-scummvm.md": "platforms/retroarch.md#scummvm",
"platforms/retroarch-sega-dreamcast-arcade.md": "platforms/retroarch.md#sega-dreamcast-arcade",
"platforms/retroarch-sega-saturn.md": "platforms/retroarch.md#sega-saturn",
"platforms/retroarch-sinclair-zx-spectrum.md": "platforms/retroarch.md#sinclair-zx-spectrum",
"platforms/retroarch-sony-playstation-2.md": "platforms/retroarch.md#sony-playstation-2",
"platforms/retroarch-sony-playstation.md": "platforms/retroarch.md#sony-playstation",
"platforms/retrobat-atari-st.md": "platforms/retrobat.md#atari-st",
"platforms/retrobat-commodore-amiga.md": "platforms/retrobat.md#commodore-amiga",
"platforms/retrobat-microsoft-msx.md": "platforms/retrobat.md#microsoft-msx",
"platforms/retrobat-nec-pc-engine.md": "platforms/retrobat.md#nec-pc-engine",
"platforms/retrobat-nintendo-ds.md": "platforms/retrobat.md#nintendo-ds",
"platforms/retrobat-sega-saturn.md": "platforms/retrobat.md#sega-saturn",
"platforms/retrodeck-arcade-2.md": "platforms/retrodeck.md#arcade",
"platforms/retrodeck-arcade-3.md": "platforms/retrodeck.md#arcade",
"platforms/retrodeck-arcade-4.md": "platforms/retrodeck.md#arcade",
"platforms/retrodeck-arcade.md": "platforms/retrodeck.md#arcade",
"platforms/retrodeck-atari-400-800.md": "platforms/retrodeck.md#atari-400-800",
"platforms/retrodeck-microsoft-msx.md": "platforms/retrodeck.md#microsoft-msx",
"platforms/retrodeck-nintendo-snes.md": "platforms/retrodeck.md#nintendo-snes",
"platforms/retrodeck-sega-saturn.md": "platforms/retrodeck.md#sega-saturn",
"platforms/retrodeck-sinclair-zx-spectrum.md": "platforms/retrodeck.md#sinclair-zx-spectrum",
"platforms/retrodeck-sony-playstation-2.md": "platforms/retrodeck.md#sony-playstation-2",
"platforms/retrodeck-sony-playstation.md": "platforms/retrodeck.md#sony-playstation",
"platforms/retropie-arcade.md": "platforms/retropie.md#arcade",
"platforms/retropie-commodore-amiga.md": "platforms/retropie.md#commodore-amiga",
"platforms/retropie-nec-pc-98.md": "platforms/retropie.md#nec-pc-98",
"platforms/retropie-nec-pc-engine.md": "platforms/retropie.md#nec-pc-engine",
"platforms/retropie-nintendo-ds.md": "platforms/retropie.md#nintendo-ds",
"platforms/retropie-nintendo-gamecube.md": "platforms/retropie.md#nintendo-gamecube",
"platforms/retropie-nintendo-sgb.md": "platforms/retropie.md#nintendo-sgb",
"platforms/retropie-nintendo-snes.md": "platforms/retropie.md#nintendo-snes",
"platforms/retropie-scummvm.md": "platforms/retropie.md#scummvm",
"platforms/retropie-sega-dreamcast-arcade.md": "platforms/retropie.md#sega-dreamcast-arcade",
"platforms/retropie-sega-saturn.md": "platforms/retropie.md#sega-saturn",
"platforms/retropie-sinclair-zx-spectrum.md": "platforms/retropie.md#sinclair-zx-spectrum",
"platforms/retropie-snk-neogeo-cd.md": "platforms/retropie.md#snk-neogeo-cd",
"platforms/retropie-sony-playstation-2.md": "platforms/retropie.md#sony-playstation-2",
"platforms/retropie-sony-playstation.md": "platforms/retropie.md#sony-playstation",
"platforms/romm-sony-playstation-2.md": "platforms/romm.md#sony-playstation-2"
}
+280 -3
View File
@@ -1,7 +1,8 @@
// Progressive enhancement of the generated pages: table captions, filters
// and keyboard scrolling, the hash lookup on files/ (a pasted hash or a file
// hashed on this device), moved cards, a hash typed in the search, glossary
// terms on a tap, the OS tab of the visitor's system, the current page in
// terms on a tap, the OS tab of the visitor's system, the setup picked in an
// install box, copy buttons on hashes, the current page in
// the sidebar, and the 404 page's hints.
// Which tables stack on a phone is decided when the page is built
// (src/plugins/rehype-stack-tables.mjs), so the layout is right on first paint.
@@ -87,9 +88,19 @@ const setScrollable = (table: HTMLTableElement, overflows: boolean) => {
// The filter box above a long table is written when the page is built
// (src/plugins/rehype-stack-tables.mjs), so the table never moves down once
// drawn. Here it only filters.
const filterTable = (table: HTMLTableElement) => {
// A table folded in a closed block can have its box above the block
// (the provenance lists): typing opens the block on the rows it keeps.
const filterControls = (table: HTMLTableElement) => {
const controls = table.previousElementSibling;
if (!controls?.matches('.rb-table-filter')) return;
if (controls?.matches('.rb-table-filter')) return controls;
const block = table.closest('details');
return block?.previousElementSibling?.matches('.rb-table-filter') ? block.previousElementSibling : null;
};
const filterTable = (table: HTMLTableElement) => {
const controls = filterControls(table);
if (!controls) return;
const block = controls.nextElementSibling instanceof HTMLDetailsElement ? controls.nextElementSibling : null;
const input = controls.querySelector('input');
const count = controls.querySelector('.rb-table-filter-count');
if (!input || !count || input.dataset.rbWired) return;
@@ -100,6 +111,7 @@ const filterTable = (table: HTMLTableElement) => {
input.addEventListener('input', () => {
searchable ??= rows.map((row) => ({ row, text: row.textContent!.normalize('NFKD').toLocaleLowerCase() }));
const query = input.value.trim().normalize('NFKD').toLocaleLowerCase();
if (block && query) block.open = true;
let visible = 0;
for (const entry of searchable) {
entry.row.hidden = !!query && !entry.text.includes(query);
@@ -171,6 +183,9 @@ const enhanceTables = () => {
});
if (document.readyState === 'complete') start();
else addEventListener('load', start, { once: true });
// A box above a closed block is typed in before its table is ever near
// the screen: it is wired now.
for (const table of tables) if (table.closest('details:not([open])') && filterControls(table)) filterTable(table);
let timer = 0;
addEventListener('resize', () => {
clearTimeout(timer);
@@ -743,6 +758,18 @@ const followMovedCard = () => {
if (match && !target) location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot()));
};
// A link to a card folded in a table row (emulators with many entries)
// opens its row. Chrome does it on its own. Where the browser left the
// target hidden, the same address is followed again once the row is open,
// so the browser brings the card into view as for any link.
const openTargetBlock = () => {
const id = decodeURIComponent(location.hash.slice(1));
const block = id ? document.getElementById(id)?.closest('details') : null;
if (!block || block.open) return;
block.open = true;
location.replace(location.hash);
};
// The site search holds no hashes. A hash typed in it gets "Look up this
// hash" as its first result, and Enter opens the lookup. The pages Pagefind
// finds for the same characters (a file name that holds them, a word that
@@ -931,6 +958,251 @@ const glossaryTerms = () => {
document.addEventListener('click', close);
};
// Copy buttons. A system card's hash cell (dd.rb-hash) gets one: in its
// fold, each hash copies itself, and the short SHA1 shown open copies the
// whole value, the fold's first hash. An emulator card's hash and each
// command of an install box's list carry data-copy: the value to copy, or
// empty when the text shown is the value. The buttons are added where the
// reader sees the values, and when a fold opens for the ones inside it, so a
// page of 250 closed folds holds none of them.
// A platform row's button carries its own data-copy: it is not a value to
// give a button.
const COPYABLE = '[data-copy]:not(button), .rb-sys-file dd.rb-hash';
// What the button copies, in words: the label of its grid row, else the
// words before the value ("MD5: ", ", SHA1 ", "RetroArch: "), else the
// label its list item opens on.
const copyLabel = (value: HTMLElement) => {
const term = (value.tagName === 'DD' ? value : value.closest('dd'))?.previousElementSibling;
if (term?.tagName === 'DT') return term.textContent?.trim() ?? '';
const before = (value.previousSibling?.textContent ?? '').replace(/[:\s]+$/, '').split(',').pop()?.trim();
if (before) return before;
return /^\s*([^:]+):/.exec(value.closest('li')?.textContent ?? '')?.[1]?.trim() ?? '';
};
// The value's own text, without the button a cell holds.
const shownText = (value: Element) =>
[...value.childNodes].filter((node) => !(node instanceof HTMLButtonElement)).map((node) => node.textContent).join('').trim();
const copyText = (value: HTMLElement) => {
const own = value.dataset.copy;
// An MDX page writes an empty attribute as "true".
if (own && own !== 'true') return own;
if (value.matches('dd.rb-hash') && !value.closest('details')) {
const whole = value.closest('.rb-sys-file')?.querySelector('details dd.rb-hash');
if (whole) return shownText(whole);
}
return shownText(value);
};
const addCopyButtons = (root: ParentNode) => {
for (const value of root.querySelectorAll<HTMLElement>(COPYABLE)) {
if (value.hasAttribute('data-copy-ready') || value.closest('details:not([open])')) continue;
value.setAttribute('data-copy-ready', '');
const button = document.createElement('button');
button.type = 'button';
button.className = 'rb-copy';
button.textContent = 'Copy';
// The word that replaces "Copy" is read out.
button.setAttribute('aria-live', 'polite');
const label = copyLabel(value);
button.setAttribute('aria-label', label ? `Copy ${label}` : 'Copy');
// A grid cell holds its button, a value elsewhere is followed by it.
if (value.tagName === 'DD') value.append(button);
else value.after(button);
}
};
const copyButtons = () => {
if (!document.querySelector(`${COPYABLE}, button.rb-copy[data-copy]`)) return;
addCopyButtons(document);
document.addEventListener('toggle', (event) => {
const fold = event.target as HTMLDetailsElement;
if (fold.open) addCopyButtons(fold);
}, true);
document.addEventListener('click', async (event) => {
const button = (event.target as Element).closest?.<HTMLButtonElement>('button.rb-copy');
if (!button) return;
// A platform row's button shows a short hash and copies the whole one
// from data-copy: its text stays, a mark shows for two seconds.
const own = button.dataset.copy;
if (own && own !== 'true') {
try {
await navigator.clipboard.writeText(own);
let status = document.querySelector<HTMLElement>('#rb-copy-status');
if (!status) {
status = document.createElement('p');
status.id = 'rb-copy-status';
status.className = 'rb-visually-hidden';
status.setAttribute('role', 'status');
document.body.append(status);
}
status.textContent = `Copied ${own}`;
button.dataset.copied = '';
window.setTimeout(() => delete button.dataset.copied, 2000);
} catch {
// No clipboard: the full value is in the row's details.
}
return;
}
const value = button.parentElement?.matches(COPYABLE)
? button.parentElement
: (button.previousElementSibling as HTMLElement | null);
if (!value?.matches(COPYABLE)) return;
try {
await navigator.clipboard.writeText(copyText(value));
button.textContent = 'Copied';
} catch {
// No clipboard (an insecure page, a refused permission): the value
// is selected, for Ctrl+C.
const range = document.createRange();
range.selectNodeContents(value.tagName === 'DD' && value.firstChild ? value.firstChild : value);
getSelection()?.removeAllRanges();
getSelection()?.addRange(range);
button.textContent = 'Selected';
}
window.setTimeout(() => { button.textContent = 'Copy'; }, 1500);
});
};
// A system's install box: one command, for the setup picked in its list,
// under the site's OS tabs. The box is built with the first setup's command;
// the choice rewrites the options of each command and its copy text, and is
// remembered for the next box and the next page. Without this script the
// picker stays hidden and the list of every command shows instead.
const SETUP_KEY = 'rb-setup';
const savedSetup = (): string | null => {
try {
return localStorage.getItem(SETUP_KEY);
} catch {
// Storage refused (a private window, blocked site data): no saved setup.
return null;
}
};
// The colours a flag and a value take in the highlighted sh command, read
// from the box's own first command so a rewritten one looks the same.
type ArgStyles = { flag: string; value: string; space: string };
const argStyles = (block: Element): ArgStyles | null => {
const leaves = [...block.querySelectorAll<HTMLElement>('pre[data-language="sh"] .ec-line .code span')].filter((span) => !span.children.length);
const at = leaves.findIndex((leaf) => leaf.textContent === '--platform');
if (at < 0) return null;
const value = leaves.slice(at + 1).find((leaf) => leaf.textContent?.trim());
const space = leaves.find((leaf) => leaf.textContent === ' ');
return { flag: leaves[at].getAttribute('style') ?? '', value: value?.getAttribute('style') ?? '', space: space?.getAttribute('style') ?? '' };
};
// Rewrites the options of a highlighted command, from " --platform": the
// installer's own part keeps its colours, the options take *styles*.
const setCommandArgs = (frame: Element, args: string, styles: ArgStyles | null) => {
const line = frame.querySelector<HTMLElement>('.ec-line .code');
if (!line) return;
const text = line.textContent ?? '';
const start = text.indexOf(' --platform ');
if (start < 0) return;
const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT);
let seen = 0;
const range = document.createRange();
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const length = node.textContent?.length ?? 0;
if (seen + length > start) {
range.setStart(node, start - seen);
break;
}
seen += length;
}
range.setEndAfter(line.lastChild!);
range.deleteContents();
for (const part of ` ${args}`.split(/(\s+)/)) {
if (!part) continue;
const span = document.createElement('span');
const space = /^\s+$/.test(part);
const style = space ? styles?.space : part.startsWith('-') ? styles?.flag : styles?.value;
if (style) span.setAttribute('style', style);
span.textContent = part;
// The theme colours a span that has no class: a word kept whole is
// wrapped, as the build wraps it (ec-unbroken-tokens.mjs).
if (space) {
line.append(span);
} else {
const whole = document.createElement('span');
whole.className = 'rb-nb';
whole.append(span);
line.append(whole);
}
}
frame.querySelector('.copy button')?.setAttribute('data-code', text.slice(0, start) + ` ${args}`);
};
const installPickers = () => {
const blocks = [...document.querySelectorAll<HTMLElement>('[data-rb-install]')];
if (!blocks.length) return;
const pick = (setup: string | null) => {
for (const block of blocks) {
const select = block.querySelector('select');
if (!select) continue;
if (setup && [...select.options].some((option) => option.value === setup)) select.value = setup;
const option = select.selectedOptions[0];
if (!option) continue;
const styles = (block as HTMLElement & { rbStyles?: ArgStyles | null }).rbStyles;
for (const frame of block.querySelectorAll('.expressive-code')) {
const shell = frame.querySelector('pre')?.dataset.language === 'powershell' ? 'ps' : 'sh';
setCommandArgs(frame, option.dataset[shell] ?? '', styles ?? null);
}
}
};
for (const block of blocks) {
(block as HTMLElement & { rbStyles?: ArgStyles | null }).rbStyles = argStyles(block);
block.hidden = false;
const list = block.nextElementSibling;
if (list instanceof HTMLDetailsElement && list.classList.contains('rb-install-all')) list.open = false;
block.querySelector('select')?.addEventListener('change', (event) => {
const setup = (event.target as HTMLSelectElement).value;
try {
localStorage.setItem(SETUP_KEY, setup);
} catch {
// Storage refused: the choice holds for this page only.
}
pick(setup);
});
}
pick(savedSetup());
};
// A platform's file list (div.rb-files) is rows of a details and its copy
// button. The filter box written above a long one hides both of a row.
const filterFiles = () => {
for (const list of document.querySelectorAll<HTMLElement>('.rb-table-filter + .rb-files')) {
const controls = list.previousElementSibling!;
const input = controls.querySelector('input');
const count = controls.querySelector('.rb-table-filter-count');
if (!input || !count) continue;
const rows = [...list.querySelectorAll<HTMLElement>(':scope > details.rb-file')];
let searchable: { row: HTMLElement; text: string }[] | null = null;
input.addEventListener('input', () => {
searchable ??= rows.map((row) => ({
row,
text: `${row.querySelector('summary')?.textContent ?? ''} ${row.nextElementSibling?.matches('button.rb-copy') ? row.nextElementSibling.textContent : ''}`
.normalize('NFKD')
.toLocaleLowerCase(),
}));
const query = input.value.trim().normalize('NFKD').toLocaleLowerCase();
let visible = 0;
for (const { row, text } of searchable) {
row.hidden = !!query && !text.includes(query);
const button = row.nextElementSibling;
if (button?.matches('button.rb-copy')) (button as HTMLElement).hidden = row.hidden;
if (!row.hidden) visible += 1;
}
count.textContent = query
? `${visible.toLocaleString('en')} of ${rows.length.toLocaleString('en')} rows`
: `${rows.length.toLocaleString('en')} rows`;
});
}
};
// The 404 page: a search button, and the list a moved page belongs to.
const notFound = () => {
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => {
@@ -948,10 +1220,15 @@ const notFound = () => {
resolveFileAddress();
followMovedCard();
openTargetBlock();
addEventListener('hashchange', openTargetBlock);
enhanceTables();
breakPaths();
hashSearch();
glossaryTerms();
copyButtons();
filterFiles();
osTabs();
installPickers();
showCurrentPage();
notFound();
+174
View File
@@ -17,6 +17,9 @@
.rb-bar inline coverage bar, width from --v
.rb-details-* collapsible block, neutral unless caution or danger
dl.rb-facts the facts of a file card, label and value side by side
.rb-install-all the list of every command in a system's install box
.rb-copy the copy button site.ts adds after a hash
.rb-install-setup the setup list of a system's install box
abbr.rb-term a glossary term, its definition on hover or tap
*/
@@ -530,6 +533,88 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width
}
:is(.rb-sys-file, .rb-file-entry):has(> .rb-file-title:target) { background: var(--rb-surface); }
/* Entries as table rows (scripts/siteentries.py): the name takes the room
and its card opens under it. On a phone the row stacks like any table
with a hash column, the name on the full width. */
body:has(table.rb-entries) { --sl-content-width: var(--rb-grid-width); }
/* A row the filter leaves out stays out on a phone, where a stacked row is
a block: `display: block` outweighed the hidden attribute, and every
stacked table showed all its rows under "11 of 1,037 rows". */
.sl-markdown-content table[data-rb-stack] tr[hidden] { display: none; }
@media (min-width: 50rem) {
.sl-markdown-content table.rb-wanted td.rb-hash { min-width: 10rem; }
}
.sl-markdown-content .rb-entries-wrap { container-type: inline-size; }
.sl-markdown-content table.rb-entries { width: 100%; }
@media (min-width: 50rem) {
.sl-markdown-content table.rb-entries { display: table; }
.sl-markdown-content table.rb-entries td:first-child { width: 100%; }
/* An open card takes the table's width, not its column's: the other
cells of its row keep to one line, so the room under them is free. */
.sl-markdown-content table.rb-entries tr:has(> td > details[open]) > td:not(:first-child) { white-space: nowrap; }
.sl-markdown-content details.rb-entry[open] { contain: inline-size; }
.sl-markdown-content details.rb-entry[open] > :not(summary) { width: 100cqi; }
}
.sl-markdown-content details.rb-entry { border: 0; padding: 0; }
.sl-markdown-content details.rb-entry > summary { min-height: 24px; font-weight: 400; }
.sl-markdown-content details.rb-entry > summary > .rb-file-title { display: inline; margin: 0; }
.sl-markdown-content details.rb-entry[open] > summary { margin-bottom: 0.4rem; }
.sl-markdown-content details.rb-entry ul { margin: 0.3rem 0 0; padding-left: 1.2rem; font-size: 0.85rem; }
.sl-markdown-content details.rb-entry li { margin: 0.1rem 0; }
.sl-markdown-content details.rb-entry:has(.rb-file-title:target) { background: var(--rb-surface); }
@media (max-width: 50rem) {
.sl-markdown-content table.rb-entries td:first-child { padding-left: 0.6rem; font-size: 1rem; }
.sl-markdown-content table.rb-entries td:first-child::before { content: none; }
/* "required for Data East DECO Cassette System" ran 12 px past a phone
beside its label: a long badge wraps in its cell. */
.sl-markdown-content table.rb-entries .rb-badge { white-space: normal; }
.sl-markdown-content details.rb-entry ul { font-size: 0.75rem; }
}
/* A fold of a file card (Hashes, the rest): no frame, a small summary in
the muted colour, a 24 px target (WCAG 2.5.8). */
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) { margin: 0.3rem 0 0; padding: 0; border: 0; font-size: 0.85rem; }
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary {
min-height: 24px;
line-height: 24px;
font-size: 0.8rem;
color: var(--rb-text-secondary);
cursor: pointer;
}
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary::before { background-color: var(--rb-text-secondary); }
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary:hover { color: var(--sl-color-white); }
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > :not(summary) { margin-top: 0.2rem; }
/* The copy button after a hash (site.ts): the Download button's frame,
smaller, a 24 px target that stays inside a 24 px line, so it never
covers the fold below it (WCAG 2.5.8). */
.rb-copy {
display: inline-flex;
align-items: center;
box-sizing: border-box;
height: 24px;
margin-left: 0.4rem;
padding: 0 0.5rem;
border: 1px solid var(--rb-border-strong);
border-radius: 4px;
background: transparent;
font: inherit;
font-family: var(--sl-font);
font-size: 0.75rem;
line-height: 1;
color: var(--rb-link);
vertical-align: top;
cursor: pointer;
}
.rb-copy:hover { background: var(--sl-color-accent-low); }
/* A system's install box: the setup list above the OS tabs, and the list
of every command, which wraps anywhere rather than scroll a phone. */
.sl-markdown-content .rb-install-all code { overflow-wrap: anywhere; }
.sl-markdown-content .rb-install-setup { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 0.9rem; }
/* Its select is styled as every other choice list on the site (the
arrow of --rb-select-arrow, below): a rule of its own hid the arrow. */
/* Collapsible blocks are quiet: a closed block must not outweigh the
command it explains. Colour is kept for caution and danger. */
.sl-markdown-content details.rb-details {
@@ -551,6 +636,95 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width
.sl-markdown-content details.rb-details > summary::before { background-color: var(--rb-text-secondary); }
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
/* A platform's file list (generate_site._platform_file_list), on the
pattern of the Tested block: each row a details whose summary is the
row (name, folder, status), its body the evidence across the whole
list. The short hash sits on its copy button (site.ts) beside the
summary, since a summary is itself a control. The details reaches under
the button column, so the row rule and an opened body run full width. */
.sl-markdown-content .rb-files {
--rb-hash-column: 6.75rem;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--rb-hash-column);
margin-top: 0.75rem;
border-top: 1px solid var(--rb-border-strong);
}
.sl-markdown-content .rb-files > * { margin: 0; }
.sl-markdown-content .rb-files > details.rb-file {
grid-column: 1;
margin-right: calc(-1 * var(--rb-hash-column));
padding: 0 0 0 1.75rem;
border: 0;
border-bottom: 1px solid var(--rb-border);
font-size: 0.8125rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.sl-markdown-content .rb-files > details.rb-file[open] { padding-bottom: 0.6rem; background: var(--rb-surface); }
.sl-markdown-content .rb-files > details.rb-file > summary {
display: grid;
grid-template-columns: 1.25rem minmax(0, 1fr) 9rem 6rem;
gap: 0.5rem;
align-items: center;
min-height: 2.25rem;
margin-left: -1.75rem;
padding: 0.3rem calc(var(--rb-hash-column) + 0.5rem) 0.3rem 0;
font: 0.8125rem/1.4 var(--sl-font-mono);
cursor: pointer;
}
.sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary { grid-template-columns: 1.25rem minmax(0, 1fr) 6rem; }
.sl-markdown-content .rb-files > details.rb-file > summary:hover { background: var(--rb-surface); }
.sl-markdown-content .rb-files > details.rb-file > summary::before { margin: 0; background-color: var(--rb-text-secondary); }
.sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 3; padding: 0; background: none; color: var(--rb-text-secondary); }
.sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: -2; justify-self: start; font-family: var(--sl-font); }
.sl-markdown-content .rb-files > button.rb-copy {
position: relative;
grid-column: 2;
align-self: start;
justify-self: start;
margin-top: 0.4rem;
}
.sl-markdown-content .rb-files > [hidden] { display: none; }
.sl-markdown-content .rb-files button.rb-copy {
display: inline-flex;
align-items: center;
gap: 0.3em;
min-height: 24px;
padding: 0 0.35em;
border: 1px solid var(--rb-border-strong);
border-radius: 3px;
background: var(--sl-color-bg);
color: var(--sl-color-white);
font: 0.8125rem/1.4 var(--sl-font-mono);
cursor: pointer;
white-space: nowrap;
}
.sl-markdown-content .rb-files button.rb-copy::after {
content: '';
width: 0.9em;
height: 0.9em;
background: var(--rb-text-secondary);
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M5 1h8a2 2 0 0 1 2 2v8h-2V3H5zM3 5h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v7h7V7z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.sl-markdown-content .rb-files button.rb-copy[data-copied]::after {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 11.2 2.8 8 1.4 9.4 6 14l8.6-8.6-1.4-1.4z'/%3E%3C/svg%3E");
background: var(--rb-ok-fg);
}
.sl-markdown-content .rb-files button.rb-copy:hover { border-color: var(--rb-link); }
@media (max-width: 50rem) {
/* On a phone the folder goes under the name, the status stays on the
first line. A row with no folder prints nothing for it. */
.sl-markdown-content .rb-files > details.rb-file > summary,
.sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary {
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
row-gap: 0.1rem;
}
.sl-markdown-content .rb-files > details.rb-file > summary::before { grid-row: 1; }
.sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 2; grid-row: 2; }
.sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: 3; grid-row: 1; }
}
/* Asides: a neutral box for notes and tips, no side stripe, no icon.
Caution and danger keep their colour, as a 1 px border all round. */
.sl-markdown-content .starlight-aside {