From d8c4eb1570ab4a8081c5f719ef308ef0d5105a47 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:35:03 +0200 Subject: [PATCH 1/2] fix: theme command wrap, sidebar, tabs, badges Commands no longer allow a cut anywhere inside a word on phones. Sidebar top-level links match their children at 14 px. OS tabs keep one row and scroll. Badge links in file cards keep a 24 px target. Card hashes are 13 px at every width. Get your files tables share their column widths, part links stay on one line. Short hash cells and the Reason column get their styles. --- tests/test_site_r2_css.py | 8 +++-- website/src/styles/retrobios.css | 58 ++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/tests/test_site_r2_css.py b/tests/test_site_r2_css.py index 6389c40d..b2a968a7 100644 --- a/tests/test_site_r2_css.py +++ b/tests/test_site_r2_css.py @@ -156,9 +156,11 @@ class ThemeStylesheet(unittest.TestCase): permalink = next(b for s, b in _blocks(self.css) if s == ".rb-permalink") self.assertIn("min-height: 24px", permalink) - # 1.10 - def test_os_tabs_wrap(self): - self.assertIn("flex-wrap: wrap", _rules(self.css, "starlight-tabs [role='tablist']")[0]) + # 1.10, then round 3 item 1.3: one row that scrolls instead of two rows + def test_os_tabs_stay_reachable(self): + tablist = _rules(self.css, "starlight-tabs [role='tablist']")[0] + self.assertIn("flex-wrap: nowrap", tablist) + self.assertIn("overflow-x: auto", tablist) self.assertIn("white-space: nowrap", _rules(self.css, "starlight-tabs [role='tab']")[0]) # 1.11 diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css index b6061b8e..0a47981d 100644 --- a/website/src/styles/retrobios.css +++ b/website/src/styles/retrobios.css @@ -333,6 +333,32 @@ .sl-markdown-content table { font-variant-numeric: tabular-nums; } .sl-markdown-content table th { font-weight: 600; } .sl-markdown-content table[role='region']:focus-visible { outline: 2px solid var(--rb-link); outline-offset: 2px; } +/* A hash in a table cell is its first 12 characters, the full value in the + title: one line, dotted to say there is more on hover. */ +.sl-markdown-content td code[title] { + white-space: nowrap; + text-decoration: underline dotted; + text-underline-offset: 0.2em; + cursor: help; +} +@media (min-width: 50rem) { + /* The reason a file is listed reads as a sentence, not a word per line. */ + .sl-markdown-content :is(td.rb-reason, td[data-label='Reason']) { min-width: 12rem; } + + /* Get your files: every group table shares one set of columns. */ + .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) { + display: table; + table-layout: fixed; + width: 100%; + } + .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) th:nth-child(1) { width: 18%; } + .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) th:nth-child(2) { width: 20%; } + .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) th:nth-child(3) { width: 40%; } + .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) th:nth-child(4) { width: 22%; } + .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) td { overflow-wrap: break-word; } +} +/* A ZIP part link or button never breaks across two lines. */ +.sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) td:last-child :is(a, .rb-button) { white-space: nowrap; } /* Key and value cards: the empty header row is not drawn */ .rb-meta-card thead, @@ -416,7 +442,7 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: 54rem; } .sl-markdown-content :is(.rb-sys-file, .rb-file-entry) li { margin: 0.1rem 0; } .rb-hash { font-family: var(--sl-font-mono); - font-size: 0.78rem; + font-size: 0.8125rem; color: var(--rb-text-secondary); overflow-wrap: anywhere; } @@ -448,7 +474,15 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: 54rem; } } .sl-markdown-content .rb-facts :is(dt, dd) { margin: 0; padding: 0; } .sl-markdown-content .rb-facts dt { font-size: 0.8rem; font-weight: 400; color: var(--rb-text-secondary); } -.sl-markdown-content .rb-facts dd { min-width: 0; overflow-wrap: break-word; } +.sl-markdown-content .rb-facts dd { min-width: 0; overflow-wrap: break-word; line-height: 1.8; } +/* A badge link in a card keeps a 24 px target, rows 4 px apart (WCAG 2.5.8). */ +.sl-markdown-content .rb-facts :is(.rb-badge:has(> a), a.rb-badge) { + display: inline-flex; + align-items: center; + min-height: 24px; + margin-block: 2px; + vertical-align: middle; +} .sl-markdown-content .rb-facts code { overflow-wrap: anywhere; } @media (max-width: 30rem) { .sl-markdown-content dl.rb-facts { grid-template-columns: minmax(0, 1fr); gap: 0; } @@ -636,22 +670,24 @@ site-search .pagefind-ui__result-tags { display: none; } } .sl-menu-button:has(~ :popover-open) { box-shadow: none; background: var(--sl-color-accent-low); } -/* Sidebar: small grey group labels, regular links, the current page on a - tinted background with no bar or rule beside it. */ +/* Sidebar: small grey group labels above 14 px links, top-level links as + large as the children, the current page on a tinted background with no + bar or rule beside it. */ #starlight__sidebar .top-level summary .large { font-size: var(--sl-text-xs); font-weight: 600; color: var(--rb-text-secondary); } -#starlight__sidebar .top-level a { font-weight: 400; } +#starlight__sidebar .top-level a { font-size: var(--sl-text-sm); font-weight: 400; } #starlight__sidebar .top-level a[aria-current='page'] { background: var(--sl-color-accent-low); color: var(--sl-color-accent-high); font-weight: 600; } -/* OS tabs wrap on a narrow phone instead of hiding the last one. */ -starlight-tabs [role='tablist'] { flex-wrap: wrap; } +/* OS tabs stay on one row and scroll sideways on a narrow phone. */ +starlight-tabs [role='tablist'] { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; } +starlight-tabs [role='tablist']::-webkit-scrollbar { display: none; } starlight-tabs [role='tab'] { white-space: nowrap; } /* A page without "On this page" keeps the column, and the search box above @@ -713,10 +749,10 @@ starlight-tabs [role='tab'] { white-space: nowrap; } color: var(--rb-text-secondary); } - /* On a phone every command wraps: the end of a one-liner is never off-screen. */ - .sl-markdown-content .expressive-code pre > code { white-space: pre-wrap; overflow-wrap: anywhere; } - .sl-markdown-content :is(.rb-sys-file, .rb-file-entry) ul, - .rb-hash { font-size: 0.75rem; } + /* On a phone every command wraps: the end of a one-liner is never + off-screen. A word is cut only when it cannot fit on a line. */ + .sl-markdown-content .expressive-code pre > code { white-space: pre-wrap; } + .sl-markdown-content :is(.rb-sys-file, .rb-file-entry) ul { font-size: 0.75rem; } } @media (max-width: 50rem) { From 77d27228e5165b078f12501441ff15ffd61303ef Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:35:07 +0200 Subject: [PATCH 2/2] chore: test the round 3 theme stylesheet --- tests/test_site_r3_css.py | 94 +++++++++++++++++++++++++++++++++++++++ wiki/testing-guide.md | 2 +- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/test_site_r3_css.py diff --git a/tests/test_site_r3_css.py b/tests/test_site_r3_css.py new file mode 100644 index 00000000..b78dde38 --- /dev/null +++ b/tests/test_site_r3_css.py @@ -0,0 +1,94 @@ +"""Round 3 theme: the stylesheet fixes of package 1, read from its source. + +Each test fails on the stylesheet as it was before the fix: commands allowed +to break inside any word on a phone, a sidebar whose top links outsize its +group labels, OS tabs on two rows, badge links cut to a few pixels by the +next row, 12 px hashes, Get your files tables each sizing their own columns. +""" +from __future__ import annotations + +import unittest + +from tests.test_site_r2_css import CSS_PATH, PAGE_BG, _blocks, _ratio, _rules, _strip_comments, _theme_tokens + +PACKS = "table.rb-packs" + + +class ThemeStylesheetRound3(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.css = _strip_comments(CSS_PATH.read_text(encoding="utf-8")) + dark = _theme_tokens(cls.css, ":root") + light = {**dark, **_theme_tokens(cls.css, ":root[data-theme='light']")} + cls.tokens = {"dark": dark, "light": light} + + # 1.1 + def test_commands_never_break_inside_a_word(self): + blocks = _rules(self.css, ".expressive-code pre > code") + self.assertTrue(blocks) + for body in blocks: + self.assertIn("white-space: pre-wrap", body, "commands keep wrapping") + self.assertNotIn("overflow-wrap: anywhere", body) + for selector in (".rb-facts code", ".rb-hash code", ".rb-meta-card td code"): + with self.subTest(selector=selector): + self.assertIn("overflow-wrap: anywhere", _rules(self.css, selector)[0]) + + # 1.2 + def test_sidebar_labels_above_links(self): + label = _rules(self.css, ".top-level summary .large")[0] + # 13 px grey semibold, no uppercase: the house style refuses small caps. + for declaration in ("font-size: var(--sl-text-xs)", "font-weight: 600", "color: var(--rb-text-secondary)"): + with self.subTest(declaration=declaration): + self.assertIn(declaration, label) + links = next(b for s, b in _blocks(self.css) if s == "#starlight__sidebar .top-level a") + self.assertIn("font-size: var(--sl-text-sm)", links) + self.assertIn("font-weight: 400", links) + + # 1.3 + def test_os_tabs_on_one_row(self): + tablist = next(b for s, b in _blocks(self.css) if s == "starlight-tabs [role='tablist']") + self.assertIn("flex-wrap: nowrap", tablist) + self.assertIn("overflow-x: auto", tablist) + self.assertIn("scrollbar-width: none", tablist) + self.assertIn("display: none", _rules(self.css, "starlight-tabs [role='tablist']::-webkit-scrollbar")[0]) + + # 1.4 + def test_badge_links_keep_a_24px_target(self): + dd = next(b for s, b in _blocks(self.css) if s == ".sl-markdown-content .rb-facts dd") + self.assertIn("line-height: 1.8", dd) + badge = _rules(self.css, ".rb-facts :is(.rb-badge:has(> a), a.rb-badge)")[0] + for declaration in ("display: inline-flex", "align-items: center", "min-height: 24px", "margin-block: 2px"): + with self.subTest(declaration=declaration): + self.assertIn(declaration, badge) + + # 1.5 + def test_hashes_are_13px_at_every_width(self): + hashes = [b for s, b in _blocks(self.css) if ".rb-hash" in s and "font-size" in b] + self.assertEqual(len(hashes), 1, "a second size for .rb-hash, on phones") + self.assertIn("font-size: 0.8125rem", hashes[0]) + short = _rules(self.css, "td code[title]")[0] + self.assertIn("white-space: nowrap", short) + self.assertIn("underline dotted", short) + self.assertIn("min-width: 12rem", _rules(self.css, "td.rb-reason")[0]) + + # 1.6 + def test_pack_tables_share_their_columns(self): + base = next(b for s, b in _blocks(self.css) if PACKS in s and "table-layout" in b) + self.assertIn("table-layout: fixed", base) + self.assertIn("width: 100%", base) + widths = {s.rsplit("th:nth-child(", 1)[1].split(")")[0]: b for s, b in _blocks(self.css) + if PACKS in s and "th:nth-child(" in s} + self.assertEqual({k: v.strip() for k, v in widths.items()}, + {"1": "width: 18%;", "2": "width: 20%;", "3": "width: 40%;", "4": "width: 22%;"}) + parts = next(b for s, b in _blocks(self.css) if PACKS in s and s.endswith(":is(a, .rb-button)")) + self.assertIn("white-space: nowrap", parts) + + def test_secondary_text_on_the_sidebar_meets_aa(self): + for theme in ("light", "dark"): + with self.subTest(theme=theme): + fg = self.tokens[theme]["--rb-text-secondary"].strip() + self.assertGreaterEqual(_ratio(fg, PAGE_BG[theme]), 4.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index e4deae8a..a470adaa 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -54,7 +54,7 @@ Every module in `tests/`, by the area it guards. | Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection` | | Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | -| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_css`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture