diff --git a/docs/install.md b/docs/install.md
index 71db821..324e32d 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -122,12 +122,35 @@ Losing them would make every already-known error look new tomorrow morning.
### Asking it how it is
-The CLI lives *inside* the container in this stack, so every command is reached the same way:
+> **Built and unreleased**, as of 2026-08-10. Everything in this section is in no image you can pull:
+> `docs/published-surface.json` records `0.1.0a8`, and these routes are items 203 to 208. Until a
+> release carries them, the CLI below is the whole of it — and this note stays, because the rule is
+> that documentation describes the released artefact rather than this checkout (`CONTRIBUTING.md`).
+>
+> Written down rather than deleted because the guard that catches this **cannot see it**: it compares
+> documented *commands* against the published image and knows nothing about URLs, so a route
+> documented before it exists passes every test. Recorded as work item 209.
+
+**From the page, which is the point** (DR-0022).
+Set a password once — `docker compose exec api hullwork password`, read from a prompt so it is not in
+your shell history — and everything below is a URL:
+
+| | |
+|---|---|
+| `/page/me/` | how it is, and every feature this instance has switched on, off, or cannot do |
+| `/page/me/doctor` | what is broken, a sentence per check |
+| `/page/me/config` | what it actually received — every variable, where it came from, and which half gets it. **No credential is printed**: a secret reads `set` or `not set` |
+| `/page/me/projects` | connect a project, re-read its manifest, disable it, rotate its webhook secret, name it in the tracker |
+
+That is the whole of running an instance, and none of it needs a shell. The password is what unlocks
+it; a read link handed to a colleague reads the instance and reaches none of those four.
+
+The CLI still does all of it, unchanged, for scripts and for installing:
```bash
-docker compose exec api hullwork status # how it is — exit 1 when degraded
-docker compose exec api hullwork doctor # what is broken — a sentence per check
-docker compose exec api hullwork config # what it is set to — every variable, source, and half
+docker compose exec api hullwork status # exit 1 when degraded — `hullwork status || mail me`
+docker compose exec api hullwork doctor
+docker compose exec api hullwork config
```
`status` exiting 1 when degraded is what makes `hullwork status || mail me` a whole monitoring setup,
@@ -141,13 +164,21 @@ you wrote in a file.
### A page a teammate can read
-`hullwork page-token` mints one URL, prints it once and stores only its hash. Behind it is what
-`status` says, read from the same functions, plus the evidence of every attempt. It is **off until you
-run that command**, and everything without the token — including a wrong token — gets the same `404` an
-unknown path gets, so it cannot be found by probing.
+Two doors, and they are for two people
+(DR-0021). **The first is unreleased**, per the note
+above; the second has worked since `0.1.0a5`.
+
+**Yours is `/page/me/`**, behind the password. Nothing to lose, nothing to copy, and it is the only
+one that administers anything.
+
+**Theirs is a link.** `hullwork page-token` mints one URL, prints it once and stores only its hash —
+read-only, and it reaches no button and neither `doctor` nor `config`. That URL **is** the credential:
+anyone holding it can read every item and captured output here, so treat it as a secret. Rotating
+replaces it and stops the old one.
-That URL **is** the credential. Anyone who has it can read every item and captured output on this
-instance. It is read-only, and rotating replaces it.
+Everything without a valid token — including a wrong one — gets the same `404` an unknown path gets,
+so the page cannot be found by probing. `/page/me/` offers a login **only where a password is set**,
+so an instance that never set one is as undiscoverable as it ever was.
### Two things about the port
diff --git a/hullwork/__init__.py b/hullwork/__init__.py
index ad13fcb..d7d636a 100644
--- a/hullwork/__init__.py
+++ b/hullwork/__init__.py
@@ -1,5 +1,5 @@
"""Hullwork — from production errors to reviewable pull requests, on your own infrastructure."""
-__version__ = "0.1.0a8"
+__version__ = "0.1.0a9"
__all__ = ["__version__"]
diff --git a/hullwork/cli.py b/hullwork/cli.py
index 24b29ca..c01b64d 100644
--- a/hullwork/cli.py
+++ b/hullwork/cli.py
@@ -56,6 +56,7 @@
)
from hullwork import decisions as decide
from hullwork import dispatch as dispatch_module
+from hullwork import features as features_module
from hullwork import upstream as upstream_module
from hullwork.config import ConfigError, Settings, get_settings
from hullwork.credentials import PushCapability
@@ -1498,6 +1499,20 @@ def rotate_secret(session: Session, slug: str) -> str:
return token
+def set_tracker(session: Session, slug: str, tracker_project: str | None) -> Project:
+ """Name this project in the tracker, or unname it. Instance configuration, never the manifest.
+
+ **Extracted by item 207**, the only production code that item moved: this lived inside
+ `_cmd_set_tracker` and had no caller but that command, so the page would have had to reimplement
+ it — the drift items 193, 194, 200 and 203 each cost a day to. Empty means *stop sweeping it*,
+ which is a real answer and not a missing one.
+ """
+ project = _require(session, slug)
+ project.tracker_project = tracker_project or None
+ session.commit()
+ return project
+
+
def disable_project(session: Session, slug: str) -> Project:
"""Deactivate. Never delete: destroying history to unregister a project is a footgun."""
project = _require(session, slug)
@@ -2261,6 +2276,18 @@ def _cmd_status(
# and stopped mentioning the dispatcher at all — while it was stopped. That is the failure item
# 075's fourth gate exists to prevent, arrived at from the other side: an operator could not
# tell a quiet healthy instance from one with nothing running.
+ # **The same function the page renders** (item 203), so a reader with a terminal and a reader
+ # with a browser cannot come to disagree about the same instance.
+ standing = features_module.on_this_instance(session, settings)
+ worrying = [one for one in standing if one.state is not features_module.ON]
+ print("\n Features:", file=out)
+ if worrying:
+ for one in worrying:
+ print(f" ! {one.name}: {one.state} — {one.detail}", file=out)
+ print(f" - {len(standing) - len(worrying)} of {len(standing)} on", file=out)
+ else:
+ print(f" - all {len(standing)} on", file=out)
+
print("\n Dispatcher:", file=out)
for note in dispatcher:
mark = "!" if note.degraded else "-"
@@ -2611,9 +2638,7 @@ def _cmd_set_tracker(
args: argparse.Namespace, session: Session, settings: Settings, out: TextIO
) -> int:
"""Name this project in the tracker. Instance configuration, never the manifest (DR-0011)."""
- project = _require(session, args.slug)
- project.tracker_project = args.tracker_project or None
- session.commit()
+ project = set_tracker(session, args.slug, args.tracker_project)
if project.tracker_project is None:
print(f"'{project.slug}' will no longer be swept.", file=out)
return 0
@@ -3053,9 +3078,10 @@ def _cmd_try(args: argparse.Namespace, settings: Settings, out: TextIO) -> int:
print(f"\n{verdict}. What it produced is under {into}.", file=out)
print(
" Nothing was published and no forge was contacted. The artefact there is the same one a "
- "pull request would carry.\n"
- " A real instance shows this and the rest — cost, policies, review debt — on a page: "
- "`hullwork page-token` (DR-0014).",
+ "pull request would carry, and `evidence.html` beside it is the page a reviewer is shown "
+ "on a real instance — open it, or send it to somebody.\n"
+ " What that page cannot have here: the numbers an instance keeps across runs — cost over "
+ "time, review debt, whether a fix held. Those need one (DR-0014).",
file=out,
)
# A trial cannot consume an item, so its exit code answers a different question from `work`'s:
diff --git a/hullwork/features.py b/hullwork/features.py
index 3bbeda6..e9030c4 100644
--- a/hullwork/features.py
+++ b/hullwork/features.py
@@ -30,8 +30,14 @@
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+from hullwork.config import Settings
from hullwork.manifest import Manifest
+from hullwork.models import Project
+
+if TYPE_CHECKING: # pragma: no cover - a type, never a runtime dependency
+ from sqlalchemy.orm import Session
@dataclass(frozen=True)
@@ -367,3 +373,152 @@ def lines(answers: Sequence[Answer]) -> list[str]:
said.append(f" limit: {limit}")
said.append("")
return said
+
+
+# --- the instance side of the same question (item 203) -------------------------------------------
+
+#: The three answers a dashboard may give about a feature **on this instance**, and they are three
+#: because collapsing any pair costs a reader something specific.
+#:
+#: `OFF` is a decision somebody took, and DR-0019's rule is that having is not permitting: rendering
+#: a decision as a fault tells its reader to go and repair a choice they made. `CANNOT` is a fault
+#: and names what is missing. `ON` is the least interesting of the three, which is why the ordering
+#: below puts it last.
+ON = "on"
+OFF = "off"
+CANNOT = "cannot"
+
+
+@dataclass(frozen=True)
+class Standing:
+ """One feature, its state on this instance, and the sentence a reader acts on."""
+
+ name: str
+ state: str
+ detail: str
+
+
+def _filing(session: Session, settings: Settings) -> Standing:
+ """Whether a production error becomes an issue here.
+
+ **Configured, not reachable.** Whether the forge answers costs a network call and this renders
+ on
+ a page request; a dashboard that opened a socket per view would be a load test of somebody's
+ forge. So this says what it established and names what asks the other question — the distinction
+ items 193, 194 and 199 each cost a day to.
+ """
+ name = "filing a production error as an issue"
+ if not settings.forge_url:
+ return Standing(
+ name, CANNOT,
+ "no forge configured. Set HULLWORK_FORGE_URL and HULLWORK_FORGE_TOKEN — a token that "
+ "can read content and write issues, and provably not push.",
+ )
+ active = session.query(Project).filter(Project.active.is_(True)).count()
+ if not active:
+ return Standing(
+ name, OFF,
+ "a forge is configured and no project is registered here yet: "
+ "`hullwork projects add --slug NAME --repo owner/name`.",
+ )
+ return Standing(
+ name, ON,
+ f"a forge is configured and {active} project(s) are registered. Whether it answers is not "
+ f"asked here — `hullwork doctor` asks it.",
+ )
+
+
+def _the_page(session: Session, settings: Settings) -> Standing:
+ """Off until somebody mints a token, which is a decision rather than an omission."""
+ del settings
+ from hullwork import page
+
+ if page.configured(session):
+ return Standing(
+ "the daily page", ON,
+ "a token has been minted. That URL is the credential: anyone holding it can read every "
+ "item and captured output here.",
+ )
+ return Standing(
+ "the daily page", OFF,
+ "off until you run `hullwork page-token`, and everything without the token gets the same "
+ "404 an unknown path gets, so it cannot be found by probing.",
+ )
+
+
+def _notifications(session: Session, settings: Settings) -> Standing:
+ """What each project asked for, against what actually delivers.
+
+ `telegram` and `email` parse in a manifest and are refused at delivery — true, documented in
+ prose, and said nowhere a person would look. This is where they would look.
+ """
+ del settings
+ name = "notifications"
+ delivers = {"none", "console"}
+ asked = {
+ str(((project.manifest or {}).get("notify") or {}).get("channel", "none"))
+ for project in session.query(Project).filter(Project.active.is_(True)).all()
+ }
+ undeliverable = sorted(asked - delivers)
+ if undeliverable:
+ return Standing(
+ name, CANNOT,
+ f"{', '.join(undeliverable)} parses in a manifest and is refused at delivery: a "
+ f"transport nobody has exercised would have its first real run in front of a user. "
+ f"`console` and `none` are what deliver.",
+ )
+ if asked <= {"none"}:
+ return Standing(
+ name, OFF,
+ "every project here asks for `none`, which is the default and a decision. `console` is "
+ "the other one that delivers.",
+ )
+ return Standing(name, ON, f"delivering to {', '.join(sorted(asked - {'none'}))}.")
+
+
+def _recurrence(session: Session, settings: Settings) -> Standing:
+ """Whether a fix that did not hold can be noticed at all. It needs the tracker, not the
+ forge."""
+ del session
+ name = "the recurrence watch"
+ if not settings.tracker_url:
+ return Standing(
+ name, CANNOT,
+ "no tracker configured, so a returning error cannot be seen: a tracker notifies once "
+ "per issue for that issue's whole life, and a recurrence arrives by asking. Set "
+ "HULLWORK_TRACKER_URL and HULLWORK_TRACKER_TOKEN.",
+ )
+ return Standing(
+ name, ON,
+ "a tracker is configured and the sweep asks it. Whether it answers is not asked here.",
+ )
+
+
+#: One answerer per name in `INSTANCE_SHAPED`, so the list is what drives the dashboard rather than
+#: something kept beside it. A fifth name added there tomorrow fails this module loudly rather than
+#: going unanswered the way all four did until item 203.
+_ANSWERS = {
+ "filing a production error as an issue": _filing,
+ "the daily page": _the_page,
+ "notifications": _notifications,
+ "the recurrence watch": _recurrence,
+}
+
+
+def on_this_instance(session: Session, settings: Settings) -> list[Standing]:
+ """What this instance has switched on, off, and cannot do — worst first.
+
+ **The other half of `examine`.** That one answers for a checkout and hands `INSTANCE_SHAPED`
+ back as somebody else's question, with a comment saying `doctor` owns it. `doctor` answers
+ resources, and a resource is not a feature: four capabilities were named as nobody's question
+ until this took them.
+
+ Ordered with `ON` last on purpose. A reader opens this looking for what is not working, and a
+ wall of green with one red line in the middle is a wall of green.
+ """
+ missing = [name for name in INSTANCE_SHAPED if name not in _ANSWERS]
+ if missing: # pragma: no cover - the structural test below is what keeps this true
+ msg = f"no answer on this instance for: {missing}"
+ raise NotImplementedError(msg)
+ standing = [_ANSWERS[name](session, settings) for name in INSTANCE_SHAPED]
+ return sorted(standing, key=lambda one: one.state is ON)
diff --git a/hullwork/main.py b/hullwork/main.py
index 9abd87d..2880d9a 100644
--- a/hullwork/main.py
+++ b/hullwork/main.py
@@ -267,6 +267,7 @@ def ready(
)
def page_instance(
token: str,
+ request: Request,
session: Annotated[Session, Depends(_readiness_session)],
) -> RedirectResponse:
"""The door. Item 122, and everything about it is in `hullwork.page`.
@@ -278,11 +279,7 @@ def page_instance(
`GET` only, and that is asserted by walking the application's routes rather than by trusting
this decorator to stay a `get`.
"""
- if not page.opens(session, token):
- # **The same body Starlette gives an unknown path**, not a friendlier one: a distinct
- # message is a yes. Measured while writing the test — the default is `{"detail":"Not
- # Found"}` and `"not found"` would have told a prober that this route exists.
- raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ _may_read(session, request, token)
# **To the same URL with a slash, and the reason is the token.** Under `/page/{token}/` every
# link between views is relative — `items`, `../items` — so the credential never has to be
# written into the HTML to get from one page to the next. Saved HTML, a screenshot of the
@@ -307,15 +304,53 @@ def page_instance_index(
session: Annotated[Session, Depends(_readiness_session)],
settings: Annotated[Settings, Depends(get_settings)],
) -> HTMLResponse:
- """The instance view itself. Everything about it is in `hullwork.page`."""
- if not page.opens(session, token):
- raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ """The front door, which is the work rather than the arithmetic about it (item 212, DR-0023).
+
+ It was the instance report until this item: 357 words, 11 numbers and 14 sentences before a
+ person could do anything. The report is a noun in the rail now, with every number it had.
+
+ **The one route where a refusal is not a `404`** (DR-0021): with a password configured and no
+ session, this is where somebody acquires one, or the door that replaces the token has no handle.
+ What that discloses is that this host runs something with a login, and nothing else — and an
+ instance that never set a password is `404` here like everywhere else.
+ """
+ shut = _the_login_if_offered(session, request, token)
+ if shut is not None:
+ return shut
+ acting = _may_read(session, request, token)
+ return HTMLResponse(
+ page.items(
+ session,
+ acting=acting,
+ here="./",
+ settings=settings,
+ front=True,
+ error_reporting=_reporting_enabled,
+ ),
+ headers=page.HEADERS,
+ )
+
+
+@app.get(
+ f"{page.PREFIX}/{{token}}/instance",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+def page_instance_report(
+ token: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """Every number this instance keeps. Moved off the front door by item 212, not dropped."""
+ acting = _may_read(session, request, token)
return HTMLResponse(
page.instance(
session,
settings,
error_reporting=_reporting_enabled,
- acting=_acting(session, request),
+ acting=acting,
),
headers=page.HEADERS,
)
@@ -334,8 +369,7 @@ def page_items(
in_: Annotated[str | None, Query(alias="in")] = None,
) -> HTMLResponse:
"""Every item, most recent first. Item 123."""
- if not page.opens(session, token):
- raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ _may_read(session, request, token)
return HTMLResponse(
page.items(session, only=in_, acting=_acting(session, request)), headers=page.HEADERS
)
@@ -349,13 +383,14 @@ def page_items(
)
def page_projects(
token: str,
+ request: Request,
session: Annotated[Session, Depends(_readiness_session)],
settings: Annotated[Settings, Depends(get_settings)],
) -> HTMLResponse:
"""Every project this instance serves. Item 142, the level the tree was missing."""
- if not page.opens(session, token):
- raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
- return HTMLResponse(page.projects(session, settings), headers=page.HEADERS)
+ _may_read(session, request, token)
+ acting = _acting(session, request)
+ return HTMLResponse(page.projects(session, settings, acting=acting), headers=page.HEADERS)
@app.get(
@@ -366,6 +401,7 @@ def page_projects(
)
def page_project(
token: str,
+ request: Request,
slug: str,
session: Annotated[Session, Depends(_readiness_session)],
settings: Annotated[Settings, Depends(get_settings)],
@@ -376,8 +412,7 @@ def page_project(
body would let somebody with a valid token enumerate which clients an instance serves — a fact
about a consultancy's customers as much as about this deployment.
"""
- if not page.opens(session, token):
- raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ _may_read(session, request, token)
rendered = page.project(session, settings, slug)
if rendered is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
@@ -403,8 +438,7 @@ def page_item(
used to count an instance's items from outside — though anyone who has got this far holds the
token and could simply read the list.
"""
- if not page.opens(session, token):
- raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ _may_read(session, request, token)
rendered = page.item(session, settings, item_id, acting=_acting(session, request))
if rendered is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
@@ -442,6 +476,73 @@ async def _field(request: Request, name: str) -> str | None:
return None
+def _may_read(session: Session, request: Request, token: str) -> page.Acting:
+ """The one gate every page route asks, and it returns what the renderer needs. Item 204.
+
+ **One place, not nine.** Each route used to decide this for itself, and DR-0021 gives the answer
+ a second input — a session may read at the reserved path — so nine copies would be nine chances
+ to add it in eight of them. That is the defect items 193, 194, 200 and 203 each cost a day to,
+ and this is auth, where the cost of getting it wrong is not a wrong number on a page.
+
+ Raises the `404` itself, with the body Starlette gives an unknown path: a distinct message is a
+ yes.
+ """
+ acting = _acting(session, request)
+ if not page.opens(session, token, acting=acting):
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ return acting
+
+
+def _the_login_if_offered(
+ session: Session, request: Request, token: str
+) -> HTMLResponse | None:
+ """The login, when this request may not read yet and may be told how to. Item 204.
+
+ Returned rather than raised so the route reads as what it is, and kept here rather than in the
+ route so that **no route asks `page.opens` itself** — the three gates in this module are the
+ only callers, which is what `test_there_is_one_gate_and_not_ten` asserts.
+ """
+ acting = _acting(session, request)
+ if page.opens(session, token, acting=acting) or not page.offers_a_login(token, acting):
+ return None
+ return HTMLResponse(page.just_the_login(acting), headers=page.HEADERS)
+
+
+def _the_operators(session: Session, request: Request, token: str) -> page.Acting:
+ """A view only the operator sees: the session, never a read link. Item 208.
+
+ `404` rather than `403`, like everything else here — a distinct refusal would tell somebody
+ holding a read link which doors exist behind it.
+
+ It returns who that is, because item 212's rail is drawn from it: these two views rendered with
+ the default `Acting` showed a signed-in operator the reader's three nouns, so opening *why it
+ will not work* took away the way to *what it received*.
+ """
+ acting = _may_read(session, request, token)
+ if operator.acting(session, request.cookies.get(operator.COOKIE)) is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ return acting
+
+
+def _may_sign_in(session: Session, request: Request, token: str) -> None:
+ """The gate the **login** asks, which is not the one every other route asks. Item 204's defect.
+
+ Found in use on the first attempt: the login was put behind `_may_read`, and at the session door
+ that requires a session — so signing in required already being signed in, and the form answered
+ `{"detail":"Not Found"}`. A door with a handle you can only reach from inside is a door nobody
+ opens.
+
+ Two ways through, and they are the two kinds of person who sign in: somebody holding a read link
+ who wants the buttons, and somebody at the session door who has the password. Everything else is
+ the same `404` an unknown path gets, so an instance with no password configured has nothing to
+ post to — the property DR-0021 spends nothing of.
+ """
+ acting = _acting(session, request)
+ if page.opens(session, token, acting=acting) or page.offers_a_login(token, acting):
+ return
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+
+
def _acting(session: Session, request: Request) -> page.Acting:
"""What this request may do, from the cookie it brought. Item 166.
@@ -470,6 +571,47 @@ def _to_page(token: str, tail: str = "") -> RedirectResponse:
)
+@app.get(
+ f"{page.PREFIX}/{{token}}/doctor",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+def page_doctor(
+ token: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """Why an instance that is running will not work. Item 208, DR-0022.
+
+ **The operator's, not a reader's.** DR-0021 gives a link reading and the password administering;
+ this and `config` are the two somebody opens when something is wrong, and they belong on the
+ second side of that line.
+ """
+ acting = _the_operators(session, request, token)
+ return HTMLResponse(
+ page.why_it_will_not_work(session, settings, acting=acting), headers=page.HEADERS
+ )
+
+
+@app.get(
+ f"{page.PREFIX}/{{token}}/config",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+def page_config(
+ token: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """What this process actually received. Item 208."""
+ acting = _the_operators(session, request, token)
+ return HTMLResponse(page.what_it_received(settings, acting=acting), headers=page.HEADERS)
+
+
@app.post(f"{page.PREFIX}/{{token}}/login", tags=["page"], include_in_schema=False)
async def page_login(
token: str,
@@ -487,8 +629,7 @@ async def page_login(
deployment served over plain HTTP behind a VPN — the cookie would never be sent and the login
would look broken; hardcoding it off would be wrong the day a TLS proxy is put in front.
"""
- if not page.opens(session, token):
- raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ _may_sign_in(session, request, token)
supplied = await _field(request, "password")
issued = operator.sign_in(session, supplied) if supplied else None
@@ -507,6 +648,114 @@ async def page_login(
return redirect
+@app.post(f"{page.PREFIX}/{{token}}/projects", tags=["page"], include_in_schema=False)
+async def page_connect_project(
+ token: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """Register a project from the page. Item 206, DR-0022.
+
+ **The same function the terminal calls.** `cli.add_project` reads `hullwork.yml` from the
+ default branch with the receiver's own credential — *issue write and content read*, which is
+ exactly what that takes — validates it, and mints the webhook token. A route that registered a
+ project its own way would drift from the command, and items 193, 194, 200 and 203 each cost a
+ day to that.
+
+ The guards are the ones every write route here already has: a session or `404`, a matching CSRF
+ pair or `403`. Nothing new is trusted.
+ """
+ from hullwork import cli
+
+ _may_read(session, request, token)
+ expected = operator.acting(session, request.cookies.get(operator.COOKIE))
+ if expected is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ if not operator.csrf_ok(expected, await _field(request, "csrf")):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden")
+
+ asked = {name: (await _field(request, name) or "") for name in ("slug", "repo", "forge")}
+ try:
+ made = cli.add_project(
+ session,
+ settings,
+ slug=asked["slug"],
+ forge_kind=asked["forge"] or "forgejo",
+ repo=asked["repo"],
+ )
+ except Exception as exc: # every refusal already carries its own sentence
+ # **The command's own words, not a generic failure.** A manifest that does not parse, a
+ # repository the token cannot read and a slug already taken are the three things a person
+ # gets wrong, and each has a sentence written for it — showing "something went wrong" here
+ # would send them to the shell to find out what this page already knew.
+ session.rollback()
+ shown = page.projects(
+ session, settings, acting=_acting(session, request), refused=str(exc)
+ )
+ return HTMLResponse(shown, headers=page.HEADERS)
+ shown = page.projects(
+ session, settings, acting=_acting(session, request), just_made=made
+ )
+ return HTMLResponse(shown, headers=page.HEADERS)
+
+
+@app.post(
+ f"{page.PREFIX}/{{token}}/projects/{{slug}}", tags=["page"], include_in_schema=False
+)
+async def page_project_action(
+ token: str,
+ slug: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """The rest of a project's life, from its own page. Item 207, DR-0022.
+
+ **One route with an action rather than four routes.** The guard that keeps the write surface
+ readable is a list a person reads, and four names for one page's worth of buttons is how a list
+ stops being read. Each action calls what the terminal calls; none of them is implemented here.
+
+ **No default branch.** An action nobody recognises does nothing and says so — a form field that
+ fell through to whichever branch was last is how a typo becomes a disabled project.
+ """
+ from hullwork import cli
+
+ _may_read(session, request, token)
+ expected = operator.acting(session, request.cookies.get(operator.COOKIE))
+ if expected is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ if not operator.csrf_ok(expected, await _field(request, "csrf")):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden")
+
+ what = await _field(request, "action")
+ rotated: str | None = None
+ try:
+ if what == "disable":
+ cli.disable_project(session, slug)
+ elif what == "refresh":
+ cli.refresh_manifest(session, settings, slug)
+ elif what == "set-tracker":
+ cli.set_tracker(session, slug, await _field(request, "tracker_project"))
+ elif what == "rotate-secret":
+ rotated = cli.rotate_secret(session, slug)
+ else:
+ raise ValueError(
+ f"{what!r} is not something this page does. Nothing was changed."
+ )
+ except Exception as exc: # every refusal already carries its own sentence
+ session.rollback()
+ shown = page.projects(
+ session, settings, acting=_acting(session, request), refused=str(exc)
+ )
+ return HTMLResponse(shown, headers=page.HEADERS)
+
+ shown = page.projects(
+ session, settings, acting=_acting(session, request), rotated=(slug, rotated)
+ )
+ return HTMLResponse(shown, headers=page.HEADERS)
+
+
@app.post(f"{page.PREFIX}/{{token}}/logout", tags=["page"], include_in_schema=False)
async def page_logout(
token: str,
@@ -518,8 +767,7 @@ async def page_logout(
CSRF-protected like the decisions are: a forced logout is a nuisance rather than a breach, but a
route that skips the check is a route somebody later copies.
"""
- if not page.opens(session, token):
- raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ _may_read(session, request, token)
cookie = request.cookies.get(operator.COOKIE)
if operator.csrf_ok(operator.acting(session, cookie), await _field(request, "csrf")):
operator.log_out(session, cookie)
@@ -530,6 +778,7 @@ async def page_logout(
def _decide(
token: str,
+ request: Request,
item_id: int,
session: Session,
csrf: str | None,
@@ -549,8 +798,7 @@ def _decide(
cookie, so there is nothing left to hide from it;
4. the state machine, in `decisions`, which is what refuses an item that is not amber.
"""
- if not page.opens(session, token):
- raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ _may_read(session, request, token)
expected = operator.acting(session, cookie)
if expected is None:
@@ -586,7 +834,7 @@ async def page_approve(
A `GET` that approves is a URL that approves — from an image tag, a prefetch, a chat unfurling a
link somebody pasted. This costs money and opens a pull request, so it cannot be a link.
"""
- return _decide(token, item_id, session, await _field(request, "csrf"), "approve",
+ return _decide(token, request, item_id, session, await _field(request, "csrf"), "approve",
cookie=request.cookies.get(operator.COOKIE))
@@ -600,5 +848,5 @@ async def page_hand_to_human(
session: Annotated[Session, Depends(_readiness_session)],
) -> RedirectResponse:
"""Take this item away from the agent: a person will do it. `POST` only, same reasoning."""
- return _decide(token, item_id, session, await _field(request, "csrf"), "human",
+ return _decide(token, request, item_id, session, await _field(request, "csrf"), "human",
cookie=request.cookies.get(operator.COOKIE))
diff --git a/hullwork/page.py b/hullwork/page.py
index e644460..a9072f3 100644
--- a/hullwork/page.py
+++ b/hullwork/page.py
@@ -32,8 +32,10 @@
import html
import re
+from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
+from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
@@ -84,18 +86,60 @@ def configured(session: Session) -> bool:
return session.scalars(select(PageAccess).limit(1)).one_or_none() is not None
-def opens(session: Session, token: str) -> bool:
- """Whether this token opens the page. Constant time, and the same cost when there is no page.
+#: The path segment that means *ask my session instead of a token* (DR-0021). Short, and shorter
+#: than anything `generate_token` can produce, which is what stops a minted token ever
+#: colliding with it — a collision would open the page for anybody signed in and close it for the
+#: person
+#: holding the link.
+MINE = "me"
+
+
+def opens(session: Session, token: str, *, acting: Acting | None = None) -> bool:
+ """Whether this request may read the page. Constant time, and the same cost when there is no
+ page.
A missing row must not answer faster than a wrong token: the difference would say *"this
instance has a page and you have the wrong key"*, which is the one bit of information the `404`
is there to withhold.
+
+ **And a token is not the only way in** (DR-0021). The person who ran `page-token` reached it
+ through a shell on the host — they can already read this database, the environment file and the
+ Docker socket, so withholding their own page's URL from them protects nothing they do not
+ have. An operator with a session reads at `MINE`, and the token keeps the job it is good at:
+ handing
+ reading to somebody with no account, revocably.
+
+ Reading, not acting. `Acting` is what the renderer decides the second question from, and this
+ function answers only the first — item 166's split, which DR-0021 keeps.
+
+ **The `MINE` door exists only where a password is configured.** Everything else answers `404`,
+ including a wrong token, so the page cannot be found by probing; a login at a fixed path would
+ end that for every instance rather than for the ones that opted in. `offered` is exactly that
+ opt-in — `operator.configured` — so an instance with no password is as undiscoverable as before.
"""
+ if token == MINE:
+ return bool(acting and acting.csrf)
access = session.scalars(select(PageAccess).limit(1)).one_or_none()
expected = access.token_hash if access is not None else _DECOY_HASH
return verify_token(token, expected) and access is not None
+def offers_a_login(token: str, acting: Acting | None) -> bool:
+ """Whether a request that may not read should be shown the login rather than a `404`.
+
+ **Two questions, and collapsing them is the bug I wrote first** (item 204). *May this request
+ see the page* and *may it be told there is a login* are different, and answering the second with
+ the first rendered the whole instance to anybody who typed `/page/me/`.
+
+ So: the content needs a session, and the door needs only the opt-in. An instance with no
+ password
+ configured offers nothing and is `404` like everything else, which is the property DR-0021
+ spends
+ nothing of.
+ """
+ return token == MINE and bool(acting and acting.offered)
+
+
def issue(session: Session, token_hash: str) -> None:
"""Store the hash of a new token, replacing any previous one. Rotation is overwriting."""
access = session.get(PageAccess, 1)
@@ -130,7 +174,17 @@ def _own_prose(text: str) -> str:
somebody points this at an error title from a tracker, the reasoning above should not be the
only thing standing there. Untrusted text goes through `_h`, which is every interpolation.
"""
- return re.sub(r"\*\*([^*]+)\*\*", r"\1", _h(text))
+ return _emphasised(_h(text))
+
+
+def _emphasised(already_escaped: str) -> str:
+ """The `**…**` half of `_own_prose`, on text that has been through `_h` already.
+
+ Separate because `_as_code` needs it and had already escaped and inserted its own ``
+ tags: running the whole of `_own_prose` over that would escape the markup this module just
+ wrote, and serve `<code>` to a reader.
+ """
+ return re.sub(r"\*\*([^*]+)\*\*", r"\1", already_escaped)
def _link(url: str | None, text: str | None = None) -> str:
@@ -197,7 +251,10 @@ def _link(url: str | None, text: str | None = None) -> str:
decision, and this page is a panel rather than a document. */
--ink: light-dark(#101319, #e9ebf0);
--muted: light-dark(#5a6270, #979fae);
- --faint: light-dark(#8b93a1, #666e7d);
+ /* Was #8b93a1/#666e7d, which carried the footer at 2.73:1 light and 3.90:1 dark —
+ under AA, on the sentence that explains what the URL is. Measured, not adjusted
+ by eye: these are the nearest values clearing 4.5:1 on all three surfaces. */
+ --faint: light-dark(#656d7b, #7d8593);
--rule: light-dark(#dfe3ea, #232833);
--canvas: light-dark(#eef1f5, #070910);
--raise: light-dark(#ffffff, #14171e);
@@ -209,6 +266,22 @@ def _link(url: str | None, text: str | None = None) -> str:
--refused: light-dark(#ab2f22, #f0847a);
--human: light-dark(#5638ad, #b193f5);
+ /* The type scale (item 213). Twelve size/weight pairs were on one page and twenty distinct
+ sizes in this stylesheet, eight of them two-decimal one-offs: each reasonable where it was
+ written, none of them reasonable together. Nine steps, and a rule may only name a step. */
+ --t-2xs: .6875rem;
+ --t-xs: .75rem;
+ --t-sm: .8125rem;
+ --t-md: .875rem;
+ --t-base: .9375rem;
+ --t-lg: 1.0625rem;
+ --t-xl: 1.375rem;
+ --t-2xl: 1.75rem;
+ --t-3xl: 2.25rem;
+
+ /* How wide a line of prose is allowed to get. The shell fills the window; sentences do not. */
+ --measure: 68ch;
+
--r: 8px;
--r-chip: 5px;
--pad: 1.15rem;
@@ -222,12 +295,19 @@ def _link(url: str | None, text: str | None = None) -> str:
margin: 0;
background: var(--canvas);
color: var(--ink);
- font: 400 15px/1.55 var(--sans);
+ font: 400 var(--t-base)/1.55 var(--sans);
font-synthesis-weight: none;
-webkit-font-smoothing: antialiased;
}
-.wrap { max-width: 62rem; margin: 0 auto; padding: 0 1.5rem 4rem; }
+/* The shell (item 213). At 1680px the work used 43% of the window: a 62rem measure is a width
+ for a document, and this is a panel. The window is filled and the sentences inside it are held
+ to `--measure`, because the fix for dead margins is not 120-character lines. */
+.wrap { max-width: 108rem; margin: 0 auto; padding: 0 2rem 4rem; }
+.sheet > p, .sheet > .sub, .sheet .why, footer { max-width: var(--measure); }
+/* A headline needs a shorter measure than a paragraph, and `ch` is relative to the element's own
+ size: 68ch at 28px came out 1170px wide, which is a measure in name only. */
+.sheet .lede { max-width: 34ch; }
a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 3px;
text-decoration-color: color-mix(in oklab, currentColor 35%, transparent); }
@@ -241,11 +321,16 @@ def _link(url: str | None, text: str | None = None) -> str:
padding: 1rem 0 .9rem; margin-bottom: 1.6rem;
border-bottom: 1px solid var(--rule);
}
-.mark { font: 600 1.1rem/1 var(--mono); color: var(--ink); }
-.word { font: 600 .95rem/1 var(--sans); letter-spacing: .01em; }
+.mark { font: 600 var(--t-lg)/1 var(--mono); color: var(--ink); text-decoration: none;
+ /* WCAG 2.2 AA 2.5.8 asks 24x24 CSS px, and this measured 10x17 (item 215). It is
+ a standalone control rather than a link inside a sentence, so the Inline
+ exception does not reach it. */
+ display: inline-flex; align-items: center; justify-content: center;
+ min-width: 24px; min-height: 24px; }
+.word { font: 600 var(--t-base)/1 var(--sans); letter-spacing: .01em; }
.bar .spacer { flex: 1; }
.pill {
- font: 550 .68rem/1 var(--sans); letter-spacing: .07em; text-transform: uppercase;
+ font: 550 var(--t-2xs)/1 var(--sans); letter-spacing: .07em; text-transform: uppercase;
padding: .32rem .5rem; border-radius: var(--r-chip);
border: 1px solid color-mix(in oklab, var(--c, var(--faint)) 40%, transparent);
background: color-mix(in oklab, var(--c, var(--faint)) 9%, transparent);
@@ -286,7 +371,7 @@ def _link(url: str | None, text: str | None = None) -> str:
and `font-feature-settings: "zero" 0` does not turn it off because for that face it is not a
feature, it is the glyph. Tabular numerals keep the columns aligned, which was the reason for
mono here in the first place. */
- font: 600 2.5rem/1 var(--sans); font-variant-numeric: tabular-nums lining-nums;
+ font: 600 var(--t-3xl)/1 var(--sans); font-variant-numeric: tabular-nums lining-nums;
letter-spacing: -.035em; color: var(--ink);
}
/* Green only when there is something to be green about: a green `0` under HELD says "good" where it
@@ -294,9 +379,9 @@ def _link(url: str | None, text: str | None = None) -> str:
.cell.won .big { color: var(--passed); }
.cell.won.none .big, .cell.lost.none .big { color: var(--ink); }
.cell.lost .big { color: var(--refused); }
-.name { font: 550 .7rem/1 var(--sans); letter-spacing: .08em; text-transform: uppercase;
+.name { font: 550 var(--t-2xs)/1 var(--sans); letter-spacing: .08em; text-transform: uppercase;
color: var(--muted); margin-top: .35rem; }
-.gloss { font: 400 .72rem/1.35 var(--sans); color: var(--faint); }
+.gloss { font: 400 var(--t-xs)/1.35 var(--sans); color: var(--faint); }
/* --- the answer ------------------------------------------------------------------------------ */
@@ -314,18 +399,169 @@ def _link(url: str | None, text: str | None = None) -> str:
queue it is from the stripe, and colour is worth more where it is scarce. A problem is the one
exception, below, because red there is the message and not a label. */
.lede {
- font: 450 1.7rem/1.28 var(--sans);
+ font: 450 var(--t-2xl)/1.28 var(--sans);
letter-spacing: -.022em; margin: 0; text-wrap: pretty; max-width: 44ch;
color: var(--ink);
}
.lede.bad { color: var(--refused); }
-.lede .also { font: 400 .55em/1 var(--sans); color: var(--muted); letter-spacing: 0; }
+.lede .also { font: 400 var(--t-base)/1 var(--sans); color: var(--muted); letter-spacing: 0; }
.answer .sub { margin: .7rem 0 0; }
/* --- the decisions --------------------------------------------------------------------------- */
/* One card with divided rows, not one card per row: six equally-bordered boxes on a screen is the
same failure as six equally-weighted columns, in a different shape. */
+/* --- what this instance has switched on (items 203, 208) ------------------------------------
+ A panel, not the `decisions` list it borrowed at first: that one carries a fixed amber left
+ border because it means *waiting for you*, so a `cannot` row sat inside an amber stripe and the
+ severity read as the panel's rather than the row's.
+
+ Two columns, and the point of them is the first: the states line up on one edge, so the panel is
+ scanned down rather than read across. The stripe is per row for the same reason. */
+.standing {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1.4rem;
+ background: var(--raise);
+ border: 1px solid var(--rule);
+ border-radius: var(--r);
+ overflow: hidden;
+}
+.standing li {
+ display: grid;
+ grid-template-columns: 6.2rem 1fr;
+ gap: 0 .85rem;
+ align-items: baseline;
+ padding: .8rem var(--pad) .8rem calc(var(--pad) - 3px);
+ border-left: 3px solid var(--c, var(--faint));
+ border-top: 1px solid var(--rule);
+}
+.standing li:first-child { border-top: 0; }
+.standing .pill { justify-self: start; color: var(--c, var(--faint)); border-color: currentColor; }
+.standing .name { font-weight: 550; color: var(--ink); }
+.standing .why {
+ grid-column: 2;
+ color: var(--muted);
+ font-size: var(--t-md);
+ margin: .2rem 0 0;
+ /* These are read, not scanned, so they get a measure. Seen only by opening the page: the rows
+ ran to about 110 characters on a wide window, which is a paragraph pretending to be a row. */
+ max-width: 62ch;
+}
+/* Tight horizontally on purpose. Seen on screen: `.3em` of padding pushes a following comma far
+ enough away to read as a typographic error — `page-token ,` — so the chip is snug and earns its
+ separation from the background rather than from space. */
+.standing code {
+ font: var(--t-sm)/1.35 var(--mono);
+ background: var(--sunk);
+ padding: .05em .18em;
+ border-radius: 3px;
+ border: 1px solid var(--rule);
+}
+@media (max-width: 34rem) {
+ .standing li { grid-template-columns: 1fr; gap: .35rem 0; }
+ .standing .why { grid-column: 1; }
+}
+
+/* --- the rail (item 212, DR-0023) -----------------------------------------------------------
+ Furniture. It does not scroll away and it does not move between pages, so what exists is never
+ something a person has to remember.
+
+ Two shapes, one markup: down the left where there is room, which is the shape the decision
+ named and the one that leaves the whole width to the work; a row of tabs under a narrow window,
+ where a column of five nouns would cost more of the screen than the page it navigates. */
+.rail {
+ display: flex;
+ flex-direction: row;
+ gap: 0 1.4rem;
+ padding: .3rem 0 0;
+ margin: 0 0 1.4rem;
+ border-bottom: 1px solid var(--rule);
+ overflow-x: auto;
+}
+.rail a {
+ padding: .5rem .1rem;
+ color: var(--muted);
+ text-decoration: none;
+ white-space: nowrap;
+ border-bottom: 2px solid transparent;
+}
+.rail a:hover { color: var(--ink); }
+.rail a[aria-current="page"] {
+ color: var(--ink);
+ font-weight: 550;
+ border-bottom-color: var(--ink);
+}
+
+@media (min-width: 60rem) {
+ .wrap {
+ display: grid;
+ grid-template-columns: 13.5rem minmax(0, 1fr);
+ column-gap: 3rem;
+ align-items: start;
+ }
+ .bar, footer { grid-column: 1 / -1; }
+ .rail {
+ grid-column: 1;
+ flex-direction: column;
+ gap: .1rem;
+ padding: 0;
+ margin: 0;
+ border-bottom: 0;
+ overflow-x: visible;
+ position: sticky;
+ top: 1.5rem;
+ }
+ .rail a {
+ padding: .35rem .6rem;
+ border-bottom: 0;
+ border-left: 2px solid transparent;
+ border-radius: 0 var(--r) var(--r) 0;
+ }
+ .rail a:hover { background: var(--raise); }
+ .rail a[aria-current="page"] {
+ border-left-color: var(--ink);
+ background: var(--raise);
+ }
+ .sheet { grid-column: 2; min-width: 0; }
+}
+
+/* The heading row and the primary action on it (item 214). The action wraps under the title on a
+ narrow window rather than squeezing both, because a button that is half a word wide is not a
+ button. */
+.head { display: flex; flex-wrap: wrap; align-items: baseline;
+ justify-content: space-between; gap: .75rem 1.5rem; }
+.head h1 { margin-bottom: .2rem; }
+details.primary { margin: 0 0 .6rem; }
+details.primary > summary {
+ display: inline-block; list-style: none; cursor: pointer;
+ font: 550 var(--t-md)/1 var(--sans); padding: .55rem .9rem;
+ border: 1px solid color-mix(in oklab, var(--working) 40%, var(--rule));
+ border-radius: var(--r-chip); background: var(--raise); color: var(--working);
+}
+details.primary > summary::-webkit-details-marker { display: none; }
+details.primary > summary::before { content: "+"; margin-right: .4rem; font-weight: 600; }
+details.primary[open] > summary::before { content: "\\2212"; }
+details.primary > summary:hover { background: var(--sunk); }
+details.primary[open] {
+ flex: 1 1 100%; background: var(--raise); border: 1px solid var(--rule);
+ border-radius: var(--r); padding: var(--pad); margin-top: .4rem;
+}
+details.primary[open] > summary { border: 0; background: none; padding: .2rem 0 .4rem;
+ min-height: 24px; }
+
+/* The one control on the page that creates something (item 213). */
+.new { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; margin: 1.4rem 0 0; }
+.field { display: flex; flex-direction: column; gap: .3rem; margin: 0; }
+.field label { font: 550 var(--t-2xs)/1 var(--sans); letter-spacing: .06em;
+ text-transform: uppercase; color: var(--muted); }
+.field input {
+ font: 400 var(--t-md)/1 var(--mono); padding: .5rem .6rem; min-width: 12rem;
+ border: 1px solid var(--rule); border-radius: var(--r-chip);
+ background: var(--canvas); color: var(--ink);
+}
+.new button { align-self: end; }
+
.decisions {
list-style: none; padding: 0; margin: 0 0 1.4rem;
background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r);
@@ -333,14 +569,14 @@ def _link(url: str | None, text: str | None = None) -> str:
}
.decision { padding: .85rem var(--pad); border-top: 1px solid var(--rule); }
.decision:first-child { border-top: 0; }
-.decision .what { display: block; font-weight: 550; font-size: .97rem; }
-.decision .meta { display: block; font: 400 .78rem/1.5 var(--mono); color: var(--muted);
+.decision .what { display: block; font-weight: 550; font-size: var(--t-base); }
+.decision .meta { display: block; font: 400 var(--t-sm)/1.5 var(--mono); color: var(--muted);
margin-top: .3rem; }
.decision .decide { margin-top: .7rem; }
.decision .sub { margin: .5rem 0 0; }
/* The "how to sign in" line belongs to the card above it, not to the gap below. */
.decisions + .how { margin: -1.1rem 0 1.4rem; padding: 0 var(--pad);
- font-size: .82rem; color: var(--muted); }
+ font-size: var(--t-sm); color: var(--muted); }
/* --- what it is doing ------------------------------------------------------------------------ */
@@ -354,7 +590,7 @@ def _link(url: str | None, text: str | None = None) -> str:
.phases { display: flex; flex-wrap: wrap; gap: .35rem; margin: .8rem 0 0; padding: 0;
list-style: none; }
-.phase { font: 500 .72rem/1 var(--mono); letter-spacing: .02em;
+.phase { font: 500 var(--t-xs)/1 var(--mono); letter-spacing: .02em;
padding: .38rem .5rem; border-radius: var(--r-chip);
border: 1px solid var(--rule); color: var(--faint); }
.phase.done { color: var(--passed);
@@ -365,12 +601,12 @@ def _link(url: str | None, text: str | None = None) -> str:
background: color-mix(in oklab, var(--working) 11%, transparent); }
.chip { display: inline-flex; align-items: center; gap: .35rem;
- font: 550 .72rem/1 var(--sans); letter-spacing: .02em;
+ font: 550 var(--t-xs)/1 var(--sans); letter-spacing: .02em;
padding: .34rem .5rem; border-radius: var(--r-chip);
border: 1px solid color-mix(in oklab, var(--c, var(--faint)) 35%, transparent);
background: color-mix(in oklab, var(--c, var(--faint)) 9%, transparent);
color: var(--c, var(--muted)); }
-.chip::before { content: "●"; font-size: .6em; }
+.chip::before { content: "●"; font-size: var(--t-2xs); }
.c-working { --c: var(--working); } .c-waiting { --c: var(--waiting); }
.c-passed { --c: var(--passed); } .c-refused { --c: var(--refused); }
.c-idle { --c: var(--faint); } .c-human { --c: var(--human); }
@@ -386,12 +622,12 @@ def _link(url: str | None, text: str | None = None) -> str:
background: var(--sunk); border-radius: var(--r);
}
.tally { display: flex; align-items: baseline; gap: .4rem; }
-.fig { font: 550 1.05rem/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--ink);
+.fig { font: 550 var(--t-lg)/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--ink);
font-feature-settings: "zero" 0; text-decoration: none; }
a.fig { text-decoration-color: color-mix(in oklab, currentColor 35%, transparent); }
a.fig:hover { text-decoration: underline; }
.fig.zero { color: var(--faint); font-weight: 400; }
-.cap { font: 400 .74rem/1 var(--sans); color: var(--muted); letter-spacing: .01em; }
+.cap { font: 400 var(--t-xs)/1 var(--sans); color: var(--muted); letter-spacing: .01em; }
/* --- the item's own views, which the first pass of item 169 left behind ---------------------- */
@@ -410,17 +646,28 @@ def _link(url: str | None, text: str | None = None) -> str:
/* The project view's own copy of the board, which shares `_COLUMNS` with the instance strip so the
two can never disagree about what a column means. */
-.board { display: flex; flex-wrap: wrap; gap: .6rem; margin: 0 0 1.2rem; }
-.col { flex: 1 1 8rem; background: var(--raise); border: 1px solid var(--rule);
+/* Six tallies in a wrapping row of five left `closed` alone across the full width, and two-word
+ labels broke over two lines so one row's cards stood taller than the next. A grid that fits its
+ own columns can do neither. */
+.board { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
+ gap: .6rem; margin: 0 0 1.2rem; }
+.col { background: var(--raise); border: 1px solid var(--rule);
border-radius: var(--r); padding: .8rem var(--pad); }
+/* `.count` had no rule at all — so a project's numbers rendered at paragraph size under labels
+ set in caps — the label outranking the number it labels, on the one view where you compare them.
+ The instance report sets the same pair at 34px and 11.5px. */
+.count { display: block; font: 600 var(--t-2xl)/1 var(--sans); color: var(--ink);
+ font-variant-numeric: tabular-nums; }
+.count.zero { color: var(--faint); font-weight: 450; }
.col.owed { border-left: 3px solid var(--waiting); }
-.age { display: block; font: 400 .74rem/1 var(--sans); color: var(--muted); margin-top: .4rem; }
+.age { display: block; font: 400 var(--t-xs)/1 var(--sans); color: var(--muted);
+ margin-top: .4rem; }
.faint { color: var(--faint); }
/* --- the line that says the checks ran ------------------------------------------------------- */
.settled { display: flex; align-items: baseline; gap: .45rem;
- font-size: .82rem; color: var(--muted); margin: 0 0 1.4rem; }
+ font-size: var(--t-sm); color: var(--muted); margin: 0 0 1.4rem; }
.settled::before { content: "✓"; color: var(--passed); font-weight: 600; }
/* --- everything the evaluator wants, as one block rather than five rules --------------------- */
@@ -431,11 +678,11 @@ def _link(url: str | None, text: str | None = None) -> str:
.more > details:first-child { border-top: 0; }
details > summary {
cursor: pointer; padding: .8rem var(--pad); list-style: none;
- font: 500 .86rem/1.4 var(--sans); color: var(--muted);
+ font: 500 var(--t-md)/1.4 var(--sans); color: var(--muted);
display: flex; align-items: center; gap: .55rem;
}
details > summary::-webkit-details-marker { display: none; }
-details > summary::before { content: "+"; font: 400 .9rem/1 var(--mono); color: var(--faint);
+details > summary::before { content: "+"; font: 400 var(--t-md)/1 var(--mono); color: var(--faint);
width: .7rem; text-align: center; }
/* The typographic minus, which pairs the + above rather than a hyphen. */
details[open] > summary::before { content: "\2212 "; }
@@ -445,18 +692,26 @@ def _link(url: str | None, text: str | None = None) -> str:
/* --- type --------------------------------------------------------------------------------- */
-h1 { font: 600 1.35rem/1.25 var(--sans); letter-spacing: -.015em; margin: 0 0 .3rem;
+h1 { font: 600 var(--t-xl)/1.25 var(--sans); letter-spacing: -.015em; margin: 0 0 .3rem;
text-wrap: balance; }
-h2 { font: 600 .78rem/1 var(--sans); letter-spacing: .07em; text-transform: uppercase;
+h2 { font: 600 var(--t-sm)/1 var(--sans); letter-spacing: .07em; text-transform: uppercase;
color: var(--faint); margin: 1.8rem 0 .7rem; }
-h4 { font: 550 .72rem/1 var(--sans); letter-spacing: .06em; text-transform: uppercase;
+h4 { font: 550 var(--t-xs)/1 var(--sans); letter-spacing: .06em; text-transform: uppercase;
color: var(--faint); margin: 0 0 .5rem; }
+/* A thing's own name, not a section label (item 213). `h2` is styled for headings like *what does
+ not add up*, and reusing it for a project gave a project the weight of a caption. */
+h2.name { font: 600 var(--t-lg)/1.25 var(--sans); letter-spacing: 0; text-transform: none;
+ color: var(--ink); margin: 2rem 0 .15rem; }
+/* A heading that is also a link is still a heading: underlined at rest it reads as body text that
+ happens to be big. The underline arrives on hover, where it answers "can I click this". */
+h2.name a { text-decoration: none; }
+h2.name a:hover { text-decoration: underline; }
p { margin: .7rem 0; }
-.sub { font-size: .84rem; color: var(--muted); }
+.sub { font-size: var(--t-md); color: var(--muted); }
.sub a { color: var(--ink); }
.bad { color: var(--refused); }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
-code { font: 400 .87em/1.4 var(--mono); background: var(--sunk); padding: .12em .32em;
+code { font: 400 var(--t-sm)/1.4 var(--mono); background: var(--sunk); padding: .12em .32em;
border-radius: 4px; }
ul, ol { margin: .7rem 0; padding-left: 1.2rem; }
li { margin: .25rem 0; }
@@ -464,20 +719,21 @@ def _link(url: str | None, text: str | None = None) -> str:
/* --- tables, which are data and should look like it ----------------------------------------- */
-table { border-collapse: collapse; width: 100%; font-size: .87rem; }
+table { border-collapse: collapse; width: 100%; font-size: var(--t-md); }
th { text-align: left; font-weight: 500; color: var(--muted); vertical-align: top;
padding: .4rem 1rem .4rem 0; white-space: nowrap; }
td { padding: .4rem 0; vertical-align: top; }
-table.list { font-size: .84rem; }
+table.list { font-size: var(--t-md); }
table.list th { border-bottom: 1px solid var(--rule); padding-bottom: .5rem;
- font: 550 .7rem/1 var(--sans); letter-spacing: .05em; text-transform: uppercase; }
+ font: 550 var(--t-2xs)/1 var(--sans); letter-spacing: .05em;
+ text-transform: uppercase; }
table.list td { border-bottom: 1px solid var(--rule); padding: .5rem .8rem .5rem 0; }
table.list tr:hover td { background: var(--sunk); }
.wide { overflow-x: auto; }
.facts { display: grid; grid-template-columns: auto 1fr; gap: .35rem 1rem; margin: 0; }
-.facts dt { color: var(--muted); font-size: .84rem; }
-.facts dd { margin: 0; font-family: var(--mono); font-size: .84rem; }
+.facts dt { color: var(--muted); font-size: var(--t-md); }
+.facts dd { margin: 0; font-family: var(--mono); font-size: var(--t-md); }
/* --- the forms that decide ------------------------------------------------------------------- */
@@ -486,21 +742,21 @@ def _link(url: str | None, text: str | None = None) -> str:
text-decoration: underline; cursor: pointer; }
.decide { display: flex; gap: .5rem; flex-wrap: wrap; }
.decide form { margin: 0; }
-.decide button, .login button {
- font: 550 .84rem/1 var(--sans); padding: .5rem .85rem; border-radius: var(--r-chip);
+.decide button, .login button, .new button {
+ font: 550 var(--t-md)/1 var(--sans); padding: .5rem .85rem; border-radius: var(--r-chip);
cursor: pointer; border: 1px solid var(--rule); background: var(--raise); color: var(--ink);
}
-.decide button:hover, .login button:hover { background: var(--sunk); }
+.decide button:hover, .login button:hover, .new button:hover { background: var(--sunk); }
.decide button.go { border-color: color-mix(in oklab, var(--passed) 45%, transparent);
color: var(--passed); }
.decide button.go:hover { background: color-mix(in oklab, var(--passed) 9%, transparent); }
.login { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: 0; }
.login input {
- font: 400 .88rem/1 var(--mono); padding: .5rem .6rem; min-width: 18rem; flex: 1 1 18rem;
+ font: 400 var(--t-md)/1 var(--mono); padding: .5rem .6rem; min-width: 18rem; flex: 1 1 18rem;
border: 1px solid var(--rule); border-radius: var(--r-chip);
background: var(--canvas); color: var(--ink);
}
-.stuck { font: 600 .64rem/1 var(--sans); letter-spacing: .05em; text-transform: uppercase;
+.stuck { font: 600 var(--t-2xs)/1 var(--sans); letter-spacing: .05em; text-transform: uppercase;
color: var(--refused); border: 1px solid currentColor; border-radius: var(--r-chip);
padding: .16rem .34rem; margin-left: .35rem; }
@@ -508,12 +764,13 @@ def _link(url: str | None, text: str | None = None) -> str:
details.evidence > summary { font-family: var(--mono); }
pre { background: var(--sunk); border: 1px solid var(--rule); border-radius: var(--r);
- padding: .9rem 1rem; overflow-x: auto; font: 400 .8rem/1.5 var(--mono); margin: .8rem 0; }
+ padding: .9rem 1rem; overflow-x: auto; font: 400 var(--t-sm)/1.5 var(--mono);
+ margin: .8rem 0; }
details > pre { border-left-width: 3px; }
footer {
margin: 2.5rem 0 0; padding-top: 1.1rem; border-top: 1px solid var(--rule);
- font-size: .78rem; color: var(--faint);
+ font-size: var(--t-sm); color: var(--faint);
}
footer strong { color: var(--muted); font-weight: 550; }
@@ -523,7 +780,7 @@ def _link(url: str | None, text: str | None = None) -> str:
@media (max-width: 40rem) {
.wrap { padding: 0 1rem 3rem; }
- .lede { font-size: 1.3rem; }
+ .lede { font-size: var(--t-xl); }
.tally { flex: 1 1 45%; border-right: 0; border-bottom: 1px solid var(--rule); }
}
"""
@@ -583,6 +840,7 @@ def _document(
acting: Acting = READING,
up: str = "",
state: tuple[str, str] | None = None,
+ here: str = "",
) -> str:
"""The whole page. No script, no external asset, one inlined stylesheet.
@@ -611,13 +869,52 @@ def _document(
f"{_h(title)}\n"
'
\n'
f"{_bar(up=up, state=state)}\n"
- f"{body}\n"
+ # **Every page, from here** (item 212). Rendering it per view is four chances to grow four
+ # opinions about what this product contains, which is the drift five items this week each
+ # cost a day to.
+ f"{_rail(acting, here=here, up=up)}\n"
+ # **And the way in, for the same reason.** It lived inside the instance report, so moving
+ # the front door left a locked-out operator landing on a page that said nothing about the
+ # lockout — a working lockout looking like a broken login, which is the exact failure item
+ # 168 fixed once already.
+ f'{_signing_in(acting, up=up)}\n{body}\n'
f"\n"
"