Merge pull request #31 from 12vectors/task/35-the-site-reads-on-a-phone

35 — Make the site read on a phone
This commit is contained in:
Ronald Ashri
2026-07-31 15:28:25 +02:00
committed by GitHub
6 changed files with 640 additions and 15 deletions
+28
View File
@@ -40,6 +40,34 @@ and a deploy is the same bytes from any machine.
directory. `tests/test_release_artifact.py` asserts that out loud rather
than leaving it to inference.
## Below the design's width
The docs design is drawn at a fixed 1180px. Everything narrower is the
stylesheet's business — three `max-width` steps, no script, and nothing
that takes effect at or above the width the design defines:
| Step | What goes | What replaces it |
| --- | --- | --- |
| 1080px | the contents gutter | "On this page" folds into a `<details>` strip under the title; tables become their own scrollers |
| 760px | the section sidebar | the same links open from a `<details>` menu under the masthead; the type scale steps down and every tappable thing reaches 44px |
| 480px | the desktop's padding | the masthead's spacer, so the row wraps; body text goes up a notch |
Two things follow from that and are worth knowing before editing
`templates/article.html`:
- **The two side columns are written twice** — once as the column, once
as the strip — and both are filled from the same `$sidebar` and `$toc`,
so the folded copy cannot say something the column does not.
- **The contents strip is a sibling between the `<h1>` and the body's
first paragraph**, even at widths where it is `display:none`. That is
why the lede rule in `static/site.css` names `.menu-contents + p` as
well as `h1 + p`.
`tests/test_site_responsive.py` holds all of it: that every query is a
`max-width` below the design, that the step hiding a column is the step
showing its strip, that a table scrolls inside itself rather than
widening the page, and that the site still ships no JavaScript.
## Where the site lives
| | |
+156 -9
View File
@@ -45,6 +45,11 @@
--radius:11px; --shadow:0 20px 50px -34px rgba(18,50,59,.55);
--shadow-ink:0 18px 40px -30px rgba(12,26,32,.9);
/* The smallest thing a finger is asked to hit. It is a token rather
than a number typed seven times because it is one decision, and the
narrow steps below are where it is spent. */
--tap:44px;
}
*{box-sizing:border-box}
@@ -182,7 +187,11 @@ a:hover{color:var(--text);text-decoration:underline}
margin:34px 0 10px;scroll-margin-top:20px;
}
.prose h3{font:600 var(--t-h3)/1.4 var(--sans);margin:26px 0 8px}
.prose h1 + p,.prose > p:first-child{
/* The lede is the body's first paragraph. The collapsed contents strip
sits between it and the h1 display:none at the design's width, but
still a sibling so the adjacency has to name it too, or the lede
would quietly become an ordinary paragraph on desktop. */
.prose h1 + p,.prose .menu-contents + p,.prose > p:first-child{
font-size:var(--t-lede);line-height:1.6;color:var(--muted);max-width:60ch;
}
.prose p{margin:0 0 16px;color:var(--muted);max-width:64ch;text-wrap:pretty}
@@ -197,6 +206,10 @@ a:hover{color:var(--text);text-decoration:underline}
border-radius:0 10px 10px 0;color:var(--text);
}
.prose blockquote p:last-child{margin:0}
/* No page slices an image today. When one does, it is the only element
in generated markdown that arrives with its own intrinsic width, and
a 1200px screenshot on a 390px screen would widen the page itself. */
.prose img{max-width:100%;height:auto}
.prose code{
font:var(--t-code)/1.5 var(--mono);
@@ -260,7 +273,7 @@ a:hover{color:var(--text);text-decoration:underline}
margin:0;max-width:44ch;font-size:var(--t-lede);line-height:1.62;
color:var(--muted);text-wrap:pretty;
}
.hero-actions{display:flex;gap:10px;align-items:center}
.hero-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.aside-note{font-size:11.5px;font-style:italic;color:var(--dim)}
.terminal{border-radius:12px;overflow:hidden;box-shadow:0 24px 46px -30px rgba(12,26,32,.85)}
@@ -343,19 +356,153 @@ a:hover{color:var(--text);text-decoration:underline}
letter-spacing:-.025em;text-wrap:balance;
}
/* ── narrow ── */
/* the columns a narrow screen folds away
The article is drawn with three columns and a phone has room for one.
Both of the ones it loses come back as a <details> the page can open:
the section nav as a menu under the masthead, the contents as a strip
under the title. Each is display:none until the step that hides its
column shows it, so at the design's width none of this renders and
because the strips are <details>, one menu that opens and closes cost
this site no script at all. */
.menu{display:none}
.menu-summary{
display:flex;align-items:center;gap:8px;min-height:var(--tap);
padding:9px 18px;cursor:pointer;list-style:none;
font:500 var(--t-ui)/1.4 var(--sans);color:var(--text);
background:var(--surface);border-bottom:1px solid var(--border-soft);
}
.menu-summary::-webkit-details-marker{display:none}
.menu-summary::after{content:"▾";margin-left:auto;font-size:11px;color:var(--dim)}
.menu[open] > .menu-summary::after{content:"▴"}
.menu-panel{
display:flex;flex-direction:column;gap:4px;
max-height:min(62vh,460px);overflow-y:auto;
padding:10px 14px 16px;background:var(--surface);
border-bottom:1px solid var(--border-soft);
}
.menu-panel .side-link,.menu-panel .toc-link{
display:flex;align-items:center;min-height:var(--tap);
}
.menu-panel .side-group{gap:2px}
/* The contents strip sits inside the prose, so it is a card rather than
a bar across the page and it says "On this page" once: the summary
is the label, and $toc's own label folds away underneath it. */
.menu-contents{
margin:0 0 22px;border:1px solid var(--border-soft);
border-radius:10px;overflow:hidden;
}
.menu-contents .menu-summary{
background:var(--canvas);border-bottom:0;padding:9px 14px;
}
.menu-contents[open] .menu-summary{border-bottom:1px solid var(--border-soft)}
.menu-contents .menu-panel{border-bottom:0;padding:8px 14px 12px}
.menu-contents .toc-label{display:none}
/* A body with no h2s has no contents: render_contents gives the strip
nothing, and a summary over an empty panel is worse than no strip. */
.menu-contents:not(:has(a)){display:none}
/* narrow
The design is drawn at 1180px and everything below this comment is
what happens under it. Three steps, each giving up what no longer
fits and handing back a way to reach it: 1080 the contents gutter,
760 the section sidebar and the top of the type scale, 480 the last
of the desktop's padding. Every query is a max-width at or below
1080, so at the design's own width the sheet above is the whole
stylesheet. */
@media (max-width:1080px){
.shell{grid-template-columns:220px minmax(0,1fr)}
.gutter{display:none}
.hero{grid-template-columns:1fr;gap:28px;padding:32px 24px}
.doors{grid-template-columns:repeat(2,1fr);padding:0 24px 28px}
.menu-contents{display:block}
.hero{grid-template-columns:minmax(0,1fr);gap:28px;padding:32px 24px}
.doors{grid-template-columns:repeat(2,minmax(0,1fr));padding:0 24px 28px}
.strip{padding:18px 24px}
/* Only a code block or a table may scroll sideways, so a token that
cannot break a url, a curl one-liner in running text breaks
rather than making the page wider than the screen. Fenced code is
untouched: white-space:pre forbids wrapping, which is why `pre`
carries overflow-x:auto instead. */
.prose :not(pre) > code{overflow-wrap:anywhere}
.prose h1,.prose h2,.prose h3{overflow-wrap:break-word}
/* A table is the one block that cannot reflow its columns are its
meaning so it becomes its own scroller. display:block makes the
table element the scroll container; the two edge shadows are
painted on the box (scroll) and covered by a patch of surface
painted on the content (local), so each one appears only while
there really is more that way. The cost is that a table narrower
than the column no longer stretches to fill it: the inner table box
an unblocked <table> generates sizes to its own content. */
.prose table{
display:block;width:100%;max-width:100%;overflow-x:auto;
background-image:
linear-gradient(to right,var(--surface),rgba(255,255,255,0)),
linear-gradient(to left,var(--surface),rgba(255,255,255,0)),
linear-gradient(to right,rgba(18,50,59,.20),rgba(18,50,59,0)),
linear-gradient(to left,rgba(18,50,59,.20),rgba(18,50,59,0));
background-position:0 0,100% 0,0 0,100% 0;
background-size:26px 100%,26px 100%,13px 100%,13px 100%;
background-repeat:no-repeat;
background-attachment:local,local,scroll,scroll;
}
}
@media (max-width:760px){
:root{
/* The design's 52px hero and 40px title are drawn for a 1180px
frame. The step comes down; the voice does not --display is
still Zilla Slab, here and at every width below. */
--t-hero:36px; --t-title:30px; --t-h2:21px; --t-h3:16.5px;
--t-lede:16.5px;
}
.shell{grid-template-columns:minmax(0,1fr)}
.side{display:none}
.page-article .prose{padding:24px 22px 36px}
.doors{grid-template-columns:1fr}
.hero h1{font-size:38px}
.masthead{padding:12px 18px;gap:12px}
.menu{display:block}
.page-article .prose{padding:24px 20px 36px}
.doors{grid-template-columns:minmax(0,1fr)}
.masthead{padding:12px 18px;gap:12px;flex-wrap:wrap}
.crumbs{flex-wrap:wrap}
.footer{flex-wrap:wrap;gap:16px 20px;padding:20px 18px}
/* A spacer holds one end of a row against the other. Once the row
wraps there are no two ends, and all it can still do is take a
line of its own so it goes, and the footer stacks. */
.footer .spacer{display:none}
.lost{padding:48px 20px 64px}
/* Tap targets: everything a finger has to hit is at least --tap tall.
Links inside running prose are the exception, because a line of
text cannot be 44px without stopping being a line of text. */
.wordmark,.nav-link,.button,.side-link,.toc-link,.strip-link,
.footer-repo{
display:inline-flex;align-items:center;min-height:var(--tap);
}
.button{justify-content:center}
.nav{gap:4px;flex-wrap:wrap}
.nav-link{padding:0 8px}
.side-link{padding:0 9px}
.toc-link{padding:0 0 0 9px}
}
@media (max-width:480px){
:root{
--t-hero:31px; --t-title:26px; --t-h2:19px;
/* Body text goes the other way: 15px is a desktop reading distance
and a phone is held closer to nothing. The lede keeps its notch
above it, or it stops being a lede. */
--t-body:16px; --t-lede:17.5px;
}
.topbar{padding:0 14px}
.masthead{padding:10px 14px;gap:8px 12px}
/* As in the footer: with the button no longer held at a far edge,
the wordmark, the nav and it share the row until they cannot, and
then wrap. */
.masthead .spacer{display:none}
.hero{padding:26px 16px}
.hero-actions{gap:8px}
.hero-actions .button{width:100%}
.doors{padding:0 16px 24px}
.strip{padding:16px}
.page-article .prose{padding:20px 16px 32px}
.menu-summary{padding:9px 14px}
.footer{padding:18px 14px}
.lost{padding:40px 16px 56px}
}
+29
View File
@@ -3,6 +3,13 @@
columns, the sidebar carrying the IA, the generated body in the
middle, contents and the two GitHub links in the gutter.
A phone has room for one of those columns, so the other two are
written twice: once as the column the design draws, and once as a
<details> strip that is display:none until the breakpoint which
hides its column. The strips carry the same $$sidebar and $$toc, so
there is one source for the links and no way for the folded copy to
say something the column does not.
Placeholders are string.Template's; a literal dollar in the markup
would have to be doubled. -->
<html lang="en">
@@ -37,6 +44,16 @@ $nav
<a class="button button-solid" href="$repo_url">GitHub ↗</a>
</header>
<!-- The section nav, folded. Shown from 760px down, where the .side
column below is gone. A <details> is the whole mechanism: one menu
that opens and closes needs no script, and this site has none. -->
<details class="menu">
<summary class="menu-summary">Documentation</summary>
<nav class="menu-panel">
$sidebar
</nav>
</details>
<div class="shell">
<aside class="side">
@@ -51,6 +68,18 @@ $sidebar
<article class="prose">
<div class="crumbs">$breadcrumb</div>
<h1>$title</h1>
<!-- "On this page", folded under the title. Shown from 1080px down,
where the gutter that normally carries it is gone. A page whose
body has no h2s gets an empty $$toc, and the stylesheet drops the
strip rather than offering a summary over nothing. It sits above
the lede on purpose: the stylesheet's lede adjacency names
.menu-contents + p for exactly this order. -->
<details class="menu menu-contents">
<summary class="menu-summary">On this page</summary>
<nav class="menu-panel">
$toc
</nav>
</details>
<!-- The one sentence an article authors. Everything below it is the
body placeholder: a heading slice of the file named in the
gutter. (Placeholders substitute inside comments too, so this
@@ -1,7 +1,11 @@
# 35 — Make the site read on a phone
<<<<<<<< HEAD:tasks/in-progress/35-the-site-reads-on-a-phone.md
**Status:** In Progress
========
**Status:** Review
**PR:** https://github.com/12vectors/bench/pull/31
>>>>>>>> origin/main:tasks/review/35-the-site-reads-on-a-phone.md
**Assignee:** istos
**Priority:** Medium — a public URL gets opened on phones whatever the
design was drawn at
+29 -6
View File
@@ -34,6 +34,18 @@ SIDE_HERE = re.compile(r'class="side-link side-here" href="([^"]+)"')
GUTTER_LINK = re.compile(r'class="gutter-link" href="([^"]+)"')
DOOR = re.compile(r'class="door[^"]*" href="([^"]+)"')
LEDE = re.compile(r'<p class="prose-lede">(.*?)</p>', re.S)
# Both rails are written twice — the column the design draws, and the
# folded <details> strip that replaces it below the breakpoint. So these
# assert every rendering, rather than assuming there is one.
CONTENTS_BLOCK = re.compile(
r'<div class="toc">(.*?)</div>|<nav class="menu-panel">(.*?)</nav>', re.S)
def contents_lists(html):
"""The anchors of each rendering of "On this page", in order. The
sidebar's own menu-panel carries no toc-links and drops out."""
found = [TOC_LINK.findall(a or b) for a, b in CONTENTS_BLOCK.findall(html)]
return [links for links in found if links]
class BuiltSite(unittest.TestCase):
@@ -123,11 +135,17 @@ class TheSidebarAndTheContentsFollowThePage(BuiltSite):
the other is the promoted slice's own h2s."""
def test_the_sidebar_marks_exactly_the_page_you_are_on(self):
"""The rail and its folded strip each mark the current page, so
there is more than one marker and every one of them names this
page and no other."""
for entry in self.articles():
here = SIDE_HERE.findall(self.page(entry["path"]))
self.assertEqual([entry["path"]], here,
f'{entry["path"]} does not mark itself in the '
f"sidebar")
self.assertTrue(here,
f'{entry["path"]} does not mark itself in the '
f"sidebar")
self.assertEqual({entry["path"]}, set(here),
f'{entry["path"]}: a sidebar rendering marks '
f"some other page as here")
def test_the_sidebar_lists_every_other_page_too(self):
html = self.page("/concepts/stages/")
@@ -145,9 +163,14 @@ class TheSidebarAndTheContentsFollowThePage(BuiltSite):
with nobody editing the site."""
for entry in self.articles():
html = self.page(entry["path"])
self.assertEqual(HEADING_ID.findall(html), TOC_LINK.findall(html),
f'{entry["path"]}: the contents list and the '
f"headings disagree")
headings = HEADING_ID.findall(html)
renderings = contents_lists(html)
self.assertTrue(renderings or not headings,
f'{entry["path"]}: headings but no contents list')
for links in renderings:
self.assertEqual(headings, links,
f'{entry["path"]}: a contents rendering and '
f"the headings disagree")
def test_a_page_with_sub_headings_really_has_a_contents_list(self):
"""Guards the test above against passing on two empty lists."""
+394
View File
@@ -0,0 +1,394 @@
"""The docs design is drawn at a fixed 1180px, and the site's first
traffic is a link pasted into a chat and opened on a phone. This file is
about what happens below the design's width — and, just as much, about
what does not happen at it.
Four promises, each mechanised below:
- **Desktop is the design.** Every media query in the stylesheet is a
max-width at or below 1080px, and the two folded strips are
display:none in the base sheet. Nothing here can change the 1180px
rendering the design defines.
- **A column that goes away hands back a way to reach it.** The section
nav and the "on this page" list are written twice the column and a
<details> strip carrying the same links and the step that hides each
column is the step that shows its strip.
- **Only a code block or a table scrolls sideways.** A table becomes its
own scroller with an edge that says there is more; a long unbroken
token breaks or scrolls inside `pre` rather than widening the page.
- **No script.** One menu that opens and closes is a <details>; the site
ships no JavaScript at all, and this is where that stays true.
python3 -m unittest discover -s tests
"""
import json
import re
import shutil
import tempfile
import unittest
from pathlib import Path
from tests.test_site_build import (BUILDER, REPO, SITE, ScratchCase,
needs_renderer, run_build)
CSS = SITE / "static" / "site.css"
TEMPLATES = SITE / "templates"
# The design's own frame. Nothing in the stylesheet may take effect at or
# above it, which is what "the desktop rendering is unchanged" means in a
# form a test can check.
DESIGN_WIDTH = 1180
# The step at which each column folds into its strip, and the strip it
# hands over to. Read as: at this width the column is gone and the
# <details> is there instead.
FOLDS = [
("1080px", ".gutter", ".menu-contents"), # "on this page"
("760px", ".side", ".menu"), # the section nav
]
# Everything a finger has to hit, once the layout is a phone's. Links
# inside running prose are deliberately not here: a line of text cannot
# be 44px tall and still be a line of text.
TAPPABLE = [".wordmark", ".nav-link", ".button", ".side-link", ".toc-link",
".strip-link", ".footer-repo"]
def stylesheet() -> str:
"""site.css with its comments removed — every test here reads
declarations, and a comment is prose about them."""
return re.sub(r"/\*.*?\*/", "", CSS.read_text("utf-8"), flags=re.S)
def media_blocks(css: str) -> list:
"""[(condition, body, (start, end))] for every @media, matched on
braces rather than a regex, because the body of one is full of
them. The span is what lets base_sheet cut the block back out."""
out = []
for opener in re.finditer(r"@media([^{]+)\{", css):
depth, index = 1, opener.end()
while depth and index < len(css):
depth += {"{": 1, "}": -1}.get(css[index], 0)
index += 1
out.append((opener.group(1).strip(), css[opener.end():index - 1],
(opener.start(), index)))
return out
def base_sheet(css: str) -> str:
"""The stylesheet with every @media block cut out: what a browser at
the design's width is left with."""
kept, cursor = [], 0
for _, _, (start, end) in media_blocks(css):
kept.append(css[cursor:start])
cursor = end
kept.append(css[cursor:])
return "".join(kept)
def rules(body: str) -> dict:
"""{selector: its declarations} for one flat block of CSS. A grouped
selector is recorded under each of its parts, and a selector written
twice accumulates which is how the cascade reads it."""
found = {}
for selectors, declarations in re.findall(r"([^{}]+)\{([^{}]*)\}", body):
for one in selectors.split(","):
key = " ".join(one.split())
if key.startswith("@") or not key:
continue
found[key] = found.get(key, "") + declarations
return found
def widths(condition: str) -> list:
"""The px widths a media condition names."""
return [float(value) for value in
re.findall(r"max-width\s*:\s*([\d.]+)px", condition)]
def block_at(css: str, width: str) -> dict:
"""The rules of the @media block for `max-width:<width>`."""
for condition, body, _ in media_blocks(css):
if f"max-width:{width}" in condition.replace(" ", ""):
return rules(body)
raise AssertionError(f"the stylesheet has no max-width:{width} block")
class TheDesignsWidthIsUntouched(unittest.TestCase):
"""Desktop is the design; this is what happens below it. Both halves
of that sentence are checkable, and the second is the dangerous one:
a rule that leaks upwards redraws a layout nobody asked to redraw."""
def setUp(self):
self.css = stylesheet()
def test_every_query_is_a_max_width_below_the_design(self):
for condition, _, _ in media_blocks(self.css):
if "width" not in condition:
continue # prefers-reduced-motion, which is not a size
self.assertNotIn("min-width", condition,
f"@media{condition} turns something on as the "
f"screen gets wider")
found = widths(condition)
self.assertTrue(found, f"@media{condition} names no max-width")
for value in found:
self.assertLess(
value, DESIGN_WIDTH,
f"@media{condition} takes effect at the design's width")
def test_the_folded_strips_do_not_render_at_the_design_width(self):
"""`.menu` covers both — the contents strip carries both classes
so the base sheet hides the pair in one declaration."""
base = rules(base_sheet(self.css))
self.assertIn("display:none", base.get(".menu", "").replace(" ", ""),
"the folded menus are not hidden by default")
def test_the_lede_survives_the_strip_between_it_and_the_title(self):
"""The contents strip is a sibling between the h1 and the body's
first paragraph even when it is display:none, so `h1 + p` alone
would silently demote the lede on every article page."""
base = rules(base_sheet(self.css))
self.assertIn(".prose .menu-contents + p", base)
self.assertIn(".prose h1 + p", base)
class EachColumnHandsBackAStrip(unittest.TestCase):
""""Collapsed is fine, absent is not" — so the step that hides a
column is the step that shows the <details> replacing it."""
def setUp(self):
self.css = stylesheet()
def test_the_step_that_hides_a_column_shows_its_menu(self):
for width, column, strip in FOLDS:
block = block_at(self.css, width)
self.assertIn("display:none", block.get(column, "").replace(" ", ""),
f"{column} is not hidden at {width}")
self.assertIn("display:block", block.get(strip, "").replace(" ", ""),
f"{strip} does not appear where {column} goes")
def test_a_menu_row_is_a_tap_target_at_every_width_it_shows(self):
"""The contents strip appears at 1080px, above the step where the
tap-target rules live, so the panel sizes its own rows."""
base = rules(base_sheet(self.css))
for selector in (".menu-panel .side-link", ".menu-panel .toc-link"):
self.assertIn("min-height:var(--tap)",
base.get(selector, "").replace(" ", ""),
f"{selector} is not a tap target")
class TapTargetsAreFingerSized(unittest.TestCase):
def setUp(self):
self.css = stylesheet()
def test_the_size_is_a_token_and_it_is_44px(self):
base = rules(base_sheet(self.css))
self.assertIn("--tap:44px", base.get(":root", "").replace(" ", ""))
def test_everything_a_finger_hits_is_at_least_that_tall(self):
block = block_at(self.css, "760px")
for selector in TAPPABLE:
self.assertIn("min-height:var(--tap)",
block.get(selector, "").replace(" ", ""),
f"{selector} is smaller than a fingertip on a phone")
class TheTypeScaleSteps(unittest.TestCase):
""""A smaller step on narrow screens without losing the Zilla Slab
display voice" — so the sizes come down and the family does not."""
def setUp(self):
self.css = stylesheet()
self.base = rules(base_sheet(self.css))[":root"]
def token(self, declarations: str, name: str):
found = re.search(rf"{name}\s*:\s*([\d.]+)px", declarations)
return float(found.group(1)) if found else None
def test_the_display_sizes_come_down_at_each_step(self):
previous = {name: self.token(self.base, name)
for name in ("--t-hero", "--t-title")}
for width in ("760px", "480px"):
root = block_at(self.css, width).get(":root", "")
for name, was in list(previous.items()):
now = self.token(root, name)
self.assertIsNotNone(
now, f"{name} is not stepped down at {width}")
self.assertLess(now, was,
f"{name} does not get smaller at {width}")
previous[name] = now
def test_the_display_family_is_never_redefined(self):
"""The step is the size. Zilla Slab is the voice, at every
width a phone gets a smaller headline, not a different one."""
for condition, body, _ in media_blocks(self.css):
self.assertNotIn("--display:", body.replace(" ", ""),
f"@media{condition} changes the display face")
class OnlyCodeAndTablesScrollSideways(unittest.TestCase):
def setUp(self):
self.css = stylesheet()
def test_a_code_block_is_its_own_scroller(self):
base = rules(base_sheet(self.css))
self.assertIn("overflow-x:auto",
base.get(".prose pre", "").replace(" ", ""))
def test_a_table_becomes_its_own_scroller_with_an_edge(self):
"""display:block is what makes the table element a scroll
container at all; the local/scroll pair is what makes its edge
shadow appear only while there is more to the right."""
table = block_at(self.css, "1080px").get(".prose table", "")
flat = table.replace(" ", "")
self.assertIn("display:block", flat)
self.assertIn("overflow-x:auto", flat)
self.assertIn("background-attachment:local,local,scroll,scroll", flat)
def test_an_unbreakable_token_in_running_text_breaks(self):
block = block_at(self.css, "1080px")
self.assertIn("overflow-wrap:anywhere",
block.get(".prose :not(pre) > code", "").replace(" ", ""))
def test_an_image_can_never_widen_the_page(self):
base = rules(base_sheet(self.css))
self.assertIn("max-width:100%",
base.get(".prose img", "").replace(" ", ""))
class EveryTemplateSaysHowWideItIs(unittest.TestCase):
"""Without the viewport meta a phone renders the page at 980px and
scales it down, which makes every rule below a fiction."""
def test_each_layout_declares_the_viewport(self):
for template in sorted(TEMPLATES.glob("*.html")):
markup = template.read_text("utf-8")
self.assertIn('name="viewport"', markup,
f"{template.name} has no viewport meta")
self.assertIn("width=device-width", markup, template.name)
@needs_renderer
class TheBuiltPagesCarryTheMenus(unittest.TestCase):
"""The stylesheet's half of this is above; here is the markup's.
Built pages, not templates a strip the builder fills with nothing
is the failure that would look fine in a template."""
NAV = re.compile(r'<details class="menu">(.*?)</details>', re.S)
CONTENTS = re.compile(
r'<details class="menu menu-contents">(.*?)</details>', re.S)
@classmethod
def setUpClass(cls):
cls.out = Path(tempfile.mkdtemp(prefix="bench-phone-")).resolve()
result = run_build(REPO, cls.out)
if result.returncode != 0: # not assert: must survive python -O
raise RuntimeError(f"site/build.py failed:\n{result.stderr}")
cls.manifest = json.loads((SITE / "pages.json").read_text("utf-8"))
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.out, ignore_errors=True)
def articles(self):
for entry in self.manifest["pages"]:
if entry["layout"] == "article":
yield entry, BUILDER.target_for(
self.out, entry["path"]).read_text("utf-8")
def test_every_article_carries_the_section_nav_folded(self):
listed = [page for page in self.manifest["pages"] if page.get("section")]
for entry, html in self.articles():
menu = self.NAV.search(html)
self.assertIsNotNone(menu, f'{entry["path"]} has no folded nav')
for page in listed:
self.assertIn(f'href="{page["path"]}"', menu.group(1),
f'{entry["path"]}\'s menu cannot reach '
f'{page["path"]}')
def test_the_folded_contents_lists_what_the_gutter_lists(self):
for entry, html in self.articles():
strip = self.CONTENTS.search(html)
self.assertIsNotNone(strip,
f'{entry["path"]} has no contents strip')
for slug in re.findall(r'<h2 id="([^"]+)"', html):
self.assertIn(f'href="#{slug}"', strip.group(1),
f'{entry["path"]}\'s contents strip drops '
f'#{slug}')
def test_at_least_one_page_proves_the_strip_is_not_empty(self):
"""Guards the test above from passing on a page that happens to
have no h2s at all."""
filled = [entry["path"] for entry, html in self.articles()
if "toc-link" in self.CONTENTS.search(html).group(1)]
self.assertTrue(filled, "no article page's contents strip has links")
def test_the_site_ships_no_script(self):
"""One menu that opens and closes is a <details>. Nothing on this
site justifies the first line of JavaScript on it and the
Content-Security-Policy in site/root/_headers forbids one."""
self.assertEqual([], sorted(self.out.rglob("*.js")))
for entry in self.manifest["pages"]:
html = BUILDER.target_for(self.out, entry["path"]).read_text("utf-8")
self.assertNotIn("<script", html.lower(),
f'{entry["path"]} loads a script')
def test_every_built_page_declares_the_viewport(self):
for entry in self.manifest["pages"]:
html = BUILDER.target_for(self.out, entry["path"]).read_text("utf-8")
self.assertIn(
'<meta name="viewport" content="width=device-width, '
'initial-scale=1">', html, entry["path"])
@needs_renderer
class TheHazardsInGeneratedMarkdown(ScratchCase):
"""The two shapes a slice of AGENTS.md can take that no amount of
layout CSS reflows: a table wider than a phone, and a token longer
than one. Both have to reach the page intact the container is what
scrolls, not the page so this builds them rather than trusting
that no source file has one yet."""
URL = ("https://github.com/12vectors/bench/releases/download/"
"v0.2-alpha/bench-0.2-alpha.tar.gz")
def build_one(self, body: str) -> str:
(self.repo.root / "SOURCE.md").write_text(
f"# Doc\n\n## Section\n\n{body}\n", encoding="utf-8")
self.repo.pages({
"path": "/hazard/", "title": "Hazard", "layout": "article",
"section": "Concepts", "source": "SOURCE.md",
"from": "## Section",
})
result = self.repo.build()
self.assertEqual(result.returncode, 0, result.stderr)
return (self.repo.out / "hazard" / "index.html").read_text("utf-8")
def test_a_wide_table_reaches_the_page_as_a_table(self):
"""It stays a <table>: the scrolling container is the table
element itself (see the stylesheet's max-width:1080px block), so
nothing in the build has to wrap it and nothing in the markup
can forget to."""
html = self.build_one(
"| Setting | Default | What it does |\n"
"| --- | --- | --- |\n"
"| `BOARD_PORT` | `26071` | the port the board serves on |\n"
"| `BOARD_SYNC` | `0` | origin/main is the truth, every "
"board a replica |\n")
self.assertIn("<table>", html)
self.assertIn("<code>BOARD_SYNC</code>", html)
def test_a_long_token_in_a_code_block_stays_in_the_code_block(self):
html = self.build_one(f"```\ncurl -fsSL {self.URL} | tar xz\n```")
fence = re.search(r"<pre>(.*?)</pre>", html, re.S)
self.assertIsNotNone(fence, "the code block did not survive")
self.assertIn(self.URL, fence.group(1))
def test_a_long_token_in_running_text_stays_inline_code(self):
html = self.build_one(f"Fetch it from `{self.URL}` and untar it.")
self.assertIn(f"<code>{self.URL}</code>", html)
if __name__ == "__main__":
unittest.main()