// spec.sr.ht stylesheet.
//
// Mirrors the SourceHut service pattern (see paste.sr.ht/scss/main.scss and
// compare.sr.ht/scss/main.scss): pull in the shared `base` partial (Bootstrap 4
// plus the SourceHut chrome — contrast, variables, nav, events, highlight,
// dark) and then add only what is unique to the document views.
//
// Build: `make css`, which is `sassc -I /usr/share/sourcehut/scss scss/main.scss`
// plus minify plus a content-hashed filename. The shared partials come from
// core.sr.ht's `make install`; dart-sass works too for a local build.
//
// The output is EMBEDDED INTO THE BINARY (web/templates.go's //go:embed static),
// so `make css` alone does not restyle a running daemon: build the CSS, then
// `go build`, then restart.
@import "base";
// Let scheme-aware widgets follow prefers-color-scheme, as compare.sr.ht does.
:root {
color-scheme: light dark;
}
// ---- Rendered document ----------------------------------------------------
.spec-doc {
// Prose, not code: a comfortable measure matters more than filling the
// column. The sidebar already takes a quarter of the width, so this only
// bites on very wide screens.
max-width: 46rem;
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5rem;
}
// The first heading sits directly under the page title; the extra top margin
// reads as a gap rather than as structure.
> :first-child {
margin-top: 0;
}
blockquote {
padding-left: 0.75rem;
border-left: 3px solid $gray-400;
color: $gray-700;
@media (prefers-color-scheme: dark) {
border-left-color: $gray-700;
color: $gray-300;
}
}
table {
@extend .table;
width: auto;
}
img {
max-width: 100%;
}
}
// A wikilink that resolved to nothing. The renderer emits it as a span rather
// than dropping it, so the read plane doubles as a link checker — which only
// works if it is visibly different from a link that resolved.
.wikilink-missing {
color: $danger;
text-decoration: line-through dotted;
}
// ---- Search ---------------------------------------------------------------
.search-hit {
margin-bottom: 0.75rem;
}
.search-snippet {
color: $gray-700;
// bleve wraps matched terms in <mark>; the browser default is a yellow that
// is unreadable on the dark chrome.
mark {
padding: 0;
background: transparent;
color: inherit;
font-weight: 700;
}
@media (prefers-color-scheme: dark) {
color: $gray-300;
}
}
// ---- Proposal review ------------------------------------------------------
.proposal-actions {
margin: 1rem 0;
form {
margin-right: 0.5rem;
}
}
.proposal-doc {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid $gray-300;
@media (prefers-color-scheme: dark) {
border-top-color: $gray-700;
}
}
// ---- Prose diff -----------------------------------------------------------
//
// A unified, line-numbered diff table (web/diffrows.go emits it): four columns
// — old number, new number, sign, text — one row per source line, plus a
// full-width `ph-notes` row per block carrying that block's review threads.
//
// This replaced a block-card renderer, and the reasons it was rejected are the
// reasons these rules look the way they do. The cards labelled every block
// ("ADDED PARAGRAPH", "MODIFIED PARAGRAPH") and boxed it; on a new document the
// whole page is one change, so every card carried identical chrome and the
// labels outweighed the prose they described. Two columns of digits say the
// same thing and then get out of the way, which is the whole point of a gutter.
//
// The markup is a TABLE on purpose. Prose lines wrap — often to three or four
// visual lines — and a line's number has to stay on the FIRST of them. Only a
// table cell does that for free (`vertical-align: top`); a flex row centres the
// number and a grid row stretches it.
.prosediff {
// ONE row metric for the whole diff, in absolute units. The gutter is small
// tabular monospace and the prose is larger body text; if each side kept its
// own unitless line-height, the two would build different row boxes and the
// numerals would drift off the text baseline the moment the sizes diverged.
// Everything that participates in a row reads this variable and nothing sets
// its own line-height.
--row-lh: 1.4rem;
// Settled in the bead's review: 34px number tracks. Four digits of tabular
// figures fit; anything wider and the gutter stops being a margin.
--num-w: 34px;
--sign-w: 1.25rem;
// The rail ground and its single hairline are named once here, so light and
// dark differ in one place rather than in a dozen nested media queries.
--rail-bg: #{$gray-100};
--rail-line: #{$gray-300};
--num-fg: #{$gray-600};
// Change tints have to be re-mixed for dark: an alpha that reads as a wash
// over white is invisible over near-black.
--ins-tint: #{rgba($success, 0.1)};
--del-tint: #{rgba($danger, 0.1)};
--move-tint: #{rgba($info, 0.08)};
--sel-tint: #{rgba($info, 0.26)};
font-size: 0.95rem;
@media (prefers-color-scheme: dark) {
--rail-bg: #{$gray-800};
--rail-line: #{$gray-700};
--num-fg: #{$gray-500};
--ins-tint: #{rgba($success, 0.22)};
--del-tint: #{rgba($danger, 0.22)};
--move-tint: #{rgba($info, 0.18)};
--sel-tint: #{rgba($info, 0.3)};
}
.ph-diff {
width: 100%;
// Fixed layout pins the three narrow columns to the widths declared below
// instead of letting the longest line in the document negotiate them, and
// it is what makes the rail the same width in every table on the page.
table-layout: fixed;
border-collapse: collapse;
margin: 0;
}
// Cells never get their own vertical rhythm: they inherit the row metric and
// hang from the top, which is what keeps a number level with the first visual
// line of the wrapped line beside it. `height` on a table cell behaves as a
// minimum, so a blank source line still occupies a full row instead of
// collapsing to nothing.
.ph-row > td {
padding: 0;
line-height: var(--row-lh);
height: var(--row-lh);
vertical-align: top;
}
// ---- the rail ----
//
// The review's exact complaint about the first attempt was that a border on
// each cell drew a CAGE. So: one ground colour behind BOTH number tracks
// (they are adjacent collapsed cells of the same colour, so there is no seam
// to see) and exactly one hairline, on the right edge of the new-number
// track, dividing the rail from the content. No horizontal rules at all — the
// line height is what separates the rows.
.ph-n {
width: var(--num-w);
padding: 0 0.35rem 0 0.15rem;
background-color: var(--rail-bg);
color: var(--num-fg);
font-family: $font-family-monospace;
font-size: 0.78rem;
// Digits must not change width between rows, or the right-aligned column
// jitters as the numbers grow.
font-variant-numeric: tabular-nums;
text-align: right;
// The numbers are chrome, not content: leaving them selectable means every
// copied diff arrives with the gutter interleaved into the prose. The sign
// and the text stay selectable, so a copied selection is still a usable
// diff.
-webkit-user-select: none;
user-select: none;
cursor: default;
}
.ph-n-new {
border-right: 1px solid var(--rail-line);
}
.ph-sign {
width: var(--sign-w);
padding: 0 0.25rem;
font-family: $font-family-monospace;
font-size: 0.78rem;
text-align: center;
}
.ph-text {
padding: 0 0.6rem;
// Fixed table layout has no escape hatch for an unbreakable token, so a
// pasted URL would otherwise push the text column past the page.
overflow-wrap: break-word;
}
// Code fences, frontmatter and raw HTML blocks: significant whitespace, and
// read line by line rather than as a measure of prose.
.ph-mono {
font-family: $font-family-monospace;
font-size: 0.85em;
white-space: pre-wrap;
}
// ---- change tints ----
//
// The tint starts at the SIGN column and never touches the gutter, so the
// rail reads as the frame of the diff rather than as part of the change. This
// is also why the tint is set on the two content cells and not on the row.
.ph-r-ins {
> .ph-sign,
> .ph-text {
background-color: var(--ins-tint);
}
> .ph-sign {
color: $success;
}
}
.ph-r-del {
> .ph-sign,
> .ph-text {
background-color: var(--del-tint);
}
> .ph-sign {
color: $danger;
}
}
// A move is not an edit: the text is unchanged, only its position moved, so
// it gets the informational colour and no red or green.
.ph-r-move {
> .ph-sign,
> .ph-text {
background-color: var(--move-tint);
}
> .ph-sign {
color: $info;
}
> .ph-text {
color: $gray-700;
@media (prefers-color-scheme: dark) {
color: $gray-400;
}
}
}
// A marker row is the renderer speaking: "paragraph moved here (was line 3)".
// It sits in the text column, in the same face as the prose it introduces, and
// a reviewer skimming a moved block would otherwise read it as the block's
// first sentence. Italic and quiet is enough to place it outside the document
// without giving it the weight of a heading.
.ph-marker > .ph-text {
font-style: italic;
font-size: 0.85em;
color: $gray-600;
@media (prefers-color-scheme: dark) {
color: $gray-400;
}
}
// The region fallback (a modified block the word mapper could not attribute
// per line) states a line RANGE in the gutter — "12–15" — because a single
// number there would be a guess. Five monospace glyphs do not fit 34px, and
// the track cannot grow, so the range drops to the proportional face, which
// is narrow enough for a two-plus-two-digit range on one line. A four-digit
// document will still overflow that, so wrapping stays allowed: the range
// then breaks after its dash and its two ends land on the row's first two
// lines. Either way the whole range is in the cell's title.
.ph-region .ph-n {
font-family: inherit;
font-size: 0.62rem;
padding-left: 0;
padding-right: 0.1rem;
white-space: normal;
overflow-wrap: anywhere;
}
// ---- sticky section readout ----
//
// There is no per-hunk breadcrumb any more: it restated a heading that was
// visible a few rows above, and on a new file it restated it on every hunk.
// Instead the heading row itself pins, so the section is only ever named when
// its heading has scrolled off. The background must be opaque or the rows
// scroll through it, and it is the rail colour so the band reads as one strip
// rather than as a heading with a differently-coloured gutter.
//
// The ROW is sticky as well as its cells, and it carries a background of its
// own, because sticky cells alone do not survive a second heading. Every
// heading pins at top: 0 and none of them ever unpins — the containing block
// of a table part is the whole table, not the section — so from the second
// heading onwards two of them share the same 22px band, and the newest one
// has to cover the rest. Measured in Chrome 141: with sticky cells only, the
// earlier heading's TEXT paints above the later heading's cell background and
// the two section names render on top of each other ("## Storalge"); a sticky
// row without a background does the same; a sticky row WITH a background is
// the one combination that hides the older headings completely. The cell rule
// stays underneath it because Safari sticks table cells but not table rows.
tr.ph-head {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--rail-bg);
}
.ph-head > td {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--rail-bg);
font-weight: 700;
}
// ...but a sticky heading that is also an insert has to keep its tint, or an
// added section stops looking added the moment it pins.
.ph-head.ph-r-ins > .ph-sign,
.ph-head.ph-r-ins > .ph-text {
background-image: linear-gradient(var(--ins-tint), var(--ins-tint));
}
.ph-head.ph-r-del > .ph-sign,
.ph-head.ph-r-del > .ph-text {
background-image: linear-gradient(var(--del-tint), var(--del-tint));
}
// ---- selection ----
//
// Set by diff.js when the reviewer drags across the gutter. Unlike a change
// tint this one DOES cover the number cells: the selection is about the lines
// themselves, so the rail is part of what is selected. `background-image`
// rather than `background-color` so it paints over both the rail ground and
// the change tint without having to out-specify either.
.ph-sel > td {
background-image: linear-gradient(var(--sel-tint), var(--sel-tint));
}
// diff.js gives the table a single tab stop and walks the rows with the
// arrow keys, so the focused row has to be visible as a row — inset so the
// ring does not overlap the neighbouring line's tint.
.ph-row:focus-visible {
outline: 2px solid $info;
outline-offset: -2px;
}
// ---- folding ----
//
// A long run of untouched context collapses behind a checkbox, not behind a
// script: folding is the one interaction a reviewer needs before deciding
// whether the page is worth trusting, so it works with JavaScript off.
.ph-folded {
display: none;
}
.ph-fold:has(.ph-fold-cb:checked) .ph-folded {
display: table-row;
}
.ph-fold-head > td {
padding: 0.1rem 0.6rem;
background-color: var(--rail-bg);
border-top: 1px solid var(--rail-line);
border-bottom: 1px solid var(--rail-line);
font-size: 0.8rem;
color: var(--num-fg);
}
// Hidden from sight, NOT from the tab order: `display: none` on the checkbox
// would take the only control of the fold away from the keyboard. This is the
// clip-rect pattern, which leaves the input focusable, and the focus ring is
// drawn on the label so there is something visible to focus.
.ph-fold-cb {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.ph-fold-cb + label {
display: inline-block;
margin: 0;
padding: 0 0.2rem;
cursor: pointer;
text-decoration: underline dotted;
}
.ph-fold-cb:focus-visible + label {
outline: 2px solid $info;
outline-offset: 1px;
}
.ph-fold-cb:checked + label {
text-decoration: none;
opacity: 0.7;
}
// ---- word-level marks ----
//
// A deletion is struck through in danger; an insertion keeps the browser's
// underline off and is coloured in success instead, because a line of
// underlined prose is harder to read than the same line in green. Both carry
// a faint background so a one-word change inside a long line is visible
// without reading the colour.
del {
text-decoration: line-through;
color: $danger;
background: rgba($danger, 0.12);
text-decoration-thickness: 1px;
}
ins {
text-decoration: none;
color: darken($success, 8%);
background: rgba($success, 0.14);
@media (prefers-color-scheme: dark) {
color: lighten($success, 8%);
}
}
// ---- the block's review threads ----
//
// A full-width cell under the block it belongs to. It is indented to the text
// column so a thread lines up with the prose it criticises, and it carries no
// rail: the rail numbers lines, and this row is not a line.
.ph-notes > td {
padding: 0.25rem 0.6rem 0.4rem
calc(var(--num-w) * 2 + var(--sign-w) + 0.6rem);
}
// On a phone that indent is a quarter of the screen, and a review comment is
// the one thing on this page that has to stay readable at any width. Below
// Bootstrap's sm breakpoint the alignment gives way to the text.
@media (max-width: 575.98px) {
.ph-notes > td {
padding-left: 0.6rem;
}
}
// ---- the composer, once JavaScript is in ----
//
// The server renders a shut `<details class="ph-compose">Comment on this
// block</details>` under EVERY block, and it has to: with diff.js absent that
// control is the only way to comment, so removing it from the template would
// take commenting away from anyone without JavaScript. Drawn as-is, though, a
// seventeen-block document shows seventeen identical "Comment on this block"
// summaries, each on its own full-width row with an empty gutter beside it.
// That is the same identical-chrome-under-every-block that got the block-card
// renderer rejected, and it chops the continuous run of numbered lines this
// table exists to be into seventeen pieces.
//
// So the composer is hidden only when there is another way to reach it.
// diff.js marks each table it has successfully wired `.ph-js`, and under that
// class a notes row is drawn only if it has something to show: a thread
// (someone's critique is content, not chrome), an OPEN composer — which is
// how selecting lines brings it back, since diff.js sets `open` on the
// composer of the block whose lines were picked, by mouse or by keyboard —
// or a composer marked `.ph-draft`. Selecting the lines is the point of the
// port; diff.js also writes the one `.ph-hint` line below that says the
// gutter is draggable.
//
// `.ph-draft` is diff.js saying "there are words in this textarea that have
// not been posted", and it is what stops this rule from eating a review
// someone is in the middle of writing. Without it, collapsing the composer
// from its own summary hides the row, and the disclosure control the reviewer
// would click to get their text back goes with it. An empty composer is
// chrome and this rule may hide it; one holding a draft is content, exactly
// as a posted thread is, and stays drawn until it is posted or emptied.
//
// DO NOT "tidy this up" by deleting the composer from threads.html, and do
// not drop the `.ph-js` qualifier: with JavaScript off no table is marked,
// this rule matches nothing, and every block keeps its visible composer,
// which is the only reason hiding it here is honest.
.ph-diff.ph-js tr.ph-notes:not(:has(.ph-threads)):not(:has(.ph-compose[open])):not(:has(.ph-compose.ph-draft)) {
display: none;
}
// ...and the same draft outranks the fold. A notes row inside a collapsed run
// carries `.ph-folded` like the lines around it, so closing the fold again
// hides it — including when the reviewer opened its composer from inside the
// fold and typed into it. Measured in Chrome 150 before this rule: the
// composer stayed `open` with the text intact, its row computed `display:
// none` and the textarea measured 0px tall, so a half-written critique was
// still in the document with nothing on the page to say so.
//
// The rejected fix was to refuse the fold's second click while a composer was
// open inside it. That makes a control that does nothing when clicked, and it
// needs script to enforce, which would take folding away from the no-JS path
// it was built for. Letting the fold close and keeping the one row that holds
// typed text is the honest half: the lines go, the words stay, and the range
// diff.js prefixed onto the note ("Lines 16–17 · Anchors to …") is what says
// which folded lines they were about.
.ph-diff.ph-js tr.ph-notes.ph-folded:has(.ph-compose.ph-draft) {
display: table-row;
}
// The hint diff.js writes above the table, standing in for those seventeen
// summaries. It has to weigh less than any one of them, so it takes the
// gutter's size and colour: one quiet line, above the diff and outside it,
// read once and then ignored.
.ph-hint {
margin: 0 0 0.25rem;
font-size: 0.8rem;
color: var(--num-fg);
}
}
// The composer and each thread state which block they attach to, because
// selection is by line while anchoring is by block — the indirection is real,
// so it is written down rather than left for the reviewer to infer. Server
// rendered, so it is honest with JavaScript off; diff.js only prefixes the
// selected line range onto it. Outside .prosediff because a thread whose anchor
// was lost is rendered on the page itself, away from any diff table.
.ph-anchor-note {
margin: 0 0 0.3rem;
font-size: 0.8rem;
color: $gray-600;
code {
font-size: inherit;
}
@media (prefers-color-scheme: dark) {
color: $gray-400;
}
}
// ---- Review threads -------------------------------------------------------
//
// Rendered both inside a diff block (web/diff.go executes "blockthreads" into
// the block's markup) and, for comments whose anchor is lost, in the page's own
// area — so these styles are outside .prosediff and apply in both places.
.ph-threads {
margin: 0.35rem 0 0.15rem;
}
.ph-thread {
margin: 0.35rem 0;
padding: 0.4rem 0.6rem;
border-left: 3px solid $info;
background: rgba($info, 0.06);
border-radius: 2px;
// A resolved thread is history: it stays readable and stays where it was, but
// stops asking for attention the way an open one does.
&.ph-thread-resolved {
border-left-color: $gray-400;
background: transparent;
opacity: 0.7;
}
}
.ph-thread-head {
font-size: 0.8rem;
margin-bottom: 0.2rem;
.ph-author {
font-weight: 700;
}
.ph-when {
color: $gray-600;
margin-left: 0.35rem;
@media (prefers-color-scheme: dark) {
color: $gray-400;
}
}
.badge {
margin-left: 0.35rem;
}
}
// A comment is prose someone typed, newlines included; the server escapes it and
// the browser must not then reflow it into one paragraph.
.ph-comment-body {
white-space: pre-wrap;
word-break: break-word;
}
.ph-reply {
margin-top: 0.4rem;
padding-left: 0.6rem;
border-left: 2px solid $gray-400;
@media (prefers-color-scheme: dark) {
border-left-color: $gray-700;
}
}
.ph-thread-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.4rem;
}
// The compose and reply forms fold away behind a summary: the review page shows
// one control per block, and a textarea per block would bury the diff.
.ph-form {
font-size: 0.85rem;
> summary {
cursor: pointer;
color: $gray-600;
@media (prefers-color-scheme: dark) {
color: $gray-400;
}
}
textarea {
margin: 0.3rem 0;
}
}
.ph-compose {
margin: 0.2rem 0 0.4rem;
}
// Comments whose block is gone. The warning border is the point: they are shown
// because dropping them would hide a critique, and marked because they no longer
// describe anything on this page.
.ph-lost {
border-top-color: $warning;
.ph-thread {
border-left-color: $warning;
background: rgba($warning, 0.06);
}
}