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:
@@ -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
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user