From 199d1c005367912ea6c3ea36e538073045764c60 Mon Sep 17 00:00:00 2001 From: andy-zen-dev Date: Thu, 22 Oct 2026 10:51:34 +0300 Subject: [PATCH 1/7] SITE: polish literary Reader, accessible evidence navigation and print --- .../2026-10-22-reader-literary-theme.md | 7 + scriptorium/site_renderer.py | 233 +++++++++++++++--- tests/test_site_literary_theme.py | 66 +++++ 3 files changed, 278 insertions(+), 28 deletions(-) create mode 100644 project/CHANGELOG.d/2026-10-22-reader-literary-theme.md create mode 100644 tests/test_site_literary_theme.py diff --git a/project/CHANGELOG.d/2026-10-22-reader-literary-theme.md b/project/CHANGELOG.d/2026-10-22-reader-literary-theme.md new file mode 100644 index 00000000..e88250af --- /dev/null +++ b/project/CHANGELOG.d/2026-10-22-reader-literary-theme.md @@ -0,0 +1,7 @@ +# SCRIP-SITE-012 — readable Reader home and evidence pages (2026-10-22) + +- Replaced default system styling with a cohesive paper-and-ink literary theme, responsive cards, deliberate hierarchy and readable numeric tables. +- Added a real three-step reading guide, direct local CLI documentation link, empty-site explanation and labelled detail-page breadcrumbs. +- Added conspicuous keyboard focus, 44px navigation targets, high-contrast and dark-mode palettes, reduced-motion and print-only layouts. +- Kept all six published evidence paths, data boundaries, derived metrics, site atomic publication and M2=0/5 unchanged. +- No deployment or independent visual acceptance is claimed by this source change. diff --git a/scriptorium/site_renderer.py b/scriptorium/site_renderer.py index 5fb40c39..14f6da9a 100644 --- a/scriptorium/site_renderer.py +++ b/scriptorium/site_renderer.py @@ -530,15 +530,24 @@ def _render_index(entries: Sequence[Mapping[str, Any]]) -> str: + _escape(comparison["slug"]) + '/">Compare Grin and Verne — two full-length works →

' ) + cards_html = "".join(cards) if cards else '

No public analyses are available in this build.

' body = ( - '

Scriptorium · literary analysis

' + '

Scriptorium · literary analysis

' '

Explore the language behind a story.

' '

Inspect vocabulary, sentence rhythm, dialogue and punctuation in real literary texts. ' 'Each result shows its measurements, source identity and limits.

' + action + + '
' + '

Read the evidence, not a mystery score

' + '
  1. Choose a work. Open a measured report and inspect exact units.
  2. ' + '
  3. Check its limits. An inferred measurement is not verified FantLab parity.
  4. ' + '
  5. Compare carefully. Different source editions and translations remain distinct.
' + '

Have your own manuscript? Analyze a text privately with the Reader CLI → No upload is required.

' + '
' + '

Full-work metrics, illustrative excerpts and provenance-only pages are labelled separately. ' 'FantLab source identity remains unknown for the frozen full-work report: diagnostic evidence is not parity.

' - + f'
{"".join(cards)}
' + + '

Published work and evidence

' + + f'
{cards_html}
' ) return _page("Scriptorium", "assets/site.css", body) @@ -565,7 +574,7 @@ def _render_metric_work(item: Mapping[str, Any]) -> str: ) revision_url = _escape(item["revision_url"]) body = ( - '
' + '
' f'

{_escape(item["publication_status"])}

' f'

{_escape(item["title"])}

' f'

{_escape(item["author"])} · {_escape(item["work_title"])}

' @@ -594,7 +603,7 @@ def _render_provenance_work(item: Mapping[str, Any]) -> str: revision_url = _escape(item["revision_url"]) fantlab_url = _escape(item["fantlab_url"]) body = ( - '
' + '
' f'

{_escape(item["publication_status"])} · provenance-only

' f'

{_escape(item["title"])}

' f'

{_escape(item["author"])} · {_escape(item["work_title"])}

