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_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 = """
+Search works and authors
+
+Evidence type
+All evidence
+Full-work measurements & comparisons
+Illustrative excerpts Provenance-only pages
+Clear filters
+
+No evidence pages match these filters. Clear the filters to see everything.
+ """
+
+
+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(); }
+})();
+"""
diff --git a/scriptorium/site_renderer.py b/scriptorium/site_renderer.py
index 5fb40c39..0be7e09e 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''
f'{_escape(item["author"])} · {_escape(item["work_title"])}
'
@@ -530,15 +532,26 @@ 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 '
+ 'Choose a work. Open a measured report and inspect exact units. '
+ 'Check its limits. An inferred measurement is not verified FantLab parity. '
+ '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' '
+ + 'Published work and evidence '
+ + FILTER_CONTROLS
+ + f''
+ + ' '
)
return _page("Scriptorium", "assets/site.css", body)
@@ -565,7 +578,7 @@ def _render_metric_work(item: Mapping[str, Any]) -> str:
)
revision_url = _escape(item["revision_url"])
body = (
- '← All published analyses '
+ '← All published analyses '
f'{_escape(item["publication_status"])}
'
f'{_escape(item["title"])} '
f'{_escape(item["author"])} · {_escape(item["work_title"])}
'
@@ -594,7 +607,7 @@ def _render_provenance_work(item: Mapping[str, Any]) -> str:
revision_url = _escape(item["revision_url"])
fantlab_url = _escape(item["fantlab_url"])
body = (
- '← All published analyses '
+ '← All published analyses '
f'{_escape(item["publication_status"])} · provenance-only
'
f'{_escape(item["title"])} '
f'{_escape(item["author"])} · {_escape(item["work_title"])}
'
@@ -646,33 +659,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: #69786f;
+ --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; }
+}
"""
@@ -774,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
diff --git a/tests/test_site_frozen_diagnostic.py b/tests/test_site_frozen_diagnostic.py
index e701cd34..4a11c49c 100644
--- a/tests/test_site_frozen_diagnostic.py
+++ b/tests/test_site_frozen_diagnostic.py
@@ -121,10 +121,22 @@ 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("