web: say in the embed directive that partials are wanted
//go:embed templates/*.html carried _threads.html by accident of a rule
rather than by saying so: embed excludes a name starting with '.' or '_'
when it walks a directory, not when a pattern matches the file directly.
Measured before changing anything — the partial is in the embedded FS
today, and TestEveryTemplateOnDiskIsEmbedded is what keeps it there.
The "all:" prefix states the rule instead of relying on the reader
knowing the distinction, so the line is right under either reading of it
and cannot be shortened into the directory form without the prefix being
deleted first. The glob stays: "all:templates" is the same intention
with no extension to match, and would compile a stray .DS_Store or an
editor swap file into the release binary, which pages.Load would filter
out in silence.
The comment on the neighbouring static directive moves with it, since
the two forms now differ on the page: that tree is served to a browser
by name, so excluding '.' and '_' is what is wanted there. It also
listed two assets where there are three — proposal.html has loaded
/static/diff.js since the prose differ grew a client side.
web: serve the asset tree for the two methods it answers
r.Mount registers a subtree under every method chi knows, so the routing
table listed all ten against a handler that answers two, and a
same-origin POST to /static/main.min.<sha>.css came back 200 with the
whole 133 KB stylesheet instead of a 405. Nothing was writable and
nothing leaked; the description was wrong, and the table is what chi's
own 405 and anything walking the router read.
chimw.GetHead over the pattern Mount would have appended, so the asset
tree is registered the way every other read route on this surface is.
Nothing of Mount's is lost: its RoutePath shift and its 404/405
inheritance are for a mounted *Mux, and assets.Handler is a plain
handler that reads r.URL.Path and strips the prefix itself.
Measured against a live listener, before and after: the hashed asset
still answers 200 with public, max-age=31536000, immutable and no Vary,
a HEAD still answers the same headers with no body, an
If-None-Match: * still answers 304 carrying the asset policy rather than
the page's private, no-store, and /static, /static/ and a name that is
not there still get this service's own 404 page. POST, PUT, PATCH,
DELETE and OPTIONS now get the rendered 405.
The 405 carries no Allow header, which is chi's limitation rather than
this route's: it hands the list of methods that would have matched only
to its built-in responder, through unexported types, so the custom
handler chimw.RenderRefusals installs cannot emit one. Every 405 on this
surface is missing it, not just this route.
api: tell caches that the write plane answers per credential
Every answer the REST write plane gives is computed from the bearer token on
the request and from nothing else the request line shows: the 201 names a
proposal opened for the agent that presented the credential, the 401 names the
challenge, the 403 says this token may not propose. The mux carried no cache
policy at all, which made it the last credential-varying surface of the service
without one — web/ has it, /mcp has it, and /query got it two commits ago.
The policy is sr-ht-ecore's PrivateCache, the same helper the other three use:
private, no-store with Vary: Cookie, Authorization. Where it goes is a short
question here and was a long one for mountGraphQL, because that Group also holds
api-meta.json and had to keep it publicly cacheable. This mux mounts exactly one
route and it is the credential-varying PUT, so the policy covers the whole mux
and picks up two answers a route-level one would miss: chi's 404 for an address
this surface does not serve, and the resolver middleware's 401, written before
the router has looked at the path.
The honest ranking, which is why this is one line: a PUT is even less cacheable
than the POST /query carries the same policy for, so this is defence in depth
and not a live leak.
The test drives httptest.NewServer and a real client rather than a recorder,
which hands back the live header map and so cannot observe when a header was
set
doc: check the href a review link publishes, not the destination it came from
Mutation found the dangerous-URL refusal had grown two guards for one property:
with an archive supplied, the check on the way out caught what the check on the
way in was for, so removing the incoming one turned nothing red. There is one
check now, on the href, which is the thing that reaches the anchor — and
removing it fails four tests in both configurations.
The relative-link test went the same way: a sibling destination resolves through
doc.Archive's space-wide stem fallback as well, so it passed with the linking
document's directory thrown away. It uses a path-qualified destination now,
which has no fallback.
web: resolve a proposal's links against the revision under review
An internal link inside a proposed document used to reach the review page as the
destination as typed, so following one landed nowhere. The page now reads the
proposal through ProposalReview and resolves each block against the archive of
the revision that block's text is from: the branch tip for the text under
review, the base for a block the proposal deletes.
Every href is pinned to the revision it was resolved at. A document's site path
names the document and not the revision, so an unpinned link out of a proposed
block would land on the approved head — the text the proposal is changing.
Two things the resolver could not say before and says now: a link to a document
that does not exist is marked, and so is a citation of a section the proposal
has renamed, which is the error a proposal introduces and review is the moment
to catch.
service: read a proposal's two revisions as archives
ProposalDiff hands over bytes and a path, so nothing above it can turn a
wikilink in a proposed document into a URL. ProposalReview returns the same
documents plus the whole document set of each side as an archive, built from the
tree walks the diff already does.
Two archives and not one because the review page renders two revisions: a block
the proposal deletes belongs to the base, and resolving its links against the
branch tip would answer for it with the very text the proposal is changing.
Each is built once per proposal rather than once per document; LinkPass is a
parse of every document in the revision. ProposalDiff is left as it was, so the
comment POST path and the MCP surface pay for neither.
doc: let the review renderer resolve against a supplied archive
Inline now takes the linking document's directory and the document set to
resolve against, and wraps whatever it is handed in reviewResolver rather than
holding one. The wrapping is the security boundary: doc.Archive applies no
dangerous-destination check, correctly, because the read plane's documents are
approved and render with unsafe on.
web/ still passes nothing, so the review page is unchanged by this commit.
web: describe the selection the composer actually gets
The composer's note explained that the script prefixes a selected line range
onto it and removes that element on Escape. Nothing prefixes anything any more:
the gutter drag is gone and a selection becomes a quotation in the textarea. The
comment now says that, since it is the one place a reader looks to learn what the
composer promises before they type.
scss: stop calling the margin a flex row, which it is not
scss: mark the block being quoted, not the empty half of its cell
The tint that says "this is the block your comment will attach to" was painted
on the whole text cell. On a full-bleed review that cell runs about 500 pixels
past the end of the measure and another 200 past the margin the conversation
sits in, so the mark was mostly a wash of colour over nothing — which reads as
a rendering defect rather than as a mark.
It is on the block's own text now, plus the two number cells, which is what
"this block" means. The conversation beside it is left alone: a thread is not
part of what is being quoted, and this replaces the rule that had to paint the
page's background back over it.
web: pin the placement a stranded conversation falls back to
writeGroup pairs a notes row with the row before it only when the two belong to
the same block, because the other reading — "put this conversation on whatever
row is above it" — files a critique under a paragraph nobody wrote it about.
The pairing holds by construction today, which is exactly why the branch that
does not was untested: nothing on the page reaches it.
It is reached here directly, with a group holding the notes row alone, and what
it has to prove is that the comment is still on the page.
scss: give a phone its comment back from the gutter
The conversation lives in the block's text cell now, so on a phone it starts
where the text starts — 88px in, behind two number tracks and a sign column.
Measured on a 375px viewport: 257 pixels of comment on a 375 pixel screen.
The full-width notes row this replaced had a rule for exactly that, and it says
why: a review comment is the one thing on the page that has to stay readable at
any width. So the rule moves to where the conversation moved. Below Bootstrap's
sm breakpoint the box is pulled back over the rail, which is empty at that
height — the gutter's number sits at the top of the row and the conversation
hangs off the bottom of it. 345 pixels of the 375 afterwards, and the page
still does not scroll sideways.
web: quote the text you selected instead of dragging line numbers
Dragging the gutter was how a reviewer said what they were talking about, and
it stopped meaning anything when prose started flowing: a paragraph is one row
with one range in the gutter, so a drag could only ever say "this paragraph",
which is what the composer already says. Selecting the words says which words.
They land in the composer as a `> ` quotation — something the reviewer can
edit, delete or keep, and which survives into the posted comment, where it says
what the critique is about to whoever reads the thread later.
The anchor does not move. A comment is still stored against its block's content
hash, heading path and index, which is why the selection is still CLAMPED to
one block: a quote reaching into the next paragraph would have to be filed
under a paragraph it is not about. The clamp does not argue with the mouse — it
takes the part of the selection lying inside the block the selection began in,
and the quote in the composer is the report of what was taken. Measured: a
701-character selection across two blocks quotes exactly the first block's 163
and opens that block's composer.
A quote is not a draft. `hasDraft` ignores a body that is exactly the quotation
this file inserted, and without that every stray selection would leave a
composer that nothing could ever close — "one composer at a time" would decay
into a box beside every block, which is the state the whole hiding rule exists
to prevent. Type anything beside the quote, or edit the quote itself, and it is
a draft like any other: measured, the second selection closes the quote-only
composer and clears its body, and leaves the one holding words open with the
words in it.
The address bar carries `#b-<digest>` — the block, not a range. That id is
honest across revisions by construction, since the digest covers the block's
content hash, so a link into a block that has since been rewritten resolves to
nothing rather than to its neighbour. What is lost is a shareable sub-block
selection; the quote in the comment body carries those two sentences to the
reader of the thread instead of to the follower of a link. A character range
under the same id would in fact be honest for the same reason, and is written
down at the top of diff.js as the option not taken rather than left to be
rediscovered.
The gutter cells and the sign are stripped out of a quote. That matters in one
narrow case, measured with the filter removed: a drag beginning above the diff
is read from its end, so the clamp opens the range before the block's row and
the quote comes out as `> 15–2015–20Спека даёт боту…`.
Selecting somebody's posted comment does nothing at all, deliberately: quoting
a critique into the box that comments on the paragraph would be an answer to
the wrong thing.
web: move a block's conversation into the review page's margin
A thread was a full-width row under its block, so every critique, every reply
and every compose form pushed the two halves of the document apart by its own
height — on a page whose subject is how those paragraphs read together. It now
sits beside the block instead, out in the empty right-hand third of a
full-bleed review.
THE MARGIN IS A CELL AND NOT A COLUMN, which the table forces. The diff has to
be a table (a paragraph is six visual lines and its numbers have to stay on the
first of them), and a table has no column a row can opt into: a rowspanning
fifth column would have to survive a fold cutting a block in half, which it
cannot, and a fifth column on every row would take its width from the text on
a phone. What a table does have is a cell that can hold two things. So the
conversation is written into the block's own text cell and the stylesheet sets
the two side by side.
What that buys is that a row is as tall as its tallest cell: a thread beside a
paragraph now costs the document nothing. What it costs is a conversation
TALLER than its block — a heading with a long critique on it — which still
pushes what follows down, by the difference rather than by the whole.
Two inline-blocks and not a flex row. `display: flex` on a td is the obvious
spelling and it is wrong: the cell stops being a table cell and stops driving
its row's height, and measured in Chrome 151 every commented block painted
over the three rows below it. A code fence keeps its conversation underneath,
because only its last line would make room and one short line in a listing is
a lie about where the scrolling starts.
Below 1200px the two stack and the thread is back under its block, which is
also what it does with JavaScript off. The row model is untouched: writeGroup
folds the notes row into the row before it, the two are adjacent and share a
fold by construction, and a notes row that ever arrived without its block in
front of it still renders as the full-width row it used to be.
The deleted-block strike moved from the cell to the block's own div. It had to:
text-decoration propagates and no descendant can switch it off, so a strike
started on the cell runs through the comments written about the paragraph.
Measured both ways — with the strike back on the cell Chrome 151 leaves the
wide layout alone (an inline-block is an atomic inline and exempt) and strikes
the comment's author, timestamp, anchor and body in the stacked one.
The rule that keeps typed text on the page was ported first. A composer holding
a draft inside a fold that is then shut now keeps the block's whole row drawn
rather than a notes row of its own; measured with the rule removed, the
textarea collapses to 0px with the words still in it.
web: say which of the two guards keeps a comment off the map
Mutation-testing the tick sizes turned up a property with two guards and a
test that cannot tell them apart. mapTicks skips a notes row when it adds up a
block's lines — and deleting that skip changes nothing, because a notes row
carries no gutter number on either side and so already reports zero source
lines through diffRow.sourceLines.
Both the check and its test are kept: what would break the property is a notes
row that grows a number, and then the skip is what holds and the test is what
says so. What is not kept is a comment implying the skip is doing the work.
web: give the review page a map of what changed
The page could say how many blocks changed and could show each of them, and
still refused the question a reviewer arrives with: is this a new document, a
two-word fix, or a rewritten chapter. The map answers it in shape. One tick
per block down the right edge, coloured by what happened to that block, so a
proposal that adds a document is a solid bar, a one-word edit is a single
coloured tick in a grey column, and a reworked section is a band.
Context blocks are ticks too. The coloured ones are only readable against how
much document they sit in — a map of the changes alone draws the same picture
for both ends of that scale. The ladder in tickWeight is deliberately not
linear: a document with one forty-line fence in it would otherwise be a bar of
code with every paragraph beside it too thin to see.
A changed block is a link to itself and an unchanged one is a mark on the
ground, which bounds the links by the size of the change rather than by the
size of the document. A tick that points into a collapsed run opens it: the
new `:has(:target)` rule is what keeps that working with JavaScript off, since
a browser cannot scroll to a row it is not drawing.
Nothing is derived twice. rowBlock now carries the block's own change kind,
because a move-in opens with a marker row and a modified fence opens with one
too, so reading the kind off a block's first row would colour a rewritten
fence as a move. mapTicks reduces the same row stream the table is built from,
so the map cannot claim a block the table does not draw.
The map is aria-hidden and out of the tab order: every tick points at a block
that is already on this page in this order, and a new document would otherwise
put a hundred empty links in front of the keyboard. The stats line above says
the same thing in words.
diff.js adds the one thing markup cannot, the scroll indicator, by observing
the block rows themselves — with the sticky heading band cut out of the root.
Without that, a `tr.ph-head` pins at top: 0 and never leaves the viewport:
measured on a 5000px render of one RFC, scrolled to the bottom, nine of the
fifty-one ticks were still lit and every one of them was a heading long since
passed. Neither getBoundingClientRect nor offsetTop can tell a stuck row from
one that is genuinely at the top.
hooks, mcpsrv: stop two comments and one message from naming stale facts
The push refusal spelled "spec:propose" as a literal — the one line an agent
reads before going to mint a token, so a rename that missed it would send the
agent after a grant this service no longer honours. It now names
authn.ActionPropose, which the grant vocabulary test holds the docs to.
mcpsrv/cache.go said mcphttp was "not resolvable: the ecore commit that adds
mcphttp is not published". It is in the ecore this module pins today. What still
holds the swap is that mcphttp's vary is a package constant, so the comment says
that instead.
authn: enumerate the working-token grant vocabulary and hold the docs to it
spec-oqk asked for the api-meta.json scope list and the locally-checked grant
string to be derived from one source. Reproducing the current declarations
first, as the issue itself asked: that pair already is.
authn/provenance.go const ConfigSection = "spec.sr.ht"
authn/meta.go const ScopeRead = ConfigSection + "/SPECS"
graph/server.go var GrantScopes = []string{metapat.ScopeName(authn.ScopeRead)}
cmd/specsrht/*.go var apiScopes = graph.GrantScopes
apimeta.Handler(apiScopes...) / NewMetaAuth(pats, owner, authn.ScopeRead)
"SPECS" is written once, in a const; the served document and the string
MetaAuth.VerifyToken hands to metapat.Allows both descend from it. Renaming it
moves the served bytes and the check together, measured. The shape is bench's,
coverage's and artifacts' — four services already share it — so nothing here is
worth diverging for. The issue's cited sibling does not support its claim
either: artifacts' core.Grants is the tokens.sr.ht vocabulary and has no bearing
on that repo's api-meta.json, which derives from authn.ScopeRead exactly as this
one does.
What is missing is the other vocabulary, and it is the one the cover:/cov:
lesson was actually about. ActionPropose and ActionRead are checked here and
published nowhere this daemon serves: tokens.sr.ht knows no service's grant
names, so a working token is obtained by a human reading a name out of docs/ and
typing it into another service's mint form. Concretely, a drift costs this:
rename ActionPropose and every token minted for "spec:propose" is refused at
Principal.Authorize — a 403 from the REST and MCP write planes and from
hooks.badTokenMessage on a push — while docs/DESIGN.md keeps telling the next
agent to mint the string that no longer works. Nothing logs the disagreement,
because from the daemon's side each refusal is an ordinary missing grant.
So: Grants enumerates the vocabulary, and grants_test.go holds it against
ConfigSection and against every non-Go file in the repository, in both
directions. This is a test and not a type-level guarantee — the name a document
spells cannot be checked by the compiler — and the pin was mutated both ways to
confirm it fails: renaming a constant reports docs/DESIGN.md naming a grant the
service no longer honours and a grant no file can tell anybody about; dropping a
member reports the constant the enumeration cannot answer for.
api-meta.json is byte-identical across this commit, driven through a real
request against the mounted router.
cmd: tell caches that /query answers per credential
gitx, doc: finish retiring the render cache from the comments
The previous commit corrected the five sites the issue named. Four more said the
same thing and were left because they sat outside that change's file set: the
Blobs map in gitx, Page.Blob in doc, and the two test messages that quote them.
A blob sha is a content identity here, and that is all it is. Saying so in every
place that mentions one is the point: the claim survived this long because nine
files corroborated each other, and a repository that donates code copies its
comments along with it.