// 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 ; 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 `
Comment on this // block
` 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); } }