' @@ -646,33 +655,201 @@ def _render_work(item: Mapping[str, Any]) -> str: _SITE_CSS = """\ -:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; } +:root { + color-scheme: light; + --paper: #fbf8f0; + --surface: #fffdf8; + --ink: #202d2a; + --muted: #465954; + --accent: #13554e; + --accent-soft: #e6efeb; + --line: #9fada3; + --focus: #a54b18; + font-family: "Iowan Old Style", Georgia, "Times New Roman", serif; + font-size: 100%; + line-height: 1.65; +} * { box-sizing: border-box; } -body { margin: 0; background: Canvas; color: CanvasText; } -main, footer { max-width: 72rem; margin: auto; padding: clamp(1rem, 4vw, 2rem); } -a { color: inherit; } -.skip-link:not(:focus) { position: absolute; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; } -.skip-link:focus { position: absolute; z-index: 10; margin: .5rem; padding: .75rem; background: Canvas; } -*:focus-visible { outline: 3px solid currentColor; outline-offset: 3px; } -nav a, .card a, .actions a { display: inline-block; min-height: 44px; padding-block: .6rem; } -h1, .card h2 { font-family: Georgia, 'Times New Roman', serif; } -.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-block: 1.5rem; } -.primary-action { border: 2px solid currentColor; border-radius: .5rem; padding-inline: 1rem; font-weight: 700; } -.notice { margin-block: 1.5rem; } -h1 { font-size: clamp(2rem, 5vw, 4rem); line-height: 1.05; } -.eyebrow { font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } -.lede { font-size: 1.15rem; max-width: 52rem; } -.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); gap: 1rem; } -.card, .notice { border: 1px solid currentColor; border-radius: .75rem; padding: 1rem; } -.table-wrap { overflow-x: auto; } -table { border-collapse: collapse; width: 100%; } -th, td { border-bottom: 1px solid currentColor; padding: .65rem; text-align: left; vertical-align: top; } -dt { font-weight: 700; margin-top: 1rem; } +html { scroll-behavior: smooth; } +body { + min-width: 0; + margin: 0; + color: var(--ink); + background: radial-gradient(circle at 90% 0%, #efe8d9 0, transparent 32rem), var(--paper); +} +main, footer { max-width: 78rem; margin-inline: auto; padding: clamp(1rem, 4vw, 3rem); } +main { min-height: 70vh; } +footer { border-top: 1px solid var(--line); color: var(--muted); font: .9rem/1.6 system-ui, sans-serif; } +a { + color: var(--accent); + text-underline-offset: .2em; + text-decoration-thickness: .075em; +} +a:hover { text-decoration-thickness: .15em; } +a:focus-visible, :where(button, input, select, textarea, [tabindex]):focus-visible { + outline: 3px solid var(--focus); + outline-offset: 4px; +} +.skip-link { + position: fixed; + top: .5rem; + left: .5rem; + z-index: 100; + padding: .75rem 1.1rem; + min-height: 44px; + background: var(--surface); + border: 2px solid var(--focus); + border-radius: .25rem; + font: 600 .95rem/1.4 system-ui, sans-serif; +} +.skip-link:not(:focus) { + clip-path: inset(50%); + width: 1px; + height: 1px; + overflow: hidden; + padding: 0; + border: 0; +} +.hero { + padding-block: clamp(2rem, 6vw, 6rem); + border-bottom: 2px solid var(--ink); +} +.hero h1 { max-width: 17ch; } +h1, h2, h3 { font-weight: 650; text-wrap: balance; } +h1 { font-size: clamp(2.2rem, 6.5vw, 5.4rem); letter-spacing: -.035em; line-height: 1.08; margin-block: .65rem 1.25rem; } +h2 { font-size: clamp(1.5rem, 2.8vw, 2.1rem); line-height: 1.2; } +h3 { font-size: 1.18rem; line-height: 1.3; } +.eyebrow { + color: var(--muted); + font: 750 .78rem/1.5 system-ui, sans-serif; + letter-spacing: .14em; + text-transform: uppercase; +} +.lede { max-width: 61ch; font-size: clamp(1.1rem, 2vw, 1.35rem); line-height: 1.7; } +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem 1rem; margin-block: 1.7rem; } +.actions a, nav a, .card h2 a { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: .55rem .2rem; +} +.primary-action { + padding: .65rem 1.2rem !important; + border: 2px solid var(--accent); + border-radius: .3rem; + background: var(--accent); + color: #ffffff; + font: 700 1rem/1.45 system-ui, sans-serif; + text-decoration: none; +} +.primary-action:hover { background: var(--ink); border-color: var(--ink); color: #fff; } +.reading-guide { padding-block: clamp(1rem, 3vw, 2rem); } +.reading-guide ol { + padding: 0; + margin-block: 1.1rem; + list-style: none; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + counter-reset: read-step; +} +.reading-guide li { + counter-increment: read-step; + min-width: 0; + padding: 1.2rem; + border-left: 3px solid var(--accent); + background: var(--surface); +} +.reading-guide li::before { + content: "0" counter(read-step); + display: block; + color: var(--muted); + font: 750 .85rem/1.5 system-ui, sans-serif; + letter-spacing: .05em; + margin-bottom: .6rem; +} +.reading-guide p, .notice { max-width: 80ch; } +.notice { + padding: clamp(1rem, 2.2vw, 1.5rem); + margin-block: 2rem; + border: 1px solid var(--line); + border-left: 4px solid var(--accent); + border-radius: .2rem; + background: var(--accent-soft); +} +.section-title { margin-top: 2.5rem; } +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); + gap: 1rem; + margin-block: 1.5rem 3rem; +} +.card { + min-width: 0; + border: 1px solid var(--line); + border-radius: .35rem; + background: var(--surface); + padding: clamp(1rem, 2vw, 1.6rem); + box-shadow: 0 5px 0 #aebeb180; +} +.card h2 { font-size: clamp(1.3rem, 2vw, 1.65rem); } +.card p { overflow-wrap: anywhere; } +.empty-state { padding: 2rem; border: 1px dashed var(--line); } +.table-wrap { max-width: 100%; overflow-x: auto; padding-bottom: .3rem; } +.table-wrap:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; } +table { border-collapse: collapse; width: 100%; font: .94rem/1.5 system-ui, sans-serif; } +caption { text-align: left; font-weight: 700; padding-block: .7rem; } +th, td { border-bottom: 1px solid var(--line); padding: .75rem .65rem; vertical-align: top; text-align: left; } +thead { background: var(--accent-soft); } +tbody tr:nth-child(even) { background: var(--surface); } +dt { margin-top: 1rem; font-weight: 700; } dd { margin-left: 0; overflow-wrap: anywhere; } -.status { font-weight: 700; } +.status { font-weight: 750; } code { overflow-wrap: anywhere; } -footer { opacity: .7; font-size: .9rem; } -@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } +@media (max-width: 720px) { + .reading-guide ol { grid-template-columns: 1fr; gap: .6rem; } + .hero { padding-block: 1.5rem 2rem; } + .actions { align-items: stretch; } + .actions a { justify-content: center; } + th, td { padding: .6rem .45rem; } +} +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --paper: #14201d; + --surface: #1e2b27; + --ink: #f2ebdc; + --muted: #d1d8cf; + --accent: #a0e2d2; + --accent-soft: #20372f; + --line: #73887e; + --focus: #f2b273; + } + body { background: var(--paper); } + .primary-action { color: #14201d; } + .primary-action:hover { background: #f2ebdc; border-color: #f2ebdc; color: #14201d; } + .card { box-shadow: 0 5px 0 #00000035; } +} +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { animation: none !important; transition: none !important; } +} +@media (forced-colors: active) { + .card, .notice, .primary-action, .reading-guide li { border: 1px solid CanvasText; } + .primary-action { color: ButtonText; background: ButtonFace; } +} +@media print { + :root { color-scheme: light; --paper: #fff; --surface: #fff; --ink: #000; --muted: #222; --accent: #000; --accent-soft: #fff; --line: #666; } + body { background: #fff !important; color: #000 !important; } + main, footer { max-width: none; min-height: 0; padding: .3cm; } + .hero { padding: .4cm 0; } + .skip-link, .actions, nav { display: none !important; } + .grid, .reading-guide ol { display: block; } + .card, .reading-guide li, .notice { box-shadow: none; break-inside: avoid; margin-bottom: .4cm; background: #fff; } + .table-wrap { overflow: visible; } + a { color: #000; text-decoration: underline; } + @page { margin: 1.5cm; } +} """ diff --git a/tests/test_site_literary_theme.py b/tests/test_site_literary_theme.py new file mode 100644 index 00000000..2c4e424e --- /dev/null +++ b/tests/test_site_literary_theme.py @@ -0,0 +1,66 @@ +"""Reader site appearance must preserve working, accessible HTML and honest evidence.""" +import unittest + +from scriptorium.site_renderer import _SITE_CSS, _page, _render_index + + +class LiteraryReaderThemeTests(unittest.TestCase): + def test_real_navigation_and_source_honesty(self): + entry = { + "render_mode": "frozen_diagnostic", + "slug": "a-frozen-work", + "entry_id": "a-work-v1", + "title": "", + "author": "A. Writer", + "work_title": "A Novel", + "publication_status": "diagnostic", + "compatibility_claim": "inferred", + "benchmark_admissibility": "diagnostic_only", + "corpus_admissibility": "not_admissible", + "metric_count": 28, + } + page = _render_index([entry]) + self.assertIn('href="works/a-frozen-work/"', page) + self.assertIn('href="#works"', page) + self.assertIn('<Read & Learn>', page) + self.assertNotIn('', page) + self.assertIn('diagnostic evidence is not parity', page) + self.assertIn('inferred measurement is not verified FantLab parity', page) + self.assertIn('Reader CLI', page) + self.assertEqual(page.count('

'), 1) + self.assertEqual(page.count('id="works"'), 1) + self.assertIn('
', page) + self.assertIn('aria-labelledby="reading-guide-title"', page) + + def test_empty_state_is_explained_without_fake_results(self): + page = _render_index([]) + self.assertIn('No public analyses are available in this build.', page) + self.assertNotIn('Explore the 28-metric', page) + self.assertIn('Published work and evidence', page) + + def test_keyboard_mobile_print_and_no_remote_font(self): + page = _page("Reader", "assets/site.css", '
') + self.assertIn('name="viewport"', page) + self.assertIn('href="#main-content"', page) + self.assertIn('Reader', page) + for required in ( + '.skip-link:not(:focus)', + ':focus-visible', + 'min-height: 44px', + 'overflow-x: auto', + '@media (max-width: 720px)', + '@media (prefers-color-scheme: dark)', + '@media (prefers-reduced-motion: reduce)', + '@media (forced-colors: active)', + '@media print', + 'break-inside: avoid', + 'grid-template-columns: 1fr', + ): + with self.subTest(required=required): + self.assertIn(required, _SITE_CSS) + self.assertNotIn('@import', _SITE_CSS) + self.assertNotIn('url(', _SITE_CSS) + + +if __name__ == "__main__": + unittest.main() From ef79a8a87495ed68cb22027693b89328eff6eeec Mon Sep 17 00:00:00 2001 From: andy-zen-dev Date: Fri, 23 Oct 2026 08:08:13 +0300 Subject: [PATCH 2/7] feat(site): add private-free reader gallery search and evidence filters --- scriptorium/site_gallery_filter.py | 113 +++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 scriptorium/site_gallery_filter.py diff --git a/scriptorium/site_gallery_filter.py b/scriptorium/site_gallery_filter.py new file mode 100644 index 00000000..f54fb606 --- /dev/null +++ b/scriptorium/site_gallery_filter.py @@ -0,0 +1,113 @@ +"""Progressive, local-only filters for the Scriptorium evidence gallery. + +Navigation categories are not evidence, parity or corpus-admissibility claims. +""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +def evidence_kind(item: Mapping[str, Any]) -> str: + """Classify presentation modes, not scientific evidence status.""" + mode = item.get("render_mode") + if mode in {"frozen_diagnostic", "comparison"}: + return "full_work" + if mode == "provenance": + return "provenance" + if item.get("publication_status") == "illustrative_excerpt": + return "excerpt" + return "other" + + +FILTER_CONTROLS = """""" + + +FILTER_CSS = """ +/* Without JavaScript every allow-listed evidence card stays visible. */ +.evidence-filters { margin: 1.5rem 0 0; padding: 1.2rem; background: var(--accent-soft); + border: 1px solid var(--line); border-radius: .5rem; } +.evidence-filters[hidden], .card[hidden], .empty-state[hidden] { display: none !important; } +.filter-fields { display: flex; flex-wrap: wrap; gap: .9rem; align-items: end; } +.filter-fields label { display: grid; gap: .35rem; min-width: min(100%, 15rem); + flex: 1 1 15rem; font: 700 .88rem/1.45 system-ui, sans-serif; } +.filter-fields input, .filter-fields select, .filter-fields button { width: 100%; min-height: 44px; + border: 1px solid var(--line); border-radius: .35rem; padding: .6rem .7rem; + color: var(--ink); background: var(--surface); font: 400 1rem/1.4 system-ui,sans-serif; } +.filter-fields button { width: auto; font-weight: 700; cursor: pointer; } +.filter-fields button:disabled { cursor: default; opacity: .65; } +.filter-fields input:focus-visible, .filter-fields select:focus-visible, +.filter-fields button:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; } +.filter-count { margin: .8rem 0 0; font: .91rem/1.55 system-ui,sans-serif; color: var(--muted); } +.empty-state { background: var(--surface); padding: 1rem; margin-top: 1rem; } +@media(max-width:650px) { .filter-fields button { width: 100%; } } +""" + + +FILTER_JS = r"""/* No uploads, network requests, cookies, tracking or stored queries. */ +(function () { + 'use strict'; + function ready() { + const panel = document.getElementById('evidence-filters'); + const input = document.getElementById('filter-query'); + const kind = document.getElementById('filter-kind'); + const reset = document.getElementById('filter-reset'); + const status = document.getElementById('filter-count'); + const empty = document.getElementById('filter-empty'); + const work = document.getElementById('works'); + if (!panel || !input || !kind || !reset || !status || !empty || !work) return; + const cards = Array.from(work.querySelectorAll('article.card[data-evidence-kind]')); + if (cards.length === 0) return; + // Restrict search to visible title/author fields, not scientific warning text. + function searchKey(value) { + return String(value || '').normalize('NFC').toLocaleLowerCase('ru-RU').replace(/ё/g, 'е'); + } + const inventory = cards.map(card => { + const heading = card.querySelector('h2'); + const byline = card.querySelector('h2 + p'); + return { + card, + titleKnown: Boolean(heading && heading.textContent.trim()), + key: searchKey(`${heading ? heading.textContent : ''} ${byline ? byline.textContent : ''}`), + }; + }); + function refresh() { + const query = searchKey(input.value.trim()); + const chosen = kind.value; + let visible = 0; + inventory.forEach(({card, titleKnown, key}) => { + const match = (!query || !titleKnown || key.includes(query)) && + (chosen === 'all' || card.dataset.evidenceKind === chosen); + card.hidden = !match; + if (match) visible++; + }); + status.textContent = `${visible} of ${cards.length} evidence pages shown`; + empty.hidden = visible !== 0; + reset.disabled = query === '' && chosen === 'all'; + } + input.addEventListener('input', refresh); + kind.addEventListener('change', refresh); + reset.addEventListener('click', () => { + input.value = ''; + kind.value = 'all'; + refresh(); + input.focus(); + }); + panel.hidden = false; + refresh(); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', ready, { once: true }); + } else { ready(); } +})(); +""" From e34f28f2cf113fae41abfac30c54defb01885e5b Mon Sep 17 00:00:00 2001 From: andy-zen-dev Date: Fri, 23 Oct 2026 08:08:53 +0300 Subject: [PATCH 3/7] feat(site): connect evidence filters to canonical site renderer --- scriptorium/site_renderer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scriptorium/site_renderer.py b/scriptorium/site_renderer.py index 14f6da9a..24cb9c7d 100644 --- a/scriptorium/site_renderer.py +++ b/scriptorium/site_renderer.py @@ -19,6 +19,7 @@ from .site_publish import staged_site from .publication import validate_compatibility_claim +from .site_gallery_filter import evidence_kind, FILTER_CONTROLS, FILTER_JS, FILTER_CSS from . import site_frozen_diagnostic from . import site_work_comparison @@ -504,8 +505,9 @@ def _render_index(entries: Sequence[Mapping[str, Any]]) -> str: "provenance": " · provenance-only", "frozen_diagnostic": " · full-work metrics", }.get(item.get("render_mode"), "") + kind = evidence_kind(item) cards.append( - '
' + f'
' f'

{_escape(item["publication_status"])}{_escape(mode_note)}

' f'

{_escape(item["title"])}

' f'

{_escape(item["author"])} · {_escape(item["work_title"])}

' @@ -547,7 +549,9 @@ def _render_index(entries: Sequence[Mapping[str, Any]]) -> str: + '

Full-work metrics, illustrative excerpts and provenance-only pages are labelled separately. ' 'FantLab source identity remains unknown for the frozen full-work report: diagnostic evidence is not parity.

' + '

Published work and evidence

' - + f'
{cards_html}

' + + FILTER_CONTROLS + + f'
{cards_html}
' + + '
' ) return _page("Scriptorium", "assets/site.css", body) @@ -951,7 +955,8 @@ def build_site( try: with staged_site(root, destination) as output: (output / "assets").mkdir() - (output / "assets" / "site.css").write_text(_SITE_CSS, encoding="utf-8", newline="\n") + (output / "assets" / "site.css").write_text(_SITE_CSS + FILTER_CSS, encoding="utf-8", newline="\n") + (output / "assets" / "evidence-filter.js").write_text(FILTER_JS, encoding="utf-8", newline="\n") (output / "index.html").write_text(index_html, encoding="utf-8", newline="\n") for slug, page_html in work_pages.items(): work_dir = output / "works" / slug From cf4d0e5094bfa2c9c03fbcc8a8f7d69fe7cccbd3 Mon Sep 17 00:00:00 2001 From: andy-zen-dev Date: Fri, 23 Oct 2026 08:09:22 +0300 Subject: [PATCH 4/7] test(site): cover evidence filter accessibility, privacy and search scope --- tests/test_site_gallery_filter.py | 65 +++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/test_site_gallery_filter.py diff --git a/tests/test_site_gallery_filter.py b/tests/test_site_gallery_filter.py new file mode 100644 index 00000000..0cea22bc --- /dev/null +++ b/tests/test_site_gallery_filter.py @@ -0,0 +1,65 @@ +"""Unit and privacy regressions for the #283 evidence-gallery filters.""" +import unittest + +from scriptorium.site_gallery_filter import ( + evidence_kind, FILTER_JS, FILTER_CSS, FILTER_CONTROLS, +) + + +class GalleryFilterContractTests(unittest.TestCase): + def test_mode_classification_is_presentation_only(self): + cases = { + "frozen_diagnostic": "full_work", + "comparison": "full_work", + "provenance": "provenance", + } + for mode, expected in cases.items(): + with self.subTest(mode=mode): + self.assertEqual( + evidence_kind({"render_mode": mode, "publication_status": "diagnostic"}), + expected, + ) + self.assertEqual(evidence_kind({"publication_status": "illustrative_excerpt"}), "excerpt") + self.assertEqual(evidence_kind({"publication_status": "diagnostic"}), "other") + self.assertEqual( + evidence_kind({"render_mode": "unknown", "publication_status": "diagnostic"}), + "other", + ) + + def test_zero_js_fallback_keeps_all_cards_visible(self): + self.assertIn('id="evidence-filters"', FILTER_CONTROLS) + self.assertIn("hidden>", FILTER_CONTROLS) + self.assertIn("panel.hidden = false", FILTER_JS) + self.assertIn("if (cards.length === 0) return", FILTER_JS) + self.assertIn(".card[hidden]", FILTER_CSS) + + def test_accessible_controls_and_feedback(self): + for term in ('type="search"', " Date: Fri, 23 Oct 2026 08:11:38 +0300 Subject: [PATCH 5/7] test(site): allow only the reviewed offline gallery script on index --- tests/test_site_frozen_diagnostic.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_site_frozen_diagnostic.py b/tests/test_site_frozen_diagnostic.py index e701cd34..817f3e1a 100644 --- a/tests/test_site_frozen_diagnostic.py +++ b/tests/test_site_frozen_diagnostic.py @@ -121,7 +121,15 @@ def test_every_page_has_targetable_main_and_no_source_selection_prose(self): self.assertIn("href='#main'", rendered) else: self.assertIn('id="main-content" tabindex="-1"', rendered) - self.assertNotIn(" Date: Fri, 23 Oct 2026 08:13:11 +0300 Subject: [PATCH 6/7] test(site): preserve keyboard focus checks for scoped literary selectors --- tests/test_site_frozen_diagnostic.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_site_frozen_diagnostic.py b/tests/test_site_frozen_diagnostic.py index 817f3e1a..4a11c49c 100644 --- a/tests/test_site_frozen_diagnostic.py +++ b/tests/test_site_frozen_diagnostic.py @@ -132,7 +132,11 @@ def test_every_page_has_targetable_main_and_no_source_selection_prose(self): self.assertNotIn(" Date: Fri, 23 Oct 2026 09:53:30 +0300 Subject: [PATCH 7/7] fix(site): give light-mode filter controls accessible borders Raise light-theme border contrast above 3:1 against both adjoining surfaces, with a regression for non-text control boundaries. Refs #283. --- scriptorium/site_renderer.py | 2 +- tests/test_site_literary_theme.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/scriptorium/site_renderer.py b/scriptorium/site_renderer.py index 24cb9c7d..0be7e09e 100644 --- a/scriptorium/site_renderer.py +++ b/scriptorium/site_renderer.py @@ -667,7 +667,7 @@ def _render_work(item: Mapping[str, Any]) -> str: --muted: #465954; --accent: #13554e; --accent-soft: #e6efeb; - --line: #9fada3; + --line: #69786f; --focus: #a54b18; font-family: "Iowan Old Style", Georgia, "Times New Roman", serif; font-size: 100%; diff --git a/tests/test_site_literary_theme.py b/tests/test_site_literary_theme.py index 2c4e424e..76699e58 100644 --- a/tests/test_site_literary_theme.py +++ b/tests/test_site_literary_theme.py @@ -1,4 +1,5 @@ """Reader site appearance must preserve working, accessible HTML and honest evidence.""" +import re import unittest from scriptorium.site_renderer import _SITE_CSS, _page, _render_index @@ -62,5 +63,27 @@ def test_keyboard_mobile_print_and_no_remote_font(self): self.assertNotIn('url(', _SITE_CSS) + def test_light_control_borders_have_non_text_contrast(self): + # WCAG 1.4.11: visible input/select/button boundaries need >=3:1. + # Both the input interior and the panel behind its border matter. + def color(name): + match = re.search(rf"^ --{name}: (#[0-9a-f]{{6}});$", _SITE_CSS, re.MULTILINE) + self.assertIsNotNone(match, name) + return match.group(1) + + def luminance(value): + channels = [int(value[pos:pos + 2], 16) / 255 for pos in (1, 3, 5)] + linear = [n / 12.92 if n <= 0.04045 else ((n + 0.055) / 1.055) ** 2.4 + for n in channels] + return sum(weight * n for weight, n in zip((0.2126, 0.7152, 0.0722), linear)) + + line = luminance(color("line")) + for background in ("surface", "accent-soft"): + with self.subTest(background=background): + other = luminance(color(background)) + ratio = (max(line, other) + 0.05) / (min(line, other) + 0.05) + self.assertGreaterEqual(ratio, 3.0, f"light control boundary vs {background}: {ratio:.2f}:1") + + if __name__ == "__main__": unittest.main()