Artifact a45626de9b108788971cfc9eba9e85e5b3cb41fe6f45105c9f78bda131e9f4f5:
- File
internal/web/stylesheet.go
— part of check-in
[e5c7c186c4]
at
2026-10-03 15:46:01
on branch trunk
— land environments-in-words
Checks tested the merge with trunk at 4d7f88bc08af; trunk has moved since. (user: dmestas, size: 346694) [annotate] [blame] [check-ins using] [more...]
// stylesheet.go is the whole stylesheet as written: the palette // tokens and the rules, the two constants every page's appearance // comes from. // // They were 4,685 of web.go's 32,153 lines, sitting between the page // templates and the handlers. Their own file for two reasons. // // The tests already treat them as a unit and name them directly -- // styleSource is baseHTML plus rawSheet, palette_test.go reads // paletteCSS by name, sheet_test.go and stylesource_test.go both // reach for the literal -- so this codifies a seam that was already // there in everything but the file layout. // // And the drift these accumulated is the kind that comes from living // inside something too large to hold at once: three severity tokens // read by thirteen rules and declared nowhere, a fallback colour that // never met the contrast floor because it sat outside the block the // floor inspects, and two dark selectors carrying different token // sets. None of that survives being looked at. All of it survived // being scrolled past. // // What stays in web.go is composition, not authoring: rawSheet, // leanCSS and the hashed asset URL are about SERVING these bytes. package web // darkTokens is the dark theme's whole token set, written once and // used by both dark selectors: the OS-preference media query and the // explicit data-theme stamp. Two hand-maintained copies is how the // stamp came to be missing the diff colours. const darkTokens = ` --fg: #f0ece4; --bg: #1d2021; --muted: #bdb6ab; --code-bg: #32302f; --rule: #504945; --accent: #d99058; --uname-l: 70%; color-scheme: dark; --idn-l: 72%; --slice-empty: #3a3735; --mark: #665418; --add: #b8bb26; --del: #fc8c7f; --add-row: #2c3122; --del-row: #382724; --add-mark: #384226; --del-mark: #4a2d27; --code-block: #32302f; --syn-kw: #fc8c7f; --syn-str: #b8bb26; --syn-num: #da99aa; --syn-fn: #fabd2f; --syn-type: #8ec07c; --syn-com: #b6a997; --syn-builtin: #fe8e32; --syn-attr: #95b2a7; --surface: #a899841a; --warn: #e3b341; ` // paletteCSS is every colour the site uses, in one place so the // contrast tests can find them all. A colour defined anywhere else // escapes those tests, which is how the highlight and the danger red // came to be wrong in one of the three theme states. const paletteCSS = `/* Gruvbox, after craftdesign.group: a warm cream rather than white and a soft near-black rather than pure black, because a forge is read for long stretches and maximum contrast is tiring rather than legible. --accent carries links and focus. Every pair here is contrast-checked against both the page ground and --code-bg, the secondary surface links also sit on; see TestPaletteContrast, which fails the build rather than letting a nicer-looking colour quietly drop below AA. Three states, not two. An explicit choice stamps data-theme on the root; the default "system" stamps nothing, so only the media query separates light from dark. Each set is therefore declared three times: once bare, once under the media query GUARDED against an explicit light choice, and once under the explicit dark choice — otherwise picking light on a dark-mode phone would do nothing. */ :root { --fg: #282828; --bg: #f3ecda; --muted: #665c54; --code-bg: #e6dcc0; --rule: #d5c4a1; --accent: #a04412; --uname-l: 32%; --mark: #f7dd9a; --add: #605c0b; --del: #9d0006; /* SEVERITY. --ok and --danger are the diff palette under another name: in this design language "removed" and "wrong" are one red, "added" and "right" one green, and a second pair would be two things to keep tuned. Declared once, not per theme, because a custom property resolves at use time -- redefining --del below moves --danger with it. --warn is its own amber because nothing else in the palette is one. Its old value was not a token at all: nine rules read var(--warn, ...) for a property never declared anywhere, so they all rendered the fallback -- #b60 in six places and #b3261e in two, the same severity in two different colours. #b60 scores 3.55 against the cream ground, BELOW AA, and it escaped the contrast floor precisely because it lived outside this block where TestPaletteColoursAreAllChecked could not see it. */ --ok: var(--add); --danger: var(--del); --warn: #7a4a00; --add-row: #e8f0d2; --del-row: #fae4dc; --add-mark: #d3e4ae; --del-mark: #f4cbbd; /* --code-block is the ground under a code block and a diff. In light it is paper, a step LIGHTER than the cream page, so code stands out the way the dark theme's block does; the tan --code-bg stays for inline code spans. */ --code-block: #fbf8ef; /* SYNTAX. Gruvbox's hues, each moved in lightness only until it reads at 4.5:1 on the code ground, the page, both diff rows and both word marks; highlight.CSS refers to these and nothing else. */ --syn-kw: #9d0006; --syn-str: #605c0b; --syn-num: #8d3e70; --syn-fn: #7c510e; --syn-type: #356246; --syn-com: #62584d; --syn-builtin: #a23603; --syn-attr: #076273; /* --surface is the neutral wash under cards and the top bar. It is a warm grey at 10% alpha rather than a colour per theme: one value, and it tints whatever ground it lands on instead of fighting it. --code-bg stays opaque, because code needs a solid block. */ --surface: #a899841a; /* People: hue and chroma band are identity (idn), lightness is the theme's, so the same person reads the same on both grounds without the wheel being recomputed. OKLCH keeps every hue equally loud. */ --idn-l: 56%; --idn-c0: 0.17; --idn-c1: 0.10; /* A cell with nothing in it — a day nobody worked, or under a filter a day that was not this person's. Its own token because --surface is too faint to read as a cell and --rule is a line colour. */ --slice-empty: #e2d8bf; /* What the browser paints the parts of a control it draws itself: the datetime picker's calendar glyph, spinners, scrollbars. Absent, every one of them is drawn for a light page, and on the dark ground the calendar icon is near-black on near-black. */ color-scheme: light; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {` + darkTokens + `} } /* An explicit DARK choice gets the same tokens as a dark OS. It used to get only nine of them: fg, bg, muted, code-bg, rule, accent, uname-l, idn-l and slice-empty. Every diff colour was left at its LIGHT value, so a reader who picked dark from the menu got pale green and pink diff rows on a near-black page, and a dark red --del with nothing like enough contrast against it. The media query had the full set and the explicit stamp did not, which is a drift only the menu path shows -- and nothing walks the menu path by hand. One list now, used twice. */ :root[data-theme="dark"] {` + darkTokens + `} /* An explicit LIGHT choice has to beat a dark OS, native controls included — the media block above already lost that fight on tokens via :root:not([data-theme="light"]), and this is its other half. */ :root[data-theme="light"] { color-scheme: light; } /* The page paints its own ground: a transparent body borrows whatever the browser decided, which is the white flash this palette exists to avoid. */ html { background: var(--bg); } /* The rubber-band area past the top of the page painted --bg while the bar above it is --surface, so overscrolling showed a stripe of the wrong colour behind the header. Refusing the scroll chain is the direct fix and does not require painting html a colour that would then be wrong at the other end. */ html, body { overscroll-behavior: none; } /* iOS Safari "text autosizing" inflates text inside wide scrollable blocks — code in a phone-width pre rendered LARGER than the prose around it. 100% keeps every font at its declared size; this is the documented opt-out, not a workaround. */ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }` // It used to be inlined in every response: 112,118 bytes of <style> // plus a 13,377-byte icon sprite, 40% of a 309,584-byte timeline page, // byte-identical on every request and — being inline — impossible to // cache. The JS assets already carried Cache-Control; the CSS could // not, because it had no URL. Served as a file it is fetched once and // then costs nothing at all, which beats any compression setting. // sheetLiteral is a RAW STRING: a backtick anywhere inside it closes // the literal, and the compiler then reports a syntax error thousands // of lines below the edit that caused it. Quote CSS property names in // comments plainly, without them. const sheetLiteral = ` /* Chat and inbox used to carry their own inline <style> blocks. The strict CSP (style-src 'self') drops a <style> ELEMENT — style-src-attr covers the style="" attribute and nothing else — so the browser kept the tag in the DOM, gave it no sheet, and applied none of it. Silently: no console error a reader would see, no failing test, just a chat log rendering as a numbered list and an inbox bar that never became a flex row. They live here now, in the one served, hashed, cacheable sheet, which is where the CSP was written to insist they be. */ /* The account page's own layout, moved here from two inline <style> elements it used to carry. The strict CSP (style-src 'self') drops a <style> ELEMENT, and drops it SILENTLY: the browser keeps the tag in the DOM, gives it no sheet, and applies none of it. So /me had no two-column rail and no notification matrix styling at all — its rail stacked above the content instead of sitting beside it. The chat log and the inbox were moved here for exactly this reason and the comment above says so; these two were missed. Nothing about the rules changed. They are the same bytes, in the one served, hashed, cacheable sheet the CSP was written to insist they be. */ .pref-matrix{border-collapse:collapse;width:100%;font-size:var(--fs-body)} .pref-matrix th,.pref-matrix td{padding:var(--sp-2);border-bottom:1px solid var(--rule);text-align:left} .pref-matrix th.c,.pref-matrix td.c{text-align:center} .pref-matrix td.when{color:var(--fg)} .pref-when{color:var(--muted);font-size:var(--fs-sm);white-space:nowrap} .pref-unknown{color:var(--muted);font-weight:600;cursor:help} /* The agent badge. Quiet on purpose: it repeats on every row a machine wrote, and a loud one would make an automated timeline unreadable while telling the reader nothing they cannot get from its shape. It is a FACT about the author, not a warning about the content. */ .agent-tag{font-size:0.7em;text-transform:uppercase;letter-spacing:0.06em; color:var(--muted);border:1px solid var(--rule);border-radius:var(--r-4); padding:0 0.35em;margin-left:0.35em;vertical-align:0.1em;white-space:nowrap;cursor:help} .pref-note{color:var(--muted);font-size:var(--fs-md);margin:var(--sp-2) 0} .pref-switches{margin:var(--sp-4) 0;display:flex;flex-direction:column;gap:var(--sp-2)} .pref-banner{border-left:3px solid var(--accent);padding-left:var(--sp-2);margin:var(--sp-3) 0} .pref-mutes{list-style:none;margin:var(--sp-2) 0;padding:0;display:flex; flex-wrap:wrap;gap:var(--sp-2)} .pref-mutes li{display:flex;gap:var(--sp-1);align-items:center; border:1px solid var(--rule);border-radius:var(--r-1);padding:0 var(--sp-2)} .pref-unmute{background:none;border:0;padding:0;font:inherit;font-size:var(--fs-sm); color:var(--muted);text-decoration:underline;cursor:pointer} .pref-unmute:hover{color:var(--accent)} .pushrow{margin-top:var(--sp-6);border-top:1px solid var(--rule);padding-top:var(--sp-3)} .push-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:var(--sp-2)} .push-head b{font-size:var(--fs-lg)} .push-state{color:var(--muted);font-size:var(--fs-sm);border:1px solid var(--rule); border-radius:var(--r-4);padding:0 var(--sp-2);white-space:nowrap} .pushrow[data-push="on"] .push-state{color:var(--add); border-color:color-mix(in srgb,var(--add) 45%,var(--rule))} .push-acts{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-3)} .push-acts .btn{white-space:nowrap} .push-nojs{display:block;color:var(--muted);font-size:var(--fs-sm);margin-top:var(--sp-3); max-width:60ch} .push-msg{display:block;color:var(--muted);font-size:var(--fs-md);margin-top:var(--sp-2)} .push-msg[data-push-err]{color:var(--del)} .push-devices{list-style:none;margin:var(--sp-4) 0 0;padding:0;font-size:var(--fs-md)} .push-devices li{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--sp-2); padding:var(--sp-2) 0;border-top:1px solid var(--rule)} .push-devices .pd-name{color:var(--fg);font-weight:600} .push-devices .pd-when,.push-devices .pd-pending{color:var(--muted);font-size:var(--fs-sm)} .push-devices .pd-pending{font-style:italic} .acct-email-form{display:flex;gap:var(--sp-2);flex-wrap:wrap;margin-top:var(--sp-2)} .email-unset{color:var(--muted);font-size:var(--fs-sm)} .email-unset a{white-space:nowrap} .acct-rail{display:grid;grid-template-columns:12rem minmax(0,1fr);gap:0 var(--sp-6);align-items:start} .acct-rail>.set-body>section{padding:var(--sp-2) 0 var(--sp-5);border-bottom:1px solid var(--rule);scroll-margin-top:0.8rem} .acct-rail>.set-body>section:last-child{border-bottom:0} .acct-rail h2{font-size:var(--fs-lg);margin:var(--sp-2) 0 var(--sp-2)} @media (max-width:720px){.acct-rail{display:block} .set-rail{position:sticky;top:0;z-index:4;background:var(--bg)} .set-rail ul{display:flex;gap:var(--sp-1);border-left:0;overflow-x:auto} .set-rail li{display:contents} .set-rail a{display:inline-block;margin:0;padding:var(--sp-1) var(--sp-3);border-left:0;border-bottom:2px solid transparent;white-space:nowrap} .set-rail a[aria-current]{border-color:var(--accent)}} .chatwrap{position:relative} .chatlog{overflow-y:auto;border:1px solid var(--rule);border-radius:var(--r-2); padding:var(--sp-2) var(--sp-3);margin:0 0 var(--sp-2);list-style:none} .chatlog>li{margin:0} {{- /* The forum's own vocabulary at chat density, but at a chat product's proportions: a 44px gutter holding a 32px mark (an identicon is square art, so it takes a radius, never a circle), the author's hue as a 2px rail down the whole group, and the run's rows separated by 2px against 14px between groups. The rail is drawn with PADDING inside a group rather than margin, so it runs unbroken from the first row of a run to the last. */ -}} .chatmsg{position:relative;display:grid;grid-template-columns:34px 1fr; align-items:start;margin:0;padding:var(--sp-0) var(--sp-2); border-left:2px solid oklch(var(--idn-l) var(--uc, var(--idn-c0)) var(--uh, 0) / 0.6); border-radius:0 var(--r-row) var(--r-row) 0; transition:background var(--t-fast)} {{- /* A robot line is the channel talking about the repository, not a person with a column: no author hue, a muted rail, muted text, and a small mark where a name would be. */ -}} .chatmsg[data-kind="robot"]{border-left-color:var(--rule);color:var(--muted)} .chatmsg[data-kind="robot"] .chat-head b{font-weight:600;color:var(--muted)} .chat-robot{display:inline-block;border:1px solid var(--rule);border-radius:var(--r-1); padding:0 var(--sp-1);margin-right:var(--sp-1);font-size:var(--fs-xs);font-weight:600; letter-spacing:0.06em;text-transform:uppercase;color:var(--muted);vertical-align:1px} .chatlog>li:not(:first-child)>.chatmsg:not([data-grouped]){margin-top:var(--sp-3)} {{- /* One step toward the rule, not a new colour: the row lifts off the page without introducing a surface the palette never declared. The flat wash is the fallback where color-mix is not understood. */ -}} .chatmsg:hover,.chatmsg:focus-within{background:var(--surface)} .chatmsg:hover,.chatmsg:focus-within{background:color-mix(in srgb, var(--rule) 35%, transparent)} .chat-gut{grid-column:1;grid-row:1 / -1;display:flex;align-items:flex-start;padding-top:var(--sp-0)} .chatmsg>*:not(.chat-gut){grid-column:2;min-width:0} /* Sized to the name it sits beside, like every other byline in the forge. At 32px it read as an avatar in a contacts list rather than a mark against a line of text. */ .chat-gut .idn{width:20px;height:20px;border-radius:var(--r-1);vertical-align:0} {{- /* A continuation row has no header, so the gutter carries the one thing it would otherwise never say: the absolute time, on hover, where the mark would have been. */ -}} .chat-clock{font-size:var(--fs-xs);color:var(--muted);font-variant-numeric:tabular-nums; opacity:0;transition:opacity var(--t-fast)} .chatmsg:hover .chat-clock,.chatmsg:focus-within .chat-clock{opacity:1} .chat-head{display:block;line-height:1.4} .chat-head b{font-size:var(--fs-body);font-weight:700;color:var(--fg)} .chat-when{font-size:var(--fs-sm);font-weight:400;opacity:0.55} .chatbody{display:block;font-size:var(--fs-body);line-height:1.5} .chatbody h2,.chatbody h3,.chatbody h4,.chatbody h5,.chatbody h6{ margin:0.4em 0 0.15em;line-height:1.3} .chatbody h2{font-size:var(--fs-lg)} .chatbody h3{font-size:var(--fs-lg)} .chatbody h4,.chatbody h5,.chatbody h6{font-size:var(--fs-body)} .chatbody>p{margin:var(--sp-1) 0} .chatbody>p:first-child{margin-top:0} .chatbody>p:last-child{margin-bottom:0} {{- /* The quote is a card, not a blockquote: mark, name, number, one line, all on one row, and the whole card is the permalink. A reply-to quote and an inline #seq quote are the same card. */ -}} .chat-quote{display:flex;align-items:center;gap:var(--sp-1);overflow:hidden; border-left:2px solid var(--rule);padding:var(--sp-1) var(--sp-2);margin:var(--sp-1) 0; background:var(--surface);background:color-mix(in srgb, var(--rule) 12%, transparent); border-radius:0 var(--r-row) var(--r-row) 0;font-size:var(--fs-md); color:var(--muted);text-decoration:none;white-space:nowrap} .chat-quote .idn{width:16px;height:16px;border-radius:var(--r-1);flex:none;vertical-align:0} .chat-quote b{color:var(--fg);font-weight:600} .chat-quote:hover{background:color-mix(in srgb, var(--rule) 22%, transparent)} .chatreply{font-size:var(--fs-md);color:var(--muted)} {{- /* On touch the toolbar is simply there, under the row and to the right — "point at it" is not an instruction a touch screen can follow. Where there IS a pointer it becomes a floating box at the row's top-right, appearing on hover or focus. */ -}} .chat-toolbar{display:flex;gap:var(--sp-0);align-items:flex-start;justify-content:flex-end;flex-wrap:wrap; margin-top:0;font-size:var(--fs-sm);color:var(--muted)} {{- /* A real tap target. The glyph is 16px because that is what reads right beside 0.9rem text; the BOX around it is 36px, because that is what a thumb hits. Padding does that without making the icon bigger, and the row is only as tall as the icon thanks to the negative margin. 36 is short of the 44 the platform guides ask for; these sit at the end of a row with nothing dangerous beside them, and 44 would make the toolbar taller than the message. */ -}} {{- /* Nothing here sets the display property on a <summary>. Changing a summary's display is a documented way to lose the disclosure's own behaviour in some engines, and what is behind these ones is an edit form and a delete confirmation — the two-step guard on the only irreversible action in the app. Not worth the risk for a box we can put on an inner span instead, so the tap target is that span and the summary keeps the box the UA gives it. */ -}} .chat-icon{color:var(--muted);cursor:pointer;list-style:none;text-decoration:none} .chat-icon::-webkit-details-marker{display:none} .chat-icon-box{display:inline-grid;place-items:center;width:32px;height:32px; vertical-align:middle;border-radius:var(--r-2)} .chat-icon .ic{width:16px;height:16px} .chat-icon:hover,.chat-icon:focus-visible{color:var(--fg);text-decoration:none} .chat-icon:hover>.chat-icon-box,.chat-icon:focus-visible>.chat-icon-box{background:var(--surface)} {{- /* The open control keeps the pressed look, so the panel below is visibly attached to the thing that opened it. */ -}} details[open]>.chat-icon{color:var(--fg)} details[open]>.chat-icon>.chat-icon-box{background:var(--surface)} {{- /* Two modes, and the plain one is the DEFAULT. In flow the panel makes the row taller and pushes the conversation down, which is a layout shift — but it can never be clipped and never needs measuring, so it is what a page with no script gets, and the menu is reachable there by scrolling a few pixels. Where script is running (the log adds .js at init) the panel floats instead: zero layout shift, nothing below it moves, and the toggle handler scrolls it into view if it would open past the log's edge. That is the enhancement — better, never load bearing. */ -}} .chatmenu{position:relative} .chatmenu-panel{display:flex;flex-direction:column;align-items:stretch; margin:var(--sp-1) 0 var(--sp-0);padding:var(--sp-1);border:1px solid var(--rule); border-radius:var(--r-2);box-shadow:var(--shadow-1); background-color:var(--bg); background-image:linear-gradient(var(--surface),var(--surface)); min-width:12rem;text-align:left} .chatlog.js .chatmenu-panel{position:absolute;top:calc(100% + 4px);right:0; z-index:3;margin:0} {{- /* Same rule as above: this class also lands on a <summary>, so the block-level sizing is scoped to the elements where setting it cannot affect a disclosure. */ -}} .chatmenu-item{padding:var(--sp-2) var(--sp-2);border-radius:var(--r-1); color:var(--fg);text-decoration:none;font-size:var(--fs-sm);cursor:pointer; list-style:none;text-align:left;width:100%;box-sizing:border-box} a.chatmenu-item,button.chatmenu-item{display:block} .chatmenu-item::-webkit-details-marker{display:none} .chatmenu-item:hover,.chatmenu-item:focus-visible{background:var(--bg);text-decoration:none} .chatmenu-item.chat-act-danger{color:var(--del)} .chatmenu-note{color:var(--muted);cursor:default;font-size:var(--fs-sm)} .chatdelete>form,.chatpurge>form{margin:0} .chat-act{color:var(--muted);text-decoration:none;background:none;border:0;padding:0; font:inherit;font-size:var(--fs-sm);cursor:pointer} .chat-act:hover,.chat-act:focus{color:var(--accent);text-decoration:underline} .chatdelete{display:inline;position:relative} .chatdelete>summary{cursor:pointer;list-style:none} .chatdelete>summary::-webkit-details-marker{display:none} .chatdelete[open]>form{position:absolute;right:0;top:1.6em;z-index:2; background:var(--bg);border:1px solid var(--rule);border-radius:var(--r-2); padding:var(--sp-1) var(--sp-2);box-shadow:var(--shadow-1);white-space:nowrap} .chat-act-danger{color:var(--del)} .chat-act-danger:hover,.chat-act-danger:focus{color:var(--del);text-decoration:underline} /* THE APP SHELL. A chat is not a document. It used to grow down the page like one, and the arithmetic did not work: the composer began at 697px in a 757px window, so its bottom half sat behind the fixed footer and its Send button was off the screen entirely. You scrolled the PAGE to reach the control you type into, while the log scrolled separately inside it — two scrollbars competing for the same gesture. Now the window is the frame. Header and repo bar keep their height, ONE region scrolls, and the composer is pinned where a composer belongs. dvh, not vh: on a phone vh is the height with the browser chrome hidden, which is why bottom-anchored controls on mobile sites sit under the toolbar until you scroll. min-height:0 on every flex child in the chain is what actually lets the log scroll instead of the whole column growing — a flex item's default min-height is auto, which refuses to shrink below content. */ body.app-shell{height:100dvh;overflow:hidden;display:flex;flex-direction:column; padding-bottom:0} body.app-shell>header,body.app-shell>.repobar{flex:none} {{- /* min-width:0 as well as min-height:0. A flex item's automatic minimum is its MIN-CONTENT width, so one nowrap line anywhere inside — the composer's retention note — made the whole column as wide as that sentence and pushed the conversation off the side of the phone. */ -}} body.app-shell>main{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column; padding:0 var(--sp-3) var(--sp-2);max-width:none;width:auto;margin:0} {{- /* The room is the page. On a desk the top bar and repo tabs stay — they are navigation — but the page heading (the underlined tab already says it), the footer and the card border are document habits, and they went from a widget in a document to a room by leaving. The log takes every pixel that is left. */ -}} body.app-shell>main>h1{display:none} body.app-shell>footer{display:none} body.app-shell .chatwrap{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column;gap:var(--sp-2)} body.app-shell .chatlog{flex:1;min-height:0;margin:0;border:0;border-radius:0;padding-top:var(--sp-2)} body.app-shell .composer{flex:none} {{- /* Width: use it, but not all of it. A line that spans a 1920px window is unreadable; a column pinned at 730 wastes the desk. The conversation and the composer share one measure, centred. */ -}} body.app-shell .chatwrap,body.app-shell .composer{width:100%;max-width:1080px;margin-left:auto;margin-right:auto} {{- /* The rail exists only where there is a pointer and room; the phone's room is the whole screen. On a desk main becomes a grid: rail down the left, the conversation and its composer stacked in the right column. */ -}} .chat-rail{display:none} @media (hover: hover) and (pointer: fine) and (min-width: 40.01rem) and (min-height: 32.01rem){ body.app-shell>main{display:grid;grid-template-columns:224px minmax(0,1fr); grid-template-rows:minmax(0,1fr) auto;column-gap:var(--sp-4);row-gap:var(--sp-2)} body.app-shell .chat-rail{display:flex;flex-direction:column;grid-column:1;grid-row:1 / -1;min-height:0; padding:var(--sp-3) 0 var(--sp-3) var(--sp-1);border-right:1px solid var(--rule)} body.app-shell .rail-top,body.app-shell .rail-about{flex:none} body.app-shell .rail-channels{flex:1;min-height:0;overflow-y:auto} body.app-shell .chatwrap{grid-column:2;grid-row:1} body.app-shell .composer{grid-column:2;grid-row:2} body.app-shell .perma-sub,body.app-shell .chat-gone,body.app-shell .chat-signin{grid-column:2} } .rail-h{margin:0 0 var(--sp-1);padding:0 var(--sp-2);font-size:var(--fs-xs);font-weight:700;letter-spacing:0.1em; text-transform:uppercase;color:var(--muted)} .chat-here+.rail-h{margin-top:var(--sp-4)} .rail-about{margin-top:var(--sp-4)} .rail-channels,.chat-here{list-style:none;margin:0;padding:0;font-size:var(--fs-md)} .rail-channels li{display:flex;align-items:center;justify-content:space-between;gap:var(--sp-2); padding:var(--sp-1) var(--sp-2);border-radius:var(--r-1);color:var(--muted)} .rail-channels li a{color:inherit;text-decoration:none;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .rail-channels li:hover{background:var(--surface);color:var(--fg)} .rail-channels li.on{color:var(--fg);background:var(--surface);box-shadow:inset 2px 0 0 var(--accent)} .rail-hash{color:var(--muted);margin-right:var(--sp-0)} .rail-n{font-size:var(--fs-xs);font-weight:700;background:var(--accent);color:var(--bg); border-radius:var(--r-4);padding:0 var(--sp-1);line-height:1.5;flex:none;font-variant-numeric:tabular-nums} .chat-here li{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2);color:var(--fg)} .chat-here .idn{width:16px;height:16px;border-radius:var(--r-1);vertical-align:0} .chat-here-none{color:var(--muted);font-style:italic} .rail-about{padding:0 var(--sp-2);font-size:var(--fs-xs);color:var(--muted);line-height:1.4} body.app-shell .composer{margin-bottom:var(--sp-2)} /* The bar exists only where the chrome is hidden. */ .chat-bar{display:none} @media (max-width: 40rem), (max-height: 32rem){ /* FULL SCREEN. A conversation on a phone is not a page inside a site — it is the screen. Leaving the header, repo bar and footer in place cost three bands of vertical space on the axis a chat has least of, and left two scroll containers stacked inside a third. The bar below carries the channel name and the way out, so nothing is lost but the chrome. */ body.app-shell>header,body.app-shell>.repobar{display:none} body.app-shell>main{padding:0} .chat-bar{display:flex;align-items:center;justify-content:space-between; gap:var(--sp-2);flex:none; padding:var(--sp-2) var(--sp-3);border-bottom:1px solid var(--rule); background:var(--bg)} .chat-bar-name{font-weight:600} .chat-bar-x{display:grid;place-items:center;width:2rem;height:2rem; border-radius:var(--r-4);color:var(--muted);text-decoration:none} .chat-bar-x:hover,.chat-bar-x:focus{background:var(--surface);color:var(--fg)} .chat-bar-x .ic{width:1.1rem;height:1.1rem} body.app-shell .chatlog{padding-left:var(--sp-3);padding-right:var(--sp-3)} {{- /* The gone state has no log to fill the shell, so its pieces take the padding the log would have carried. */ -}} body.app-shell .perma-sub,body.app-shell .chat-gone{margin-left:var(--sp-3);margin-right:var(--sp-3)} {{- /* The composer is inset on a phone, so it must also be NARROWER by what it is inset. The desk rule gives it width:100% to share the conversation's measure; adding a margin here left the width alone, so the panel was 390px wide inside a 390px screen with 8px of margin — eight past the right edge, with Send hard against it and no gutter to tap beside. Measured at 390px: the box ran 8 to 400. width:auto lets the margins take their room, and it stays stretched because main is a column flex. */ -}} body.app-shell .composer{margin:var(--sp-2);width:auto;border-radius:var(--r-panel)} } .chatedit{display:inline;position:static} .chatedit>summary{list-style:none} .chatedit>summary::-webkit-details-marker{display:none} /* Editing happens IN PLACE, at the width of the message being edited. It used to open a 420px popover floating over that message — you edited text through a box sitting on top of it, which is the shape nothing else uses. Slack, Discord and Element all turn the row itself into the editor; the message keeps its position and the controls sit under it where a form's controls belong. */ .chatedit[open]{flex:1 0 100%;text-align:right} .chatedit[open]>form{display:flex;flex-direction:column;gap:var(--sp-1); margin:var(--sp-1) 0 0;text-align:left} .chatedit textarea{width:100%;box-sizing:border-box;font:inherit;font-size:max(var(--fs-base), 1em);resize:vertical; min-height:3.2em;background:var(--bg);color:var(--fg); border:1px solid var(--rule);border-radius:var(--r-2);padding:0.45em 0.7em} .chatedit textarea:focus,.chatedit textarea:focus-visible{outline:none; border-color:color-mix(in srgb, var(--accent) 45%, var(--rule))} .chatedit-bar{display:flex;align-items:center;justify-content:flex-end; gap:var(--sp-2);font-size:var(--fs-sm);color:var(--muted)} .chatedit-bar .chat-hint{margin-right:auto} {{- /* Where there is a pointer the icons rest a little dimmer and come up on approach — the convention's good half, which keeps the log calm until somebody is actually working with a row. They are never HIDDEN: that is what left a touch reader with nothing, and 0.5 of a muted glyph is still a visible affordance where 0 is not. Touch gets no such query and simply sees them. */ -}} {{- /* Desk density. Where there is a pointer AND room, the actions float at the row's top-right and appear on hover — the shape every desk chat client uses, and the one the phone cannot use because it has no hover. Both remain true at once: the phone keeps its row, and the desk gets a third more messages on screen. An open editor drops the toolbar back into the flow, so the full-width editor is not trapped in a floating box. */ -}} @media (hover: hover) and (pointer: fine) and (min-width: 40.01rem) and (min-height: 32.01rem){ .chatlog .chat-toolbar{position:absolute;top:-12px;right:var(--sp-2);margin:0;z-index:2; background:var(--bg);border:1px solid var(--rule);border-radius:var(--r-2); box-shadow:var(--shadow-1);padding:var(--sp-0);gap:0; opacity:0;pointer-events:none;transition:opacity var(--t-fast)} .chatmsg:hover .chat-toolbar,.chatmsg:focus-within .chat-toolbar, .chatmsg:has(.chatmenu[open]) .chat-toolbar, .chatmsg:has(.chatpurge[open]) .chat-toolbar, .chatmsg:has(.chatdelete[open]) .chat-toolbar{opacity:1;pointer-events:auto} .chatmsg:has(.chatedit[open]) .chat-toolbar{position:static;opacity:1;pointer-events:auto; background:none;border:0;box-shadow:none;padding:0;margin-top:var(--sp-1)} .chat-icon-box{width:28px;height:28px} .chatlog>li:not(:first-child)>.chatmsg:not([data-grouped]){margin-top:var(--sp-2)} } @media (hover: hover) and (pointer: fine){ .chat-toolbar{opacity:0.5;transition:opacity var(--t-fast)} .chatmsg:hover .chat-toolbar,.chatmsg:focus-within .chat-toolbar, {{- /* ANY open disclosure holds the toolbar at full strength, so a confirm button never fades out from under the pointer on its way to it. */ -}} .chatmsg:has(.chatedit[open]) .chat-toolbar, .chatmsg:has(.chatmenu[open]) .chat-toolbar, .chatmsg:has(.chatpurge[open]) .chat-toolbar, .chatmsg:has(.chatdelete[open]) .chat-toolbar{opacity:1} } @media (prefers-reduced-motion: reduce){ .chatmsg,.chat-toolbar,.chat-clock{transition:none} } {{- /* Code, mentions and references, at chat weight. The mention chip is scoped to chat: the forum keeps its byline treatment. */ -}} .chatmsg code{background:var(--code-bg);border-radius:var(--r-1);padding:var(--sp-0) var(--sp-1)} .chatmsg pre{background:var(--code-bg);border:1px solid var(--rule); border-radius:var(--r-2);padding:var(--sp-2) var(--sp-3);overflow-x:auto} .chatmsg pre code{background:none;padding:0} .chatmsg .at{background:var(--code-bg); background:color-mix(in srgb, var(--accent) 18%, transparent); color:var(--accent);border-radius:var(--r-1);padding:0 var(--sp-1);font-weight:600; text-decoration:none} .chatmsg a.ref{opacity:0.6} .chatmsg a.ref:hover,.chatmsg a.ref:focus{opacity:1} {{- /* Two pills on a rule: the unread line in the accent, the day separator neutral. Same shape, so they read as the same kind of thing — a mark in the log, not a message. */ -}} .chat-divider,.chat-date{display:flex;align-items:center;gap:var(--sp-2); margin:var(--sp-4) 0;list-style:none} /* Said again at the list's own specificity, because .chatlog>li is (0,1,1) and .chat-date is (0,1,0): the margin above lost every time and the markers sat flush against the row and against each other — an "Earlier messages" pill touching the day below it with a 0px gap, measured. The same collision zeroed the NEW divider's space on a repository's channel, which is where this rule was first wrong. */ .chatlog>li.chat-divider,.chatlog>li.chat-date{margin:var(--sp-4) 0} .chatlog>li.chat-more{margin:var(--sp-2) 0} .chat-divider::before,.chat-divider::after{content:"";flex:1;height:1px;background:var(--accent)} .chat-date::before,.chat-date::after{content:"";flex:1;height:1px;background:var(--rule)} .chat-divider>span,.chat-date>span{border-radius:var(--r-4);padding:var(--sp-0) var(--sp-2); font-size:var(--fs-xs);font-weight:600;letter-spacing:0.05em;text-transform:uppercase; white-space:nowrap} .chat-divider>span{background:var(--accent);color:var(--bg)} .chat-date>span{background:var(--rule);color:var(--fg)} {{- /* Jump-to-latest is drawn only when the server KNOWS the reader is not at the latest — an older page. Without script that is the only honest answer, so with script off the pill is simply absent rather than wrong. */ -}} /* The floating pill hovers over the log, so the log keeps a lane of padding at the bottom for it to hover IN. Without that it covers the newest message — the one the reader is most likely reading, and the one the pill exists to take them to. */ .chatlog{padding-bottom:var(--sp-6)} {{- /* Glass, not paint. The accent is the page's loudest colour and it was spending it on a control that is, by definition, in the way: it floats over the message somebody is reading. A blurred, muted disc reads as an overlay on the log rather than a call to act. */ -}} .chat-jump{position:absolute;left:50%;transform:translateX(-50%);bottom:var(--sp-3); display:grid;place-items:center;width:2rem;height:2rem;border-radius:var(--r-4); background:var(--surface); background:color-mix(in srgb, var(--bg) 65%, transparent); -webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px); border:1px solid var(--rule);color:var(--muted);text-decoration:none; box-shadow:var(--shadow-1)} .chat-jump:hover{color:var(--fg)} {{- /* It waits. Scrolling back a line, or a scroll that overshoots and settles, is not a request for a control — and a pill that blinks in on every flick of the thumb is noise. Appearing is delayed; LEAVING is immediate, because once the reader is at the end the control is already wrong. The delay lives on the shown rule, so only the transition INTO "shown" carries it. */ -}} .chat-jump-live{opacity:0;visibility:hidden; transition:opacity 120ms ease, visibility 0s linear 120ms} {{- /* The pill floats over the log, so it floated over an open menu too — a caret sitting on top of "Delete". While a disclosure is open the reader is working with one message, not looking for the end of the channel, so the pill stands down. */ -}} .chatwrap:has(.chatmenu[open]) .chat-jump, .chatwrap:has(.chatedit[open]) .chat-jump{opacity:0;visibility:hidden;transition:none} .chat-jump-live.show{opacity:1;visibility:visible; transition:opacity 200ms ease 700ms, visibility 0s linear 700ms} .chat-jump .ic{width:1rem;height:1rem} /* The count rides on the caret rather than replacing it, so the control is the same shape whether or not anything arrived. */ {{- /* The permalink page. Its whole job is to answer "what were they talking about", so the linked message is MARKED rather than merely first — with context on both sides, position no longer says which message the reader followed. */ -}} .perma-sub{margin:var(--sp-2) 0 var(--sp-3);font-size:var(--fs-sm);color:var(--muted)} {{- /* The linked message is ONE card, not a stack of shapes. It used to draw an accent stripe down its left edge — beside the author rail every message already has — inside a tinted band, around a row that kept its own rounded corners and hover wash: four edges and three surfaces for one message, which is what read as striated and cheap. Now: a single hairline in a tint of the accent, all the way round, on a surface one step off the page. The message inside gives up its own rail and its own corners, because the card is already saying everything they said. */ -}} .chat-anchor{position:relative;margin:var(--sp-3) 0;padding:var(--sp-2) 0 var(--sp-1); border:1px solid color-mix(in srgb, var(--accent) 38%, var(--rule)); border-radius:var(--r-3); background:color-mix(in srgb, var(--accent) 5%, var(--surface))} .chat-anchor>.chatmsg{border-left-color:transparent;border-radius:0;background:none} .chat-anchor>.chatmsg:hover,.chat-anchor>.chatmsg:focus-within{background:none} {{- /* The label rides ON the border, the way a fieldset legend does: it names the card without taking a band of its own above the message. */ -}} .chat-anchor-mark{position:absolute;top:0;left:var(--sp-3);transform:translateY(-50%); margin:0;padding:0 var(--sp-1);font-size:var(--fs-xs);font-weight:700;letter-spacing:0.08em; text-transform:uppercase;color:var(--accent); background:var(--bg);border-radius:var(--r-1)} {{- /* Growing the window is a LINK, so it works with no script and the browser's own anchor jump keeps the linked message under the reader's eye across the reload. */ -}} .chat-more{list-style:none;text-align:center;margin:var(--sp-2) 0} .chat-more a{display:inline-block;padding:var(--sp-0) var(--sp-3);border:1px solid var(--rule); border-radius:var(--r-4);font-size:var(--fs-xs);color:var(--muted);text-decoration:none} .chat-more a:hover{color:var(--fg);border-color:var(--muted)} .chat-gone{border:1px solid var(--rule);border-radius:var(--r-2);padding:var(--sp-3)} {{- /* Arriving at a message by its fragment — from a permalink's "open in the channel" — flashes it. Without this the browser scrolls the log to a row that looks exactly like every other row, and the reader has to guess which one they were sent to. */ -}} .chatmsg:target{background:color-mix(in srgb, var(--accent) 14%, transparent); box-shadow:inset 2px 0 0 var(--accent);border-radius:var(--r-2)} @media (prefers-reduced-motion: no-preference){ .chatmsg:target{animation:chat-found 1.6s ease-out 1} } .chatmsg.chat-found{animation:chat-found 1.6s ease-out 1} @keyframes chat-found{ 0%{background:color-mix(in srgb, var(--accent) 30%, transparent)} 100%{background:color-mix(in srgb, var(--accent) 14%, transparent)} } .chat-jump-n{position:absolute;top:-4px;right:-4px;min-width:1.05rem;height:1.05rem; border-radius:var(--r-4);background:var(--fg);color:var(--bg); font-size:var(--fs-xs);font-weight:700;line-height:1.05rem;text-align:center; padding:0 var(--sp-1);font-variant-numeric:tabular-nums} {{- /* The composer: one rounded field with a toolbar strip inside its own border — the note on the left, the key hint and Send on the right, mark-read as a text link between them. The strip is not a second control bar under a box; it is part of the box. */ -}} /* One soft card, and the controls live INSIDE it rather than in a compartment ruled off below. The divider made two boxes out of one object — the field and "the strip under the field" — when what the reader is looking at is a single place where a message is written. The border marks the field, and only while it is being used: the whole composer used to take a 2px accent ring on focus, loud enough to be the brightest thing on the screen while you typed. */ .composer{display:block;min-width:0;margin:0 0 var(--sp-2);background:var(--surface); border:1px solid var(--rule);border-radius:var(--r-panel);padding:var(--sp-1) 0 var(--sp-1)} {{- /* No focus ring on the composer, at either level. Focusing it drew TWO: the sheet's global textarea:focus-visible ring inside, and the panel's own border outside. A box inside a glowing box, every time you tapped to type. The global rule for text fields is THREE declarations, not one — outline:none, border-color:accent, and a 3px box-shadow halo — because a field with a border states focus by colouring the border it has rather than drawing a ring outside it. Cancelling only the outline left the HALO, which is the ring dmestas photographed: .composer-in sets border:0, so the accent border was invisible and the shadow was the whole visible ring. Both go. A text field states its focus with a caret, and this one is the largest, most obvious control on the page; every chat client leaves it unringed for the same reason. The rule is scoped to THIS field, so every other input in the forge keeps the ring it needs to say which of several it is. */ -}} .composer-in:focus,.composer-in:focus-visible{outline:none;box-shadow:none} .composer-in{display:block;width:100%;box-sizing:border-box;border:0;background:none; color:var(--fg);font:inherit;padding:0.5em 1.05em;resize:none;outline:none; field-sizing:content;min-height:2.4em;max-height:9.5em;overflow-y:auto} .composer-bar{display:flex;align-items:center;gap:var(--sp-1);padding:var(--sp-0) var(--sp-2) 0 var(--sp-2)} .composer-spring{flex:1} body:has(.chatlog.js) .chat-read-btn{display:none} {{- /* The strip is a row of ROUND controls: insert and info at the left, preview and Send at the right, the key hint as the quiet text between. Send is the one filled circle, because it is the only thing here anybody came to do. The retention note left the strip entirely — prose in a control row read as neither — and lives behind the info button as a disclosure. */ -}} .cbtn{display:grid;place-items:center;width:34px;height:34px;border-radius:var(--r-4); border:1px solid var(--rule);background:none;color:var(--muted);cursor:pointer; padding:0;font:inherit;flex:none} .cbtn:hover,.cbtn:focus-visible{color:var(--fg);background:var(--surface)} .cbtn .ic{width:16px;height:16px} .cbtn[disabled]{opacity:0.45;cursor:default} .cbtn[aria-pressed="true"]{color:var(--fg);background:var(--surface);border-color:var(--muted)} .cbtn-send{background:var(--accent);border-color:transparent;color:var(--bg)} .cbtn-send:hover:not(:disabled),.cbtn-send:focus-visible:not(:disabled){filter:brightness(1.08);background:var(--accent);color:var(--bg)} {{- /* The preview pane sits where the field sits, at the field's own padding, so toggling reads as the SAME surface changing state rather than a new box appearing. */ -}} .chat-preview{padding:0.5em 1.05em;font-size:var(--fs-body);min-height:2.4em} .chat-preview[hidden]{display:none} .composer-in[hidden]{display:none} /* The reply band: a band OF the composer, sharing its surface and width, a hairline under it the only structure. It copies the shape a quote already has in the log — mark, name, seq, one truncated line — because it produces one. */ .reply-band{display:flex;align-items:center;gap:var(--sp-2);min-width:0; padding:var(--sp-2) var(--sp-3) var(--sp-2) var(--sp-3);border-bottom:1px solid var(--rule)} .reply-band[hidden]{display:none} .rb-arw{width:13px;height:13px;color:var(--muted);flex:none} .rb-idn{flex:none;display:inline-flex} .rb-idn .idn{width:16px;height:16px;border-radius:var(--r-1);vertical-align:0} .rb-name{font-size:var(--fs-sm);font-weight:600;white-space:nowrap;flex:none} .rb-seq{font-size:var(--fs-sm);color:var(--muted);flex:none} .rb-txt{font-size:var(--fs-sm);color:var(--muted);min-width:0;flex:1; overflow:hidden;text-overflow:ellipsis;white-space:nowrap} {{- /* Rendered, not source — the same renderer as the log — but flat: every block the message had becomes inline so the band stays one line, and headings give up their size. What survives is emphasis, code, links; what is dropped is only the geometry. */ -}} .rb-txt *{display:inline;margin:0;padding:0;font-size:inherit;font-weight:inherit; line-height:inherit;border:0;background:none;white-space:nowrap} .rb-txt br{display:none} .rb-txt strong,.rb-txt b{font-weight:600} .rb-txt code{font-family:var(--font-mono);font-size:0.95em} .rb-txt p+p::before,.rb-txt blockquote::before{content:" · "} .rb-x{color:var(--muted);text-decoration:none;flex:none;display:grid; place-items:center;width:26px;height:26px;border-radius:var(--r-4);font-size:var(--fs-md)} .rb-x:hover,.rb-x:focus-visible{color:var(--fg);background:var(--surface)} .mentions{list-style:none;margin:0 0 var(--sp-1);padding:0 1em;display:flex; gap:var(--sp-1);flex-wrap:wrap} .mentions[hidden]{display:none} {{- /* The roster's list wraps under the whole row rather than beside the field: on a phone the row is already a column. */ -}} .roster-suggest{flex-basis:100%;padding:0} .roster-suggest li button{background:none;border:1px solid var(--rule);border-radius:var(--r-1); padding:var(--sp-0) var(--sp-2);font:inherit;font-size:var(--fs-sm);color:var(--fg);cursor:pointer} .roster-suggest li button:hover,.roster-suggest li button:focus-visible{border-color:var(--accent);color:var(--accent)} .roster-suggest li button .r{color:var(--muted)} .inbox-bar{display:flex;gap:var(--sp-4);align-items:baseline;font-size:var(--fs-md); border-bottom:1px solid var(--rule);padding-bottom:var(--sp-1);margin-bottom:var(--sp-1); flex-wrap:wrap} .inbox-f{color:var(--muted);font-weight:400;text-decoration:none;padding-bottom:var(--sp-1)} .inbox-f:hover{color:var(--accent)} .inbox-f.on{color:var(--fg);font-weight:600;box-shadow:inset 0 -2px 0 var(--accent)} .inbox-n{color:var(--muted);font-weight:400} .inbox-spacer{flex:1} .inbox-allread{background:none;border:1px solid var(--rule);border-radius:var(--r-1); padding:var(--sp-0) var(--sp-2);font:inherit;font-size:var(--fs-sm);color:var(--muted);cursor:pointer} .inbox-allread:hover:enabled{color:var(--accent);border-color:var(--accent)} .inbox-allread:disabled{opacity:0.45;cursor:default} .attn-rows{list-style:none;margin:0;padding:0} {{- /* 56px of row, so a thumb has something to hit and the eye has a rhythm to follow: the dot, two lines, the time, the actions. */ -}} .attn-row{display:grid;grid-template-columns:var(--sp-3) minmax(0,1fr) 3.2rem 5.3rem; gap:var(--sp-2);align-items:center;min-height:56px; padding:var(--sp-2) var(--sp-2);border-bottom:1px solid var(--rule); border-radius:var(--r-row);transition:background var(--t-fast)} .attn-row:last-child{border-bottom:0} .attn-row:hover{background:var(--surface)} .attn-row:hover{background:color-mix(in srgb, var(--rule) 35%, transparent)} .attn-dot{width:8px;height:8px;border-radius:var(--r-4);background:var(--accent)} {{- /* The row's width belongs to the page: a long reason takes two lines and stops, the why-line takes one, and the stamp and the control keeps its column whatever the words are, and keeps it on a row that has no control at all — a row with nothing to press must not shift the stamp of the row above it. Before this an eleven-hour-old landing was wider than a five-minute-old one and every row sat somewhere different. */ -}} .attn-reason{font-size:var(--fs-body);font-weight:400;color:var(--fg);text-decoration:none; display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden; overflow-wrap:anywhere} .attn-row[data-unread="1"] .attn-reason{font-weight:600} a.attn-reason:hover{color:var(--accent);text-decoration:underline} .attn-why{font-size:var(--fs-sm);color:var(--muted);margin-top:var(--sp-0); white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .attn-repo{background:var(--rule);color:var(--fg);border-radius:var(--r-1);padding:0 var(--sp-1)} .attn-when{font-variant-numeric:tabular-nums;font-size:var(--fs-sm);color:var(--muted); text-align:right;white-space:nowrap} .attn-acts{display:flex;gap:var(--sp-1);align-items:center;justify-content:flex-end; font-size:var(--fs-sm);white-space:nowrap} .attn-acts form{display:inline} {{- /* Done is the action this page exists for, so it is the only filled thing on the row; Unfollow is a text link beside it, not a second button of equal weight. */ -}} .attn-done{display:block;width:100%;padding:var(--sp-0) var(--sp-1);background:none; border:1px solid var(--rule);border-radius:var(--r-row); font:inherit;font-size:var(--fs-xs);color:var(--muted);cursor:pointer;white-space:nowrap} .attn-done:hover,.attn-done:focus-visible{border-color:var(--accent);color:var(--accent)} {{- /* Quiet until the row is reached — the chat log's own rule, so a long list reads as a list and not as a wall of buttons. Where there is no hover there is no "point at it", so they stay. */ -}} @media (hover: hover) and (pointer: fine){ .attn-acts{opacity:0;pointer-events:none;transition:opacity var(--t-fast)} .attn-row:hover .attn-acts,.attn-row:focus-within .attn-acts{opacity:1;pointer-events:auto} } @media (prefers-reduced-motion: reduce){.attn-acts,.attn-row{transition:none}} {{- /* A fault reached from the inbox is the one the reader was told about, so the page says which of its rows that is rather than leaving them to find it in a list. */ -}} .exc-item:target{background:color-mix(in srgb, var(--accent) 10%, transparent); border-radius:var(--r-row);scroll-margin-top:var(--sp-6)} .attn-none{color:var(--muted)} .inbox-older{margin-top:var(--sp-3);font-size:var(--fs-md)} .inbox-settings{margin-top:var(--sp-6);border-top:1px solid var(--rule); padding-top:var(--sp-3);font-size:var(--fs-body)} .inbox-settings>summary{cursor:pointer;color:var(--muted);font-size:var(--fs-body)} .inbox-settings .inbox-pref{margin:var(--sp-3) 0} .inbox-settings .inbox-pref small{display:block;color:var(--muted);margin-top:var(--sp-1)} body { color: var(--fg); background: var(--bg); font: var(--fs-base)/1.5 var(--font-sans); margin: 0; padding: 0; } /* The bars span the viewport; their contents, and the page below them, share one column. --page is the single place that column's width is decided. */ :root { --page: 76rem; } /* The measure the newer surfaces are drawn on: one spacing scale, one radius scale, one shadow, one transition. Written once here so that "a bit tighter" is an edit to a number rather than a sweep through forty rules, and so two surfaces cannot disagree about what "the small gap" is. No colour lives here — colour is the palette's. */ :root { /* --sp-0 is the hairline step: the vertical padding of a chip or a code span, where 4px would already read as loose. */ --sp-0: 2px; --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; /* TYPE. One face for prose, one for code, and seven sizes. The app had sixty-eight distinct font sizes, most of them between 11 and 14 px, so nothing stood out from anything; TestTypeStaysOnTheScale keeps every size a step here (or an em, relative to its parent). */ --font-sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif; --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; --mono: var(--font-mono); --fs-xs: 0.75rem; --fs-sm: 0.8125rem; --fs-md: 0.875rem; --fs-body: 0.9375rem; --fs-base: 1rem; --fs-lg: 1.25rem; --fs-xl: 1.75rem; --r-1: 4px; --r-2: 8px; --r-3: 12px; --r-4: 999px; /* A soft panel: rounder than a card, nothing like a pill. */ --r-panel: 20px; /* The in-between radius the rows and their toolbar share: smaller than a card, larger than a code span. */ --r-row: 6px; --shadow-1: 0 2px 8px rgba(0, 0, 0, 0.2); --t-fast: 150ms; } /* One source for the page's horizontal gutter. A full-bleed strip subtracts it back out, so a literal that disagrees with this one hangs the strip over the edge — which is exactly what the rail did at phone width, where the gutter halves and the bleed did not. */ body { --gutter: 1rem; } main { max-width: var(--page); margin: 0 auto; padding-left: var(--gutter); padding-right: var(--gutter); } /* Room for the fixed footer, so the last line of a page is readable. */ /* One source for the footer's height, so the space reserved for it and the bar itself cannot disagree. The gap is deliberate: clearing the footer by a hair still reads as content running into it. */ :root { --footer-h: 2.25rem; } body { padding-bottom: calc(var(--footer-h) + 2rem + env(safe-area-inset-bottom)); } /* Centred rather than baseline-aligned, so the two halves sit on one optical line inside a bar of fixed height. */ footer { font-size: var(--fs-sm); } /* One line, never two: the page reserves a fixed height for this bar, so a bar that wraps overlaps the content it was supposed to sit below. The left half truncates instead — version and render time are diagnostics, and they are what a narrow screen can best afford to lose. The links never shrink. */ footer .bar { display: flex; flex-wrap: nowrap; gap: var(--sp-4); align-items: center; min-height: var(--footer-h); padding-top: var(--sp-1); padding-bottom: var(--sp-1); } .foot-l { min-width: 0; display: flex; white-space: nowrap; } /* The timing never shrinks and never truncates; the two ahead of it do, and drop out entirely when even truncated they would crowd it. The per-store BREAKDOWN is not in that protected set. On a phone the whole string outruns the viewport on its own, and being flex:none it then printed straight over the links rather than giving way — so the detail drops at the same width the brand does, and the total, which is what the footer is for, survives. */ .f-time { flex: none; } .f-brand, .f-ver { min-width: 0; overflow: hidden; text-overflow: ellipsis; } @media (max-width: 34rem) { .f-brand, .f-ops { display: none; } } @media (max-width: 26rem) { .f-ver { display: none; } } .foot-r { flex: none; } .foot-r { margin-left: auto; display: flex; gap: var(--sp-4); white-space: nowrap; } details > summary { cursor: pointer; padding: var(--sp-2) 0; } pre.licence { max-height: 24rem; overflow: auto; font-size: var(--fs-sm); white-space: pre-wrap; } /* The bars use the viewport, not the content column: forgejo puts the forge name at the far left and the account at the far right, which reads as chrome. Content stays in the column below them. */ .bar { padding-left: var(--gutter); padding-right: var(--gutter); } .repobar .bar { max-width: var(--page); margin: 0 auto; } main { padding-top: var(--sp-4); } /* Present to the document and to a screen reader, absent to the eye: a page still needs one h1 even when repeating it on screen would say nothing a reader cannot already see. */ /* Explore tabs: the same pill vocabulary as the repo nav, so the two navigations do not look like different systems. */ nav.tabs { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: 0 0 var(--sp-4); } nav.tabs a { display: inline-flex; align-items: center; gap: var(--sp-1); padding: var(--sp-1) var(--sp-3); border-radius: var(--r-4); text-decoration: none; border: 1px solid transparent; color: var(--fg); } nav.tabs a:hover { border-color: var(--rule); } nav.tabs a.here { background: var(--surface); border-color: var(--rule); font-weight: 600; } nav.tabs .n { color: var(--muted); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; } ul.people { margin: 0; } ul.people li { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); } .avatar.sm { width: 1.75rem; height: 1.75rem; font-size: var(--fs-xs); margin: 0; flex: none; } .p-main { min-width: 0; } .p-meta { color: var(--muted); font-size: var(--fs-md); } .person-head { display: flex; align-items: center; gap: var(--sp-4); margin-bottom: var(--sp-4); } .person-head h1 { margin: 0; } /* Said plainly, once, where the conflation would otherwise be read as a fact about a person. */ .p-warn { border-left: 3px solid var(--accent); padding: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-4); color: var(--muted); font-size: var(--fs-body); } .p-warn strong { color: var(--fg); } .avatar.lg { width: 3rem; height: 3rem; font-size: var(--fs-base); margin: 0; flex: none; } /* Headings SCALE. The browser default is 2em at every width, which on a phone turns a real subject line into three lines of shouting and made clipping look necessary — it was not: the clip only hid the words. A heading that fits can say the whole thing, and the pathological case (a message with no subject line at all, where the title IS the body) is already handled by dropping the visible heading entirely. */ h1 { font-size: clamp(var(--fs-lg), 5vw, var(--fs-xl)); line-height: 1.2; font-weight: 700; letter-spacing: -0.01em; overflow-wrap: break-word; } /* The browser's h2 and h3 are 1.5em and 1.17em of whatever they sit in, so the same heading measured differently on every page. One step each: a section is lg, a subsection base, both semibold. */ h2 { font-size: var(--fs-lg); line-height: 1.3; font-weight: 600; } h3 { font-size: var(--fs-base); line-height: 1.4; font-weight: 600; } /* Controls and code otherwise take the browser's own faces: buttons and inputs its system UI font, code its default monospace. */ button, input, select, textarea { font-family: inherit; } code, kbd, samp, pre { font-family: var(--font-mono); } /* A name inside an uppercase label keeps its own case: branch names are case-sensitive, and "A-RUNNER-YOU-DOWNLOAD" names nothing. */ /* A div, not a p: a form inside a <p> closes the paragraph, and the 'gate history' link fell to a line of its own behind a stray dot. */ .ci-gate-foot { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-2); } .keep-case { text-transform: none; letter-spacing: normal; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } .topbar { background: var(--surface); border-bottom: 1px solid var(--rule); } /* Three columns rather than a flex row: the search belongs in the middle of the bar, and centring it with auto margins fights the avatar's own push to the right. The side columns take equal width so the box is centred on the VIEWPORT, not on whatever is left after the forge name — which would move it every time the name changed. */ .topbar .bar { display: grid; grid-template-columns: 1fr minmax(7rem, 34rem) 1fr; gap: var(--sp-3); align-items: center; padding-top: var(--sp-3); padding-bottom: var(--sp-3); } .topbar .site { justify-self: start; } .bar-acts { justify-self: end; display: flex; align-items: center; gap: var(--sp-2); } .topbar .find { justify-self: stretch; } .repobar { border-bottom: 1px solid var(--rule); } /* One row, always: the collapse exists precisely so the bar never has to wrap. The repository name yields (truncating with an ellipsis) before the tabs do — the tabs are where the reader is going. */ .repobar .bar { display: flex; gap: var(--sp-3); align-items: baseline; flex-wrap: nowrap; padding-top: var(--sp-2); padding-bottom: var(--sp-2); min-width: 0; overflow: clip; } /* Fixed and full width, like the bar at the top: the page scrolls under it. body carries matching bottom padding so the last line of content is never hidden behind it. */ footer { position: fixed; bottom: 0; left: 0; right: 0; z-index: 5; background: var(--surface); backdrop-filter: blur(6px); color: var(--muted); border-top: 1px solid var(--rule); margin: 0; /* .bar owns the inner padding. The bottom inset is the iPhone home-indicator strip: with Safari's chrome collapsed it overlays a bottom:0 bar, and the render time sat under it. viewport-fit=cover (the meta tag) is what makes the env() non-zero there; everywhere else it is 0 and inert. */ padding: 0 0 env(safe-area-inset-bottom) 0; } table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: var(--sp-1) var(--sp-3) var(--sp-1) 0; vertical-align: top; } /* An action cell is a row of buttons, not a stack of forms. */ td.acts { display: flex; gap: var(--sp-2); align-items: center; padding-top: var(--sp-1); } td.acts form { display: contents; } /* A link dressed as the buttons beside it, for actions that are a page (edit) rather than a post. */ td.acts a.btn { display: inline-block; background: var(--code-bg); font-weight: 600; font: inherit; font-weight: 600; color: var(--fg); text-decoration: none; border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-1) var(--sp-2); line-height: 1.2; } td.acts a.btn:hover { border-color: var(--fg); } td.acts button, td.acts a.btn { white-space: nowrap; } /* The mirrors table: host-only upstream, one-line truncated error, and a kebab disclosure for the actions that are not "pull". */ .m-up a { text-decoration: none; } .m-state .m-err { display: block; max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); } tr.m-failing .m-state strong { color: var(--accent); } .kebab-btn { font-weight: 700; line-height: 1.2; padding: var(--sp-1) var(--sp-2); } /* Without anchor positioning the row menu falls back to the account menu's corner; with it, it hangs off its own button. */ @supports (anchor-name: --a) { .menu.km { top: anchor(bottom); right: anchor(right); margin-top: var(--sp-1); } } form.confirm { margin-top: var(--sp-4); } /* The rows list: GitHub's list-row shape. Wide, a row is a grid of main | actions with the meta line under the name; narrow, the same row stacks and the actions drop beneath. Problems get a left rule in the failure colour so they read before they are read. */ .fleet-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); margin: var(--sp-1) 0 var(--sp-3); } .fleet-sum { margin: 0; flex: 1 1 14rem; } .fleet-bulk { display: flex; gap: var(--sp-2); } .ci-fail-text { color: var(--accent); } ul.rows { margin: 0 0 var(--sp-4); } ul.rows > li.row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-1) var(--sp-4); align-items: start; padding: var(--sp-2) var(--sp-4); } ul.rows > li.row-problem { border-left: 3px solid var(--accent); padding-left: var(--sp-2); } .row-main { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-2); } .row-name { font-weight: 600; text-decoration: none; } .row-meta { flex-basis: 100%; color: var(--muted); font-size: var(--fs-md); overflow-wrap: anywhere; } .row-meta .n { font-variant-numeric: tabular-nums; } .row-err { flex-basis: 100%; color: var(--muted); font-size: var(--fs-md); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row-acts { display: flex; gap: var(--sp-2); align-items: center; white-space: nowrap; } .row-acts form { display: contents; } .row-acts button, .row-acts a.btn { font-size: var(--fs-md); padding: var(--sp-1) var(--sp-2); } .row-acts a.btn { display: inline-block; background: var(--code-bg); font: inherit; font-size: var(--fs-md); font-weight: 600; color: var(--fg); text-decoration: none; border: 1px solid var(--rule); border-radius: var(--r-row); line-height: 1.2; } .tl-badge.m-failing, .tl-badge.ci-fail { border-color: var(--accent); color: var(--accent); } .tl-badge.m-lagging { color: var(--muted); } details.rows-fold > summary { cursor: pointer; color: var(--muted); padding: var(--sp-2) var(--sp-1); } @media (max-width: 40rem) { ul.rows > li.row { grid-template-columns: minmax(0, 1fr); } .row-acts { justify-content: flex-start; } .fleet-bulk { width: 100%; } .fleet-bulk form { flex: 1; display: contents; } .fleet-bulk button { flex: 1; } /* The page must never scroll sideways; a wide table scrolls inside its own box. */ main { overflow-x: clip; } } /* A labelled control is a column: label over input. Side-by-side put the trailing unit on its own line and made the row unreadable. */ form.addform { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: end; margin-top: var(--sp-4); padding-top: var(--sp-4); border-top: 1px solid var(--rule); } form.addform label { display: flex; flex-direction: column; gap: var(--sp-1); font-size: var(--fs-md); color: var(--muted); min-width: 0; } form.addform label.grow { flex: 1 1 16rem; } form.addform input { max-width: 100%; } /* Narrow: the row becomes a column. Wrapping alone is not enough, because a text input keeps its size attribute's width and carries the controls after it off the screen. */ @media (max-width: 34rem) { form.addform { flex-direction: column; align-items: stretch; } form.addform label, form.addform label.grow { flex: none; } form.addform input[type="text"], form.addform input[type="email"], form.addform input:not([type]) { width: 100%; } form.addform label.tl-meta-inline { flex-direction: row; align-items: center; gap: var(--sp-2); } } form.addform input { color: var(--fg); } /* The mirror form's unversioned import: a row of its own under the address, the switch reading as a sentence. A bare fieldset takes min-inline-size: min-content and holds a phone's form wide. */ form.addform fieldset.mirror-uv { flex: 1 1 100%; display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: end; min-inline-size: 0; margin: 0; padding: 0; border: 0; } form.addform label.mirror-uv-switch { flex-direction: row; align-items: center; gap: var(--sp-2); color: var(--fg); align-self: center; } form.addform fieldset.mirror-uv small { flex: 1 1 100%; } @media (max-width: 34rem) { form.addform fieldset.mirror-uv { flex-direction: column; align-items: stretch; } form.addform label.mirror-uv-switch { align-self: flex-start; } form.addform input[type="password"] { width: 100%; } } form.rowform { margin: var(--sp-3) 0 0; } th { border-bottom: 1px solid var(--rule); } /* Links carry the accent rather than inheriting the body colour: with inherit the only cue was the underline, and a page of prose and a page of links looked identical at a glance. */ a { color: var(--accent); } /* A focus ring says "here you are", not "look at this". The accent made every tab stop shout in the brand colour; --muted is neutral and still clears WCAG 1.4.11's 3:1 against both grounds (8.2:1 dark, 5.5:1 light) — where --rule, the obvious "lighter" choice, is 1.9:1 and would not legally be an indicator at all. */ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--muted); outline-offset: 1px; } /* A text field already has a border, so a ring drawn outside it reads as two boxes. Focus colours the border it has and adds a soft halo. */ input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]):focus-visible, select:focus-visible, textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent); } .topbar .site { font-weight: bold; text-decoration: none; display: inline-flex; align-items: center; } /* The wordmark: QUARRY set in Iosevka Fixed Semibold, traced to outlines so no reader downloads a font for six letters. The Q's tail descends below the baseline by design — the viewBox keeps it. The phone keeps only the round mark. */ /* The box is CAP HEIGHT only — the Q's tail overflows below it, as in the source design, so the mark centres on the letters and the tail costs no layout space. */ /* Cap height equals the ring's outer diameter: the mark is 1.75rem over a 24-unit box whose ring spans 17.2 units (r 7.2 + half the 2.8 stroke each side), so 1.75 x 17.2/24. */ .topbar .site .wordmark { height: 1.254rem; width: auto; display: block; margin-left: var(--sp-2); overflow: visible; align-self: center; } @media (max-width: 34rem) { .topbar .site .wordmark { display: none; } } /* Sized in CSS as well as in the attributes: the attributes reserve the space before the image arrives (no reflow), the CSS keeps it right if the asset is ever replaced at another resolution. */ .topbar .site .mark { width: 1.75rem; height: 1.75rem; display: block; } /* The repository is the subject of every page under it, so it reads as the heading and "quarry" recedes to the crumb it is. Without this the two sit at equal weight and the forge name — identical on every page — competes with the one word that changes. */ .repobar .repo { font-weight: bold; font-size: var(--fs-lg); text-decoration: none; min-width: 3ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Shrinking is shared in proportion to width, so a long name left the nav its share of the cut and the lit tab read as a "U". The name takes the cut first; repotabs.js folds tabs when even that is not enough. */ .repobar .bar > .repo { flex-shrink: 1000; } /* The posture badge: a chip beside the name, GitHub's "forked from" shape. failing paints its border, nothing else — the notice below the bar carries the words. */ .repobar .posture { display: inline-flex; align-items: center; gap: var(--sp-1); flex: none; font-size: var(--fs-sm); color: var(--muted); text-decoration: none; border: 1px solid var(--rule); border-radius: var(--r-4); padding: var(--sp-0) var(--sp-2); white-space: nowrap; } .repobar .posture:hover { color: var(--fg); border-color: var(--muted); } .repobar .posture-failing { border-color: var(--accent); color: var(--fg); } .repobar .posture-warn { font-size: var(--fs-md); color: var(--muted); padding-top: 0; padding-bottom: var(--sp-2); } /* Narrow: the chip keeps its icon and drops its words — the bar's one line belongs to the nav, and the title attribute still says it all. */ @media (max-width: 48rem) { .repobar .posture .words { display: none; } .repobar .posture { padding: var(--sp-1) var(--sp-2); } } /* Explore names the posture in a word, the same chip family as "private": a glyph alone read as decoration. */ .fleet .r-name .tl-badge.posture-badge { margin-left: var(--sp-1); color: var(--muted); } /* Search sits with the chrome now rather than under it. It takes the leftover width so it is the element that shrinks when the header is tight, which is right: a repository's name must stay readable and a search box is usable at any width. */ .topbar .find { min-width: 0; margin: 0; } .topbar .find input { width: 100%; } /* The avatar is initials, not an image: quarry has no avatar store and inventing one to fetch a gravatar would send every reader's identity to a third party. */ /* A tooltip for controls that are genuinely icon-only, after Basecoat: a ::after carrying content: attr(data-tip), positioned against the trigger, no script at all. Two deliberate departures from Basecoat. It shows on :focus-visible as well as :hover — Basecoat explicitly hides its tooltip for keyboard users, which makes an icon-only control unnameable without a mouse. And it is anchored where the browser can do it, so an overflow:hidden ancestor cannot clip it; the absolute fallback is what Basecoat does everywhere. What this still cannot do is touch: there is no hover on a phone. popover=hint with interestfor handles hover, focus AND long-press declaratively, but Safari does not implement it, so it cannot be the mechanism. That is why visible labels remain the default everywhere there is room, and this is reserved for controls whose meaning is also carried by aria-label. */ [data-tip] { position: relative; } [data-tip]::after { content: attr(data-tip); position: absolute; z-index: 60; bottom: calc(100% + 0.35rem); left: 50%; translate: -50% 0.25rem; padding: var(--sp-0) var(--sp-2); border: 1px solid var(--rule); border-radius: var(--r-1); background: var(--bg); color: var(--fg); font-size: var(--fs-xs); font-weight: normal; white-space: nowrap; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 100ms ease, translate 100ms ease, visibility 100ms; } [data-tip]:hover::after { opacity: 1; visibility: visible; translate: -50% 0; } @media (prefers-reduced-motion: reduce) { [data-tip]::after { transition: none; } } /* No anchor positioning here, on evidence: the implicit-anchor form resolved a column off inside table layout (Chrome, fleet headers), and a NAMED anchor on a class of trigger resolves every tip to the last trigger on the page. Absolute against the trigger is the only arrangement that cannot point at the wrong thing. Its cost is clipping under overflow ancestors, which each placement below pays deliberately instead of accidentally. */ /* A column header names itself when the table is wide enough to say it, and falls back to the icon alone when it is not. The label is real text, so it is readable by touch, by a screen reader and by anyone who does not know the glyph. */ .colhead { display: inline-flex; align-items: center; gap: var(--sp-1); justify-content: flex-end; } /* Narrow: visually hidden, NOT display:none — the label is the header's accessible name, and none would silence it for a screen reader. The tooltip carries it for sighted pointer users instead. Wide: the label is visible, so the tooltip would repeat it and stays off. */ .collabel { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; font-weight: normal; color: var(--muted); font-size: var(--fs-sm); } .colhead[data-tip]::after { bottom: auto; top: calc(100% + 0.35rem); left: auto; right: 0; translate: 0 -0.25rem; } .colhead[data-tip]:hover::after { translate: 0 0; } @media (min-width: 56rem) { .collabel { position: static; width: auto; height: auto; overflow: visible; clip-path: none; } .colhead[data-tip]::after { display: none; } } .avatar { flex: none; width: 2.25rem; height: 2.25rem; padding: 0; border-radius: var(--r-4); background: var(--bg); border: 1px solid var(--rule); font-size: var(--fs-xs); font-weight: 700; cursor: pointer; display: grid; place-items: center; } .avatar:hover { border-color: var(--fg); } /* A BASE size for the mark. Every other .idn rule is scoped to a parent, so an identicon used anywhere new inherited no size at all and an SVG with no size fills its container — the wiki history page rendered one person's mark at the width of the screen. The helper is meant to be usable wherever a person is named; that is only true if it is safe by default. Scoped rules below are more specific and still win. */ .idn { width: 14px; height: 14px; vertical-align: -2px; flex: none; } /* An uploaded picture fills the same square without being squashed. */ .idn-img { object-fit: cover; } /* The mark FILLS its avatar. It sat at 78%, so the avatar's own background showed as a ring around a rounded square — two shapes and two colours where a reader expects one disc. Filling it also makes the mark's colour the avatar's colour, which is the point of an identicon: the hue is the recognisable part at 24px, long before any pattern is legible. overflow: hidden is what lets the square fill a circle — the avatar's radius does the shaping, so the mark needs no radius of its own and the SVG's rounded corners fall outside the clip. */ .avatar { overflow: hidden; } .avatar .idn { width: 100%; height: 100%; border-radius: 0; display: block; } button.avatar[popovertarget="acct"] { anchor-name: --avatar; justify-self: end; } .menu { /* One inset declaration, not a shorthand plus overrides: the shorthand resets the sides the longhands set, which is how this first landed pinned to the top of the viewport. */ position: fixed; inset: 3.25rem 1rem auto auto; margin: 0; min-width: 11rem; padding: var(--sp-2); border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--bg); color: var(--fg); /* display is deliberately NOT set here. The UA stylesheet closes a popover with [popover]:not(:popover-open) { display: none }, and an author rule beats it — declaring display:flex on the base left the menu laid out and hit-testable at opacity 0, an invisible 185x111 region swallowing clicks on every page whether or not it had ever been opened. It is set only in the open state below. */ flex-direction: column; gap: var(--sp-0); } /* Anchored to the avatar where the browser can do it, so the menu keeps its relationship to the button at any viewport; the fixed corner above is the fallback and lands in the same place at these sizes. */ @supports (anchor-name: --a) { .menu { position-anchor: --avatar; inset: auto; top: anchor(bottom); right: anchor(right); margin-top: var(--sp-2); } } /* The menu grows out of the button rather than appearing. allow-discrete is what lets display participate, so the transition survives the popover being display:none between states. */ .menu { opacity: 0; transform: translateY(-0.25rem) scale(0.97); transform-origin: top right; transition: opacity 120ms ease, transform 120ms ease, overlay 120ms allow-discrete, display 120ms allow-discrete; } .menu:popover-open { display: flex; opacity: 1; transform: none; } @starting-style { .menu:popover-open { opacity: 0; transform: translateY(-0.25rem) scale(0.97); } } @media (prefers-reduced-motion: reduce) { .menu { transition: none; } } .menu-switch button .idn { width: 16px; height: 16px; vertical-align: -3px; margin-right: var(--sp-1); } .menu a, .menu button { /* border-box, or "width: 100%" means 100% PLUS the padding and every row hangs over the menu's right edge — which reads as a clipped, scrolling box even when the list is one item long. */ display: flex; align-items: center; gap: var(--sp-2); width: 100%; box-sizing: border-box; /* A button's UA font is smaller than the page's; in a menu every row must read the same, whatever element it is. */ font: inherit; padding: var(--sp-1) var(--sp-2); border: 0; border-radius: var(--r-1); background: none; color: var(--fg); text-decoration: none; text-align: left; font-weight: normal; } .menu a:hover, .menu button:hover { background: var(--surface); } /* The visitor's "what is this": prose in a menu's frame, so its links are links in a sentence, not rows. */ .menu.about-pop { box-sizing: border-box; width: min(24rem, calc(100vw - 2rem)); padding: var(--sp-3) var(--sp-4); gap: var(--sp-2); } .menu.about-pop p { margin: 0; line-height: 1.5; } .menu.about-pop a { display: inline; width: auto; padding: 0; border-radius: 0; color: var(--accent); text-decoration: underline; } .menu.about-pop a:hover { background: none; } .menu form { margin: 0; } .menu-who { padding: var(--sp-1) var(--sp-2) var(--sp-2); color: var(--muted); font-size: var(--fs-md); border-bottom: 1px solid var(--rule); margin-bottom: var(--sp-1); } /* Without the Popover API the menu renders as a plain inline row rather than vanishing: losing the popover is a cosmetic loss, losing the way to sign out is not. */ @supports not selector(:popover-open) { .avatar { display: none; } .menu { position: static; display: flex; flex-direction: row; align-items: center; margin-left: auto; border: 0; padding: 0; min-width: 0; gap: var(--sp-2); } .menu a, .menu button { width: auto; padding: var(--sp-0) var(--sp-2); } .menu-who { border: 0; margin: 0; padding: 0; } } form.theme button { padding: var(--sp-1) var(--sp-2); line-height: 0; background: none; border-color: transparent; color: var(--muted); } form.theme button:hover { border-color: var(--rule); color: var(--fg); } form.theme .ic { opacity: 1; width: 1.05em; height: 1.05em; } /* Scope pills reuse the nav's shape: the reader is choosing among the same sections, so they should not look like a different control. */ nav.scopes { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: var(--sp-2) 0 var(--sp-4); } nav.scopes a { display: inline-block; padding: var(--sp-0) var(--sp-2); border-radius: var(--r-4); border: 1px solid var(--rule); text-decoration: none; font-size: var(--fs-body); } nav.scopes a.here { background: var(--code-bg); color: var(--fg); font-weight: 600; } ul.hits { margin: 0; } ul.hits li { padding: var(--sp-2) var(--sp-4); } .snip { color: var(--muted); font-size: var(--fs-body); overflow-wrap: anywhere; } /* Fleet-wide results lead with the repository: the same wiki title exists in several stores, so a hit without its repo is ambiguous. */ .hit-repo { color: var(--muted); font-size: var(--fs-md); } .hit-repo::after { content: " /"; } nav.scopes.facets a .n { color: var(--muted); font-size: var(--fs-sm); } form.find.wide { max-width: none; display: flex; gap: var(--sp-2); margin-bottom: var(--sp-2); } form.find.wide input { flex: 1 1 auto; min-width: 0; } /* Chrome links are navigation, not references: colouring the whole nav with the accent would make the accent mean nothing. */ .topbar .site, .repobar .repo, nav a, .crumbs a, table.tree a { color: var(--fg); } header form { margin-left: auto; } .tw { overflow-x: auto; } /* Timeline card/detail metrics adapted from fossil src/default.css (BSD-2-Clause; see third_party/PROVENANCE.md). The graph tiles seamlessly: a fixed 36px head band (node + curves) and a stretching tail fill each row edge-to-edge at identical pixel x positions, and date rows carry the active rails through. */ table.tl td { border: 0; padding: 0; vertical-align: top; } table.tl td.tl-graph { position: relative; } svg.tl-head { position: absolute; top: 0; left: 0; display: block; } svg.tl-tail { position: absolute; top: 36px; left: 0; height: calc(100% - 36px); display: block; } tr.tl-date svg.tl-tail, tr.tl-flat svg.tl-tail { top: 0; height: 100%; } .tl-card-flat { padding: 0.45em 0.75em; font-size: 0.95em; } /* A technote is the one row on this page somebody WROTE to be read here, and the one whose position in time is authored rather than observed — the E card is a moment its author chose. Every other flat row is a record that something happened. They were pixel-identical apart from one word of badge text, so the distinction the reader needs was carried by four characters. The rule and the warmer ground say "editorial"; the excerpt does the heavier lifting, because a difference in SHAPE reads faster than a difference in colour and survives greyscale and a phone. */ /* The forum's post edge is this colour at 30% saturation over 3px. One pixel of the same value is a grey hairline — the width that carried it is gone, so the saturation has to do the work instead. 55% is the identicon's own range (its two steps are 62% and 60%), which is the point: the rule and the mark beside it are one person. */ .tl-card-note { border-left: 1px solid oklch(var(--idn-l) var(--uc, var(--idn-c0)) var(--uh, 0) / 0.9); border-top-left-radius: 0; border-bottom-left-radius: 0; padding: 0.55em 0.75em; } .tl-card-note .tl-note-t { font-weight: 500; } .tl-note-x { margin: 0.3em 0 0; color: color-mix(in srgb, var(--fg) 72%, var(--muted)); font-size: 0.93em; max-width: 62ch; } /* ---- unversioned files and releases (RFC 0020) ---- */ .uv-msg { max-width: var(--measure); margin: 0 0 var(--sp-4); padding: var(--sp-2) var(--sp-3); border: 1px solid var(--rule); border-radius: var(--r-2); font-size: var(--fs-body); } .uv-err { color: var(--del); border-color: color-mix(in srgb, var(--del) 45%, var(--rule)); } .uv-setup { max-width: var(--measure); } .uv-setup h2 { font-size: var(--fs-lg); margin: 0 0 var(--sp-2); } .uv-setup p { color: var(--muted); margin: 0 0 var(--sp-3); } .uv-steps { padding-left: var(--sp-5); margin: 0; display: flex; flex-direction: column; gap: var(--sp-3); } .uv-steps li { padding-left: var(--sp-1); } /* The form is one row on a desktop and a column on a phone: a file, two optional words and a button, the note under them. */ .uv-up { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--sp-3); margin: 0 0 var(--sp-5); padding: var(--sp-4); border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--surface); } .uv-f { display: flex; flex-direction: column; gap: var(--sp-1); font-size: var(--fs-sm); color: var(--muted); } .uv-f input:not([type=file]) { width: 12rem; font-family: var(--font-mono); font-size: var(--fs-sm); } .uv-file input { max-width: 16rem; font-size: var(--fs-sm); } .uv-up-note { flex-basis: 100%; margin: 0; font-size: var(--fs-sm); color: var(--muted); } .uv-bar { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); margin: 0 0 var(--sp-2); font-size: var(--fs-sm); color: var(--muted); } .uv-sort a { margin-left: var(--sp-2); text-decoration: none; } .uv-sort a[aria-current] { color: var(--fg); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; } .uv-name { font-family: var(--font-mono); font-size: var(--fs-sm); } .uv-name .ic { margin-right: var(--sp-1); color: var(--muted); vertical-align: -2px; } .uv-size { font-variant-numeric: tabular-nums; } .uv-hash { font-size: var(--fs-sm); background: none; padding: 0; color: var(--muted); } .uv-removed { margin-top: var(--sp-5); } .uv-removed > summary { cursor: pointer; color: var(--muted); font-size: var(--fs-sm); margin-bottom: var(--sp-2); } .uv-removed del { text-decoration-color: color-mix(in srgb, var(--muted) 60%, transparent); } @media (max-width: 40rem) { .uv-up { flex-direction: column; align-items: stretch; } .uv-f input:not([type=file]) { width: 100%; } .uv-up .btn { align-self: flex-start; } } .rel-g { margin: 0 0 var(--sp-6); padding-bottom: var(--sp-5); border-bottom: 1px solid var(--rule); } .rel-g:last-child { border-bottom: 0; } .rel-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-3); } .rel-h h2 { margin: 0; font-family: var(--font-mono); font-size: var(--fs-lg); } .rel-h h2 a { color: inherit; text-decoration: none; } .rel-h h2 a:hover { text-decoration: underline; } .rel-h .rel-title { margin: 0; font-family: var(--font-mono); font-size: var(--fs-xl); } .rel-one .rel-g { border-bottom: 0; } .rel-meta { color: var(--muted); font-size: var(--fs-sm); } .rel-tag { font-family: var(--font-mono); } /* Notes beside the files on a wide screen, under them on a narrow one. */ .rel-body.with-notes { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: var(--sp-5); } @media (max-width: 60rem) { .rel-body.with-notes { grid-template-columns: minmax(0, 1fr); } } .rel-main { min-width: 0; } .rel-target, .rel-file { font-family: var(--font-mono); font-size: var(--fs-sm); } /* The file name is the one cell long enough to push the table past its column, so it wraps; the table scrolls in its own box past that. */ .dt td.rel-file { white-space: normal; overflow-wrap: anywhere; min-width: 10rem; } .rel-files { list-style: none; padding: 0; margin: 0 0 var(--sp-2); font-family: var(--font-mono); font-size: var(--fs-sm); } .rel-files li { padding: var(--sp-1) 0; overflow-wrap: anywhere; } .rel-size { color: var(--muted); font-family: var(--font-sans); margin-left: var(--sp-2); } .rel-note { color: var(--muted); font-size: var(--fs-sm); margin: var(--sp-2) 0; max-width: var(--measure); } .rel-qual summary { cursor: pointer; color: var(--muted); font-size: var(--fs-sm); } .rel-qual dl { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-1) var(--sp-3); font-size: var(--fs-sm); margin: var(--sp-2) 0 0; } .rel-qual dt { font-family: var(--font-mono); } .rel-qual dd { margin: 0; color: var(--muted); } .rel-notes { min-width: 0; border-left: 2px solid var(--rule); padding-left: var(--sp-4); } .rel-notes h3 { margin: 0 0 var(--sp-2); font-size: var(--fs-body); } .rel-notes .tkt-body { margin: 0; font-size: var(--fs-body); } /* ---- notes: the feed, the note, its history ---- */ .tn-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-4); max-width: var(--measure); } .tn-head h1 { margin: 0; } .tn-new { white-space: nowrap; font-size: var(--fs-body); } .tn-lede { color: var(--muted); font-size: var(--fs-body); max-width: var(--measure); margin: var(--sp-2) 0 var(--sp-5); padding-bottom: var(--sp-4); border-bottom: 1px solid var(--rule); } .tn-h { margin: 0 0 var(--sp-1); } /* A numbered list without the numbers: ordered because a news feed IS a sequence, unnumbered because the dates already say where in it. */ .tn-feed { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--sp-5); } .tn-item { max-width: var(--measure); } .tn-title { font-size: var(--fs-lg); font-weight: 500; text-decoration: none; } .tn-title:hover { text-decoration: underline; } .tn-x { margin: var(--sp-1) 0 0; color: color-mix(in srgb, var(--fg) 74%, var(--muted)); } .tn-m { margin: var(--sp-1) 0 0; font-size: var(--fs-sm); color: var(--muted); } .tn-m a { text-decoration: none; } .tn-m a:hover { text-decoration: underline; } .tn-empty { color: var(--muted); max-width: var(--measure); } .about-note-x { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .tn-hist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--sp-2); } .tn-hrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); max-width: var(--measure); } .tn-hrow a { text-decoration: none; } .tn-hm { font-size: var(--fs-sm); color: var(--muted); } /* The one history fact worth colour: an edit that changed the TITLE is why a superseded row and its correction can read as two notes. */ .tn-retitle { color: color-mix(in srgb, var(--accent) 70%, var(--muted)); } .tn-form { max-width: var(--measure); } .tn-form label { display: block; font-weight: 500; } .tn-form input, .tn-form textarea { width: 100%; box-sizing: border-box; margin-top: var(--sp-1); } .tn-form input[type="datetime-local"] { width: auto; min-width: 15rem; } .tn-hint { display: block; margin-top: var(--sp-1); font-size: var(--fs-sm); color: var(--muted); } .tn-acts { display: flex; align-items: center; gap: var(--sp-4); } /* ---- operations ---- */ /* The phase banner. Its colour is the state, so an operator glancing at a wall display reads it without reading it: amber while stores are still coming up, accent while work moves, quiet at rest. */ .ops-phase { background: var(--surface); border-radius: var(--r-3); padding: var(--sp-4) var(--sp-4); margin: 0 0 var(--sp-4); border-left: 3px solid var(--rule); } .ops-phase.ph-starting, .ops-phase.ph-indexing { border-left-color: var(--accent); } .ops-phase.ph-syncing { border-left-color: var(--add); } .ops-phase-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); } .ops-phase-n { font-size: var(--fs-lg); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; } .ops-phase-c { color: var(--muted); font-size: var(--fs-md); } .ops-phase-l { margin: var(--sp-2) 0 0; font-size: var(--fs-sm); color: var(--muted); } .ops-held { list-style: none; margin: var(--sp-1) 0 0; padding: 0; font-size: var(--fs-sm); } .ops-held-i { color: var(--fg); padding: var(--sp-0) 0; } .ops-held-q { color: var(--muted); } .ops-held-v { font-weight: 600; } .ops-held-a { color: var(--muted); } /* One track, two segments: ready and stalled are both done waiting, and a bar that counted a stalled store as pending would never fill. */ .ops-bar { display: flex; height: 6px; border-radius: var(--r-1); overflow: hidden; background: color-mix(in srgb, var(--fg) 12%, transparent); margin-top: var(--sp-2); } .ops-bar-ok { background: var(--accent); } .ops-bar-bad { background: var(--del); } @media (prefers-reduced-motion: no-preference) { .ops-bar-ok, .ops-bar-bad { transition: width 0.4s ease; } } .ops-cards { display: flex; flex-wrap: wrap; gap: var(--sp-4); margin: 0 0 var(--sp-5); } /* The attribution column: two lines, repositories over causes, both small enough that the numbers beside them stay the row's subject. */ .ops-from { font-size: var(--fs-sm); color: var(--muted); line-height: 1.5; } .ops-from b { color: var(--fg); font-weight: 600; } .ops-cause { font-family: var(--font-mono); font-size: 0.95em; } .ops-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-2); } /* Small on purpose: a glance, not a chart. It takes the card's spare width and a fixed height, so a long series and a short one draw the same size box and the cards stay on one grid. */ .ops-spark { width: 4.5rem; height: 1.4rem; flex: none; fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; opacity: 0.85; } .ops-card { flex: 1 1 12rem; background: var(--surface); border-radius: var(--r-3); padding: var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-0); } .ops-n { font-size: var(--fs-xl); font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1; } .ops-l { font-size: var(--fs-md); } .ops-sub { font-size: var(--fs-sm); color: var(--muted); } .ops-sub a { text-decoration: none; } table.ops-t { width: 100%; border-collapse: collapse; font-size: var(--fs-md); } table.ops-t th { text-align: left; font-weight: 500; color: var(--muted); font-size: var(--fs-sm); text-transform: uppercase; letter-spacing: 0.05em; padding: 0 var(--sp-2) var(--sp-1) 0; border-bottom: 1px solid var(--rule); } table.ops-t td { padding: var(--sp-1) var(--sp-2) var(--sp-1) 0; border-bottom: 1px solid var(--rule); } /* Digits line up in columns, which is the whole reason these are tables. */ table.ops-t th.n, table.ops-t td.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } table.ops-t code { font-size: 0.95em; } table.ops-t a { text-decoration: none; } table.ops-t a:hover { text-decoration: underline; } .ops-route-p { margin: -0.6rem 0 var(--sp-5); color: var(--muted); } /* One request, then what it asked the store for. Indented under it because the calls belong to the request, not beside it. */ .ops-reqs { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--sp-4); } .ops-req { background: var(--surface); border-radius: var(--r-2); padding: var(--sp-2) var(--sp-3); } .ops-req-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); } .ops-req-t { font-weight: 600; font-variant-numeric: tabular-nums; } .ops-req-r { text-decoration: none; font-weight: 500; } .ops-req-m { color: var(--muted); font-size: var(--fs-sm); } /* Pushed to the end of the row: when a request happened matters less than what it cost, and a column of times would draw the eye first. */ .ops-req-w { margin-left: auto; color: var(--muted); font-size: var(--fs-sm); } .ops-calls { margin: var(--sp-2) 0 0; padding-left: var(--sp-5); color: var(--muted); font-size: var(--fs-md); display: flex; flex-direction: column; gap: var(--sp-0); } .ops-calls .n { font-variant-numeric: tabular-nums; } .hit-meta { font-size: var(--fs-sm); color: var(--muted); margin: var(--sp-0) 0 0; } .hit-meta code { font-size: 0.95em; color: var(--accent); } .hit-branch { color: color-mix(in srgb, var(--fg) 70%, var(--muted)); } .role-badge { margin-left: var(--sp-2); color: var(--accent); border-color: var(--accent); } .w-notes { display: flex; align-items: baseline; gap: var(--sp-2); margin: 0 0 var(--sp-4); } .w-notes a { text-decoration: none; font-weight: 500; } .w-notes .tn-hint { display: inline; margin: 0; } .tl-meta-inline { color: var(--muted); font-size: 85%; } table.tl td.tl-time { white-space: nowrap; width: 3.4rem; padding: var(--sp-3) var(--sp-2) 0 0; text-align: right; } table.tl td.tl-time a { color: var(--muted); text-decoration: none; } table.tl td.tl-time a:hover { text-decoration: underline; } table.tl td.tl-slot { width: 100%; padding: 0 0 var(--sp-1) var(--sp-2); } table.tl td.tl-datecell { padding: var(--sp-1) 0 var(--sp-1) var(--sp-2); } .tl-card { background: var(--surface); border-radius: 1em; padding: 0.75em; } .tl-title { font-weight: 500; } /* The kind glyph is chrome: it says what a row IS, so it sits with the muted metadata rather than competing with the row's own words. */ .tl-kind { color: var(--muted); flex: none; } .tl-badge .tl-kind { color: inherit; vertical-align: -0.1em; } .tl-p { margin: 0.35em 0 0; color: color-mix(in srgb, var(--fg) 78%, var(--muted)); } /* The landing line is provenance, not prose: quieter than the check-in comment it sits under, and the link carries the weight. */ .tl-landed { font-size: 0.92em; color: var(--muted); } /* The route beside an actual path: present for the drilldown link, and quiet enough that the path is what a reader scans. */ .ops-route-l { display: block; font-size: 0.82em; color: var(--muted); } /* A strata: the rungs under a base row, and the map on a record. */ /* A ticket's number (54c5175f): quieter than the title it names. */ .t-num { color: var(--muted); font-variant-numeric: tabular-nums; font-weight: inherit; } .strata summary { cursor: pointer; color: var(--muted); font-size: 0.92em; margin-top: 0.3em; } .strata-rungs { list-style: none; margin: 0.4em 0 0; padding: 0 0 0 0.2em; border-left: 2px solid var(--rule); } /* One rung, one row: position, title, branches, verdict. The title is clipped to a line (its first sentence already, clipped again here to the row) — a landing's description is prose, and printing all of it as a link made the fold a wall of underlined text. */ .strata-rungs li { padding: 0.2em 0 0.2em 0.6em; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.2em 0.5em; } .strata-rungs .rung-t { flex: 1 1 16rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .strata-rungs a.rung-t { color: var(--fg); text-decoration: none; } .strata-rungs a.rung-t:hover { color: var(--accent); text-decoration: underline; } .strata-rungs .rung-br { flex: none; font-size: 0.9em; } .strata-rungs li.here { background: var(--surface); border-radius: var(--r-1); } .strata-map { margin: 0.6em 0 0.2em; } .l-steps { font-size: 0.9em; margin: 0.4em 0; padding: 0.5em 0.7em; background: var(--code-bg); border-radius: var(--r-1); } .l-steps em { color: var(--muted); font-style: normal; } .tl-meta { color: var(--muted); font-size: 80%; text-align: right; opacity: 0.85; margin-top: 0.5em; } .tl-meta a { text-decoration: none; } .tl-meta a:hover { text-decoration: underline; } @media (max-width: 640px) { /* main carries the page's horizontal padding now, not body. This was "body { padding: var(--sp-2) }" — a shorthand, which silently reset the bottom reserve the fixed footer depends on and let content run underneath it on exactly the screens where that hurts most. */ body { --gutter: 0.5rem; } table.tl td.tl-time { width: 2.6rem; font-size: var(--fs-sm); padding-top: var(--sp-4); } .tl-card { padding: 0.75em 0.55em; border-radius: 0.6em; } .tl-meta { font-size: 72%; } /* Phone widths: the card's widest line is the metadata run, and its max-content is what pushed the table past the viewport (auto table layout prefers it, and .tw then scrolls sideways, hiding the right-aligned meta). Stacking the items drops that preference to the longest single item, so the table fits with the cards still wide. */ .tl-metaitem { display: block; } .tl-card { overflow-wrap: anywhere; } } /* File listing. The old one spent ~52px of height and three columns on one short filename; fossil fits roughly 60 entries in the space that showed 25. Density here comes from tight rows and dropping the repeated per-row "history" link, not from shrinking the type. */ table.tree { table-layout: auto; } /* GitHub's middle column: the last check-in to touch each entry, muted, one line, eating the slack between name and age. Its collapse is GitHub's too — the message yields on a phone, the age stays. The size column belongs to the no-data layout; GitHub's listing never had one, and the message is worth more. */ table.tree.hc { width: 100%; } table.tree.hc td.t-name { white-space: nowrap; max-width: 22rem; overflow: hidden; text-overflow: ellipsis; padding-right: var(--sp-4); } td.t-change { color: var(--muted); max-width: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: var(--sp-4); } td.t-change a { color: var(--muted); } td.t-change a:hover { color: var(--accent); } td.t-age { color: var(--muted); text-align: right; white-space: nowrap; } @media (max-width: 44rem) { td.t-change { display: none; } } table.tree td { padding: var(--sp-0) 0; border: 0; line-height: 1.45; } table.tree tr:hover { background: var(--surface); } /* Thirty underlined names in a column is a wall of rules, and the underline carries no information here — every name in the listing is a link, so marking them all marks nothing. Hover restores it. */ table.tree a, .tree-bar .crumbs a { text-decoration: none; } table.tree a:hover, .tree-bar .crumbs a:hover { text-decoration: underline; } /* The icon and the name are one label, so they get a gap rather than sitting flush: without it the glyph reads as the first character of the filename. */ td.t-name { white-space: nowrap; } td.t-name .ic { margin-right: var(--sp-2); } /* Sizes are only comparable when their digits line up, which means right-aligned and tabular figures. The column takes only what it needs so the name column is not pushed across the viewport. */ td.t-size { text-align: right; white-space: nowrap; width: 1%; padding-left: var(--sp-5); color: var(--muted); font-variant-numeric: tabular-nums; } /* Icons are an SVG sprite (see iconSprite), referenced by <use>. They were CSS masks with inline data: URIs, which Chrome accepts and iOS Safari does not: the raw <, > and quotes make the URI invalid, the mask silently fails, and every icon renders as a solid block. A sprite has no encoding to get wrong. */ .sprite { display: none; } .ic { width: 1em; height: 1em; vertical-align: -0.125em; fill: currentColor; opacity: 0.55; flex: none; } /* Flags replace ls(1)'s trailing punctuation. "compile*" reads as part of the filename; a labelled chip does not. */ .flag { font-size: var(--fs-xs); color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-1); padding: 0 var(--sp-1); vertical-align: 0.05em; } /* A strip of repository facts, the way forgejo leads a repo page. Each is a link to the surface that explains it. */ /* The activity profile. Dots take their colour from --c on the author group; the stylesheet only knows shapes. */ .act, .slice { display: block; width: 100%; height: auto; font-size: var(--fs-xs); } .act text, .slice text { fill: var(--muted); } .act .ax text { text-anchor: end; } .act .ax-x text { text-anchor: middle; } .act .band-l { text-anchor: end; font-size: var(--fs-xs); } .act .band { fill: var(--surface); } /* Nothing is written on the plot (§3): the tree axis's directory bands and its date rail are shapes, and the hover carries their names. */ .act .ax line, .act .rail line { stroke: var(--rule); } .act .mx rect, .act .my rect { fill: var(--muted); opacity: 0.45; } /* Under a person filter everyone stays drawn and everyone but them goes grey. A per-index rule cannot be written in a static sheet, so the chosen group carries .mine and this greys its siblings. */ .act[data-user] .au:not(.mine) { fill: var(--muted); opacity: 0.15; } .act[data-user] .au:not(.mine) .dot.merge { stroke: var(--muted); } /* The marginals keep the project's grey bars; the person's own are drawn over them, on the project's scale, in their colour. */ .act .mx.mine rect, .act .my.mine rect { fill: var(--c); opacity: 0.9; } .act .au { fill: var(--c); } /* The radius is on the mark, not here: on the tree axis it follows the column width, and a dot too small to be a circle is drawn square. */ .act .dot { fill-opacity: 0.85; } .act .dot.merge { fill: none; stroke: var(--c); stroke-width: 1.2; } .act a:hover .dot { fill-opacity: 1; r: 3.4; } .act-sample { overflow-x: auto; position: relative; } /* activity.js draws the same picture on a canvas and hides the SVG; the tip is its hover readout, styled like the reference card. */ .act-canvas { display: block; width: 100%; height: auto; cursor: crosshair; touch-action: none; } .act-sample > .act[hidden] { display: none; } .act-tip { position: absolute; pointer-events: none; z-index: 2; font-size: var(--fs-sm); line-height: 1.3; padding: var(--sp-1) var(--sp-2); border: 1px solid var(--rule); border-radius: var(--r-1); background: var(--bg); color: var(--fg); box-shadow: 0 2px 8px rgba(0,0,0,0.15); max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .act-tip .m { color: var(--muted); } .act-reset { position: absolute; top: 0.4rem; right: 0.6rem; z-index: 2; font: inherit; font-size: var(--fs-sm); padding: var(--sp-0) var(--sp-2); border: 1px solid var(--rule); border-radius: var(--r-1); background: var(--bg); color: var(--fg); cursor: pointer; } /* The slice's hover readout is Datastar-driven (sliceHoverAttr); it wraps rather than clips because a busy day names several people. */ .slice-tip { white-space: normal; } .slice-tip:empty { display: none; } .act-controls { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); margin: 0 0 var(--sp-2); } .act-win { flex: none; } .act-window { color: var(--muted); margin: 0 0 var(--sp-2); } /* The legend under the picture is also the chooser (§7): a wrapping row of people, each a link. Under a filter the dots grey with the plot and only the chosen one keeps its colour — the hook is on the list, so no entry needs an inline decision. */ .act-legend { list-style: none; margin: var(--sp-2) 0 var(--sp-4); padding: 0; display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); } .act-legend a { display: inline-flex; align-items: center; gap: var(--sp-1); text-decoration: none; } .act-legend a[aria-current] { font-weight: 600; } .act-legend .idn { width: 0.9rem; height: 0.9rem; border-radius: var(--r-1); flex: none; } .act-legend .act-n { color: var(--muted); font-variant-numeric: tabular-nums; } .act-legend[data-user] a:not([aria-current]) .idn { filter: grayscale(1); opacity: 0.55; } .act-clear { color: var(--muted); } .act-h { margin: var(--sp-5) 0 var(--sp-2); } .act-key { color: var(--muted); font-size: var(--fs-md); } .act-strata { position: relative; } .act-slice .slice { max-width: 980px; } .slice .au { fill: var(--c); } .slice .c { fill: var(--c); } .slice .rel { fill: var(--fg); } /* Every day of the span is a cell; an empty one is drawn so the year keeps its shape. Nothing is drawn INSIDE a cell — the whole cell is the day's person and its opacity is the day's weight. */ .slice .c.empty { fill: var(--slice-empty); } .slice .c.other { fill: var(--muted); } .slice .n, .slice .m { font-size: var(--fs-xs); font-variant-numeric: tabular-nums; } .slice .n { fill: var(--fg); } .slice .yl { fill: var(--fg); font-size: var(--fs-xs); font-weight: 600; font-variant-numeric: tabular-nums; } .act-years { white-space: nowrap; } .home-slice { display: block; margin: 0 0 var(--sp-3); position: relative; } .home-slice .slice { height: 1.4rem; width: 100%; } .repo-stats { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); margin: 0 0 var(--sp-3); padding: var(--sp-2) var(--sp-3); background: var(--surface); border: 1px solid var(--rule); border-radius: var(--r-2); font-size: var(--fs-body); } .repo-stats a { display: inline-flex; align-items: center; gap: var(--sp-2); color: var(--fg); text-decoration: none; } .repo-stats a:hover { text-decoration: underline; } /* Reader traffic. The bars are relative to the biggest row in their group, never to the total: on a public repository the unidentified row is normally an order of magnitude larger than every named one, and shares would draw every real reader as nothing at all. */ .tf-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--sp-3) var(--sp-5); margin: 0 0 var(--sp-4); } .tf-totals { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-5); font-size: var(--fs-body); color: var(--muted); } .tf-total b { font-size: var(--fs-lg); font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; margin-right: var(--sp-1); } .tf-verbs { display: flex; gap: var(--sp-3); font-size: var(--fs-body); } .tf-verbs a { color: var(--muted); text-decoration: none; padding-bottom: var(--sp-0); border-bottom: 2px solid transparent; } .tf-verbs a:hover { color: var(--fg); } .tf-verbs a.here { color: var(--fg); border-bottom-color: var(--accent); } .tf-split { display: grid; grid-template-columns: 1fr; gap: var(--sp-5); } @media (min-width: 48rem) { .tf-split { grid-template-columns: 1fr 1fr; gap: var(--sp-6); } } .tf-group h2 { font-size: var(--fs-body); font-weight: 600; margin: 0 0 var(--sp-2); } .tf-bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--sp-2); } .tf-row { display: grid; grid-template-columns: minmax(0, 9rem) 1fr 3.5rem; align-items: center; gap: var(--sp-2); font-size: var(--fs-md); } .tf-lab { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tf-track { height: 0.6rem; background: var(--surface); border-radius: var(--r-1); overflow: hidden; } .tf-fill { display: block; height: 100%; background: var(--accent); opacity: 0.35; } .tf-val { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); } /* A claimed name and an unidentified reader are both drawn APART from verified accounts, because neither is one. */ .tf-anon .tf-lab, .tf-anon .tf-val { color: var(--muted); font-style: italic; } .tf-anon .tf-fill { opacity: 0.18; } .tf-claim .tf-lab { color: var(--muted); } /* A glyph, never a tooltip: the legend below the panel carries the meaning in words, and a screen reader gets it inline. */ .tf-tilde { color: var(--accent); font-weight: 600; } .tf-why, .tf-none { color: var(--muted); font-size: var(--fs-md); max-width: 34rem; margin: var(--sp-3) 0 0; } .tf-none { max-width: 40rem; } /* The diff has not arrived yet. Deliberately quiet and deliberately not a spinner: the page is complete except for one region, and an animation would claim more is happening than is. */ .diff-waiting { color: var(--muted); } /* What a landing settles. Quiet on the row, plainer on the record — it is a fact about the proposal, not a control. */ /* A record's facts: what it closes, what it addresses, what it landed as. One row each, one glyph each, aligned on the glyph — they were three paragraph shapes and read as three unrelated remarks. */ .l-fact { margin: var(--sp-1) 0 0; font-size: var(--fs-body); color: var(--muted); display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } .l-fact .ic { flex: none; opacity: 0.75; } .l-fact a { color: var(--fg); } .l-fact-last { margin-bottom: var(--sp-4); } /* Each fragment wraps as a unit, so a separator never starts a line and "by" never leaves the name behind on the next one. */ .l-bit { display: inline-flex; align-items: center; gap: var(--sp-1); white-space: nowrap; } /* t-sub is ordinary inline flow, so the groups need their own air — a text-node space between them would be a wrap opportunity, which is the thing these groups exist to remove. */ .t-sub .l-bit { margin-right: var(--sp-2); } /* Teams: the public directory and one team's page (RFC 0018). The rows borrow the people list's shape on purpose — both answer "who is on this forge", one of them in groups. */ .teams { list-style: none; margin: 0; padding: 0; } .teams li { display: flex; align-items: flex-start; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--rule); } .teams li:last-child { border-bottom: 0; } .teams .t-glyph { flex: none; margin-top: var(--sp-1); color: var(--muted); } code.t-handle { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--r-1); padding: 0 var(--sp-1); font-size: var(--fs-sm); color: var(--muted); } /* The row's own control sits at the far end of the row, so a list of repositories reads as names with an action rather than as a column of buttons. .teams li is already a flex row; this just claims the gap. */ .teams li .row-act { margin-left: auto; flex: none; } /* Add and rename are the same shape: a stack that becomes a row when there is width for one. Wrapping rather than a grid, because the select is as wide as the longest repository name and nothing useful can be said about that in advance. */ .t-add, .t-text { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); margin: var(--sp-3) 0 var(--sp-5); } .t-add label, .t-text label { color: var(--muted); font-size: var(--fs-md); } .t-add select { max-width: 100%; } .t-text { flex-direction: column; align-items: stretch; max-width: 34rem; } .t-text input { width: 100%; } .t-text label { margin-top: var(--sp-1); } .t-text .sub { margin: var(--sp-0) 0 var(--sp-1); } .t-text button { align-self: start; } /* The room. A line said TO THE TEAM is marked rather than coloured differently: the merged view's whole job is to keep the origin of every line visible, and a badge says it in words. */ /* The team board. Chips rather than side-by-side lanes: a team's assignees are people and there is no upper bound on how many, so the per-repository board's fixed columns are the wrong shape here. The CARDS are that board's cards, from its own rules, which is what keeps the two recognisably one idea. */ /* The heading and its summary are one block, with room under the nav: .p-h carries no top margin, so a heading directly after the team bar sat against it. */ .t-board-head { margin: var(--sp-5) 0 0; } .t-board-head .p-h { margin-bottom: var(--sp-1); } .t-board-head .sub { margin: 0; } .board-strip { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: var(--sp-4) 0 var(--sp-4); } .board-strip a { display: inline-flex; align-items: baseline; gap: var(--sp-1); border: 1px solid var(--rule); border-radius: var(--r-4); padding: var(--sp-0) var(--sp-3); color: var(--fg); } .board-strip a:hover { border-color: var(--accent); } .board-strip a.here { border-color: var(--accent); background: var(--surface); font-weight: 600; } /* Nobody is a column like any other and is marked as one: dashed says "an empty seat", not "an error". */ .board-strip a.nobody { border-style: dashed; } /* Cards at a readable width rather than the page's. A ticket title is forty characters; a card eleven hundred pixels wide for it reads as an empty table, and the eye has to travel the whole width to reach the badges. auto-fill lets the count follow the window instead of being decided here — three across on a desktop, one on a phone, with no breakpoint to keep in sync. */ .board-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr)); } /* The grid spaces them, so the card's own bottom margin would double the gap on every row but the last. A column layout with the act row pushed to the bottom keeps the cards in a row the same height, which is what makes a grid read as a grid. */ .board-rows > li.card { margin-bottom: 0; display: flex; flex-direction: column; gap: var(--sp-0); padding: var(--sp-3) var(--sp-3); } .board-rows .t-badges { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1); margin-top: var(--sp-1); } .board-rows .row-act { margin-top: auto; padding-top: var(--sp-2); } /* The repository is the fact this board has and the per-repository one cannot, so it is the one badge that is not grey. */ .board-rows .chip.repo { border-color: color-mix(in srgb, var(--accent) 45%, var(--rule)); color: var(--accent); } /* Why a card has no reassign control. Per card, because "one of these trackers" is not an answer when a board shows several. */ .board-rows .no-assign { color: var(--muted); font-size: var(--fs-sm); margin-top: auto; padding-top: var(--sp-2); } .chip.nobody { border-style: dashed; color: var(--muted); } /* A label that only a screen reader needs. The select beside it says what it does to somebody who can see the card it sits on. */ .vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .room { list-style: none; margin: var(--sp-2) 0 0; padding: 0; } .room > li { display: flex; gap: var(--sp-3); padding: var(--sp-3) var(--sp-3); } /* The body claims the row rather than shrinking to its text, so every reply box is the same width. Without this each row's form was as wide as that row's message, which reads as a rendering accident. min-width is the usual flex escape: a long unbroken word would otherwise set a floor wider than the row. */ .room > li > .p-main { flex: 1 1 auto; min-width: 0; } .room-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); } .room-head a { font-weight: 600; } .room-text { margin: var(--sp-1) 0 0; overflow-wrap: anywhere; } .room li.to-team { border-left: 3px solid var(--accent); } .room-say, .room-reply { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: var(--sp-3) 0 var(--sp-4); } .room-say label { color: var(--muted); font-size: var(--fs-md); } .room-say input[name=text] { flex: 1 1 18rem; } .room-reply { margin: var(--sp-2) 0 0; } .room-reply input[name=text] { flex: 1 1 14rem; font-size: var(--fs-body); } .room-more { margin: var(--sp-3) 0 0; } .t-head { display: flex; align-items: flex-start; gap: var(--sp-4); margin: 0 0 var(--sp-5); } .t-head h1.t-name { margin: 0; } .t-handle-l { margin: var(--sp-1) 0 0; } /* The team's sentence, now the first thing under the bar rather than a line inside a heading block — so it needs the room the heading used to give it, above AND below. */ .t-descr { margin: var(--sp-4) 0 var(--sp-5); color: var(--muted); max-width: 60ch; } .avatar-lg { width: 3rem; height: 3rem; flex: none; } .avatar-lg svg { width: 100%; height: 100%; } .t-owner { border-color: var(--accent); color: var(--accent); } /* A route that is not this repository's own string. Linked, because the answer to "change this" is on the team, not here. */ .chip.via-team { border-color: var(--accent); color: var(--accent); text-decoration: none; } .chip.via-team:hover { text-decoration: underline; } .l-closes-chip { color: var(--muted); } .ops-req-u { color: var(--fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 34rem; } .ops-req-u:hover { text-decoration: underline; } .exc-acts { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; } .exc-clear { margin: 0; } .exc-clear button { font: inherit; font-size: var(--fs-md); padding: var(--sp-0) var(--sp-2); color: var(--muted); background: none; border: 1px solid var(--rule); border-radius: var(--r-1); cursor: pointer; } .exc-clear button:hover { color: var(--fg); border-color: var(--muted); } .tf-fleet { width: 100%; border-collapse: collapse; font-size: var(--fs-body); } .tf-fleet th, .tf-fleet td { text-align: left; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--rule); vertical-align: middle; } .tf-fleet th { font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); font-weight: 600; white-space: nowrap; } .tf-fleet td.tf-n, .tf-fleet th.tf-n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .tf-fleet tr:last-child td { border-bottom: 0; } .tf-fleet-bar { display: block; margin-top: var(--sp-1); max-width: 18rem; } .tf-zero { color: var(--muted); } .tf-fine { color: var(--muted); font-size: var(--fs-sm); max-width: 46rem; margin: var(--sp-6) 0 0; padding-top: var(--sp-4); border-top: 1px solid var(--rule); } .refbtn, .dlbtn { display: inline-flex; align-items: center; gap: var(--sp-2); cursor: pointer; } .refname { max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .caret { color: var(--muted); font-size: 0.7em; } /* Hug the content; only long ref lists scroll, and their scrolling does not chain to the page behind them. */ .menu.refs, .menu.dlmenu { position: fixed; inset: auto; max-height: 60vh; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; min-width: 13rem; } .menu.refs a.here, .menu.dlmenu a.here { background: var(--surface); font-weight: 600; } @supports (anchor-name: --a) { .refbtn { anchor-name: --refbtn; } .menu.refs { position-anchor: --refbtn; top: anchor(bottom); left: anchor(left); margin-top: var(--sp-1); } .dlbtn { anchor-name: --dlbtn; } .menu.dlmenu { position-anchor: --dlbtn; top: anchor(bottom); right: anchor(right); margin-top: var(--sp-1); } } /* basecoat's input-group, same as the search field: ONE bordered shell owns the border and the radius; the segments inside are borderless with hairline separators. Per-segment borders put a radius at every seam, and the corners never quite meet. The tool choice is still a URL (?clone=), so it survives a reload and can be linked to. */ .clonebox { display: inline-flex; align-items: stretch; min-width: 0; border: 1px solid var(--rule); border-radius: var(--r-row); background: var(--bg); overflow: hidden; } .clonebox:focus-within { outline: 2px solid var(--muted); outline-offset: 1px; } .clonepick { display: inline-flex; align-items: center; font-size: var(--fs-sm); border-right: 1px solid var(--rule); background: var(--surface); } .clonepick a { padding: var(--sp-1) var(--sp-2); text-decoration: none; color: var(--muted); align-self: stretch; display: inline-flex; align-items: center; } .clonepick a.here { background: var(--bg); color: var(--fg); font-weight: 600; } .cloneurl { font-family: var(--font-mono); font-size: var(--fs-sm); min-width: 0; width: 22rem; border: 0; border-radius: 0; background: none; } .cloneurl:focus-visible { outline: none; } .copybtn { border: 0; border-left: 1px solid var(--rule); border-radius: 0; background: none; padding: 0 var(--sp-2); cursor: pointer; } /* Tree chrome: one row carrying branch, crumbs and clone. */ .tree-bar { display: flex; gap: var(--sp-3); align-items: center; flex-wrap: nowrap; margin-bottom: var(--sp-2); min-width: 0; } .tree-bar .crumbs { font-weight: 600; min-width: 3ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tree-bar .tree-acts { margin-left: auto; display: flex; gap: var(--sp-3); align-items: center; font-size: var(--fs-md); /* The clone input is the compressible member of this row: without min-width the actions block refuses to shrink and spills into the About column. */ min-width: 0; flex: 0 1 auto; } .tree-bar .tree-acts .clonebox { min-width: 0; flex: 0 1 auto; } .tree-bar .tree-acts .clonepick, .tree-bar .tree-acts .dlbtn { flex: none; } .tree-bar .dl { white-space: nowrap; } /* A select is as wide as its widest option, so one long branch name set the control's width for every repository — 288px, most of a phone row, to show "trunk". The cap truncates the outlier instead; the full name is still in the open list, which is where it is read. */ .tree-bar select { max-width: 11rem; } @media (max-width: 40rem) { /* The clone command is the first thing worth losing on a phone — it is long and not what a reader came for — and the zit/fossil picker goes with it: a tool choice for a command that is not on screen. What remains (ref, path, download) holds ONE line. */ .tree-bar .clonebox, .tree-bar .clonepick { display: none; } .tree-bar { gap: var(--sp-2); } } /* Form controls inherit the browser's palette, not the page's: a white textarea on a dark page, and iOS Safari's blue pill for every button. They also default to content-box, so the width:100% on our textareas overflowed by exactly the padding and border. Both are the browser being reasonable in isolation and wrong here, so they are set once for every form rather than per-page. */ input, textarea, select, button { font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-1) var(--sp-2); box-sizing: border-box; max-width: 100%; } textarea { display: block; width: 100%; resize: vertical; line-height: 1.5; } /* The UA draws its select caret hard against the border and ignores padding. Owning the indicator (a currentColor-matched chevron) is how every design system solves it: consistent shape, real breathing room, both themes. */ select { appearance: none; -webkit-appearance: none; padding-right: var(--sp-5); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.6rem center; } button, input[type=submit] { -webkit-appearance: none; appearance: none; background: var(--code-bg); cursor: pointer; font-weight: 600; } button:hover, input[type=submit]:hover { border-color: var(--fg); } ::placeholder { color: var(--muted); opacity: 1; } /* WebKit draws its own clear button inside a search field, in the OS accent colour, and nothing but its size is styleable — a blue X in a gruvbox page. Basecoat leaves it alone and inherits the same problem. It is removed here and replaced, where there is something to clear, by a link that resets the filter: the same colour as everything else and, being a link, it works with scripting off. */ input[type=search]::-webkit-search-cancel-button, input[type=search]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; } /* basecoat's input-group: one bordered shell, borderless controls inside it. An add-on then sits WITHIN the field instead of floating beside it, which is what the loose clear link looked like. */ /* Forum: GitHub/Codeberg's comment-thread shape. Each post is a card — a muted header strip (author, time, hash, controls) over a padded body — so authorship and actions live in chrome, not in the prose. */ .f-actions { display: flex; align-items: center; gap: var(--sp-2); margin: var(--sp-3) 0 var(--sp-4); flex-wrap: wrap; } .f-actions form { margin: 0; } .f-post { border: 1px solid var(--rule); border-radius: var(--r-2); margin: var(--sp-4) 0; /* The author's hue as a WHISPER on the left edge — same 1px weight as every other border, only the color applied. */ border-left-color: oklch(var(--idn-l) var(--uc, var(--idn-c0)) var(--uh, 0) / 0.55); } .f-post-h .idn { width: 16px; height: 16px; } .f-spectrum { display: block; width: 100%; height: 6px; margin: var(--sp-0) 0 var(--sp-4); border-radius: var(--r-1); } .f-spectrum a rect:hover { fill-opacity: 1; } /* A mention reads like a BYLINE, not a highlighted link: the mark, then the name in the page's own colour. The accent is reserved for hover, because a name that is orange wherever it appears turns every conversation into a field of links and stops meaning "this is clickable" anywhere. */ a.uref { font-weight: 600; color: var(--fg); text-decoration: none; } /* References and their preview (RFC 0011 §14). The link is styled as a link because it IS one — the popover is an accelerator, not the way the reference is read. */ a.ref { text-decoration: underline dotted; text-underline-offset: 0.15em; } #refpop { border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-2) var(--sp-3); max-width: 32rem; background: var(--bg); color: var(--fg); box-shadow: 0 2px 8px rgba(0,0,0,0.15); } #refpop:not(:popover-open) { display: none; } /* The card belongs AT the reference that opened it, not centred in the viewport where the UA would put it. Anchor positioning is the real answer — it survives scroll and reflow — and the opener's measured left/top is the inline fallback where it is unknown. inset: auto undoes the UA's inset: 0, which would otherwise stretch the card across the screen the moment left is set. */ #refpop { position: fixed; inset: auto; margin: 0; } @supports (anchor-name: --a) { #refpop { position-anchor: --qrefpop; top: anchor(bottom); left: anchor(left); margin-top: var(--sp-1); position-try-fallbacks: flip-block, flip-inline; } } .refcard-sum { color: var(--muted); } .refcard[data-expired] .refcard-sum { font-style: italic; } a.chatquote { display: block; border-left: 3px solid var(--rule); padding-left: var(--sp-2); color: var(--muted); text-decoration: none; font-size: 0.9em; } a.chatreplies { color: var(--muted); font-size: 0.85em; } a.uref:hover { color: var(--accent); text-decoration: underline; } .f-parent, .f-replyto { color: inherit; } .f-replyto { font-weight: 600; } /* Stamp sits in the stats strip because that is where a reader already looks for what a repository amounts to. The pressed state rides on aria-pressed as well as colour — "is this stamped" must not be a claim made only in orange, which a colour-blind reader cannot hear. */ .stamp-form { display: inline-flex; margin: 0; } .stamp-btn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: var(--sp-0) var(--sp-2); font-size: var(--fs-md); font-weight: 600; } .stamp-btn .ic { width: 0.9em; height: 0.9em; } /* Stamping COMPLETES the mark: the empty die is the node and its rail, and the check-in dot at the centre appears when you stamp. A custom property rather than a selector, because <use> puts the symbol in a shadow tree that .stamp-btn.on .node cannot reach. */ .stamp-btn.on { border-color: var(--accent); color: var(--accent); --stamp-node: currentColor; } .stamp-n { font-weight: 400; opacity: 0.85; } /* The listing. Rows stack on a phone for the same reason the webhook rows do: a fixed-width control here would drag the page wide again. */ .ev-list { margin: var(--sp-2) 0; } .ev-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3); padding: var(--sp-2) var(--sp-4); } .ev-kind { font-size: 0.85em; background: var(--code-bg); border-radius: var(--r-1); padding: var(--sp-0) var(--sp-2); flex: 0 0 auto; } .ev-sum { flex: 1 1 16rem; min-width: 0; overflow-wrap: anywhere; } .ev-meta { color: var(--muted); font-size: 0.85em; } .p-h { font-size: var(--fs-lg); margin: 0 0 var(--sp-2); } /* .p-h carries no top margin — every use of it so far followed an element whose own bottom margin made the gap. A list card has none, so a heading after one sat flush against its border. Fixing the ADJACENCY rather than either element: a card keeps its spacing wherever else it is used, and the next heading placed after one is right without anybody noticing this rule exists. */ .list-card + .p-h { margin-top: var(--sp-5); } .stamped-list { margin: var(--sp-2) 0; } .stamped-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2) var(--sp-4); padding: var(--sp-2) var(--sp-4); } .stamped-list .p-name { font-weight: 600; } .stamped-list .p-desc { color: var(--muted); font-size: 0.9em; flex: 1 1 14rem; min-width: 0; } .stamped-empty { color: var(--muted); } /* Webhooks. A table here forced the page wider than a phone: three columns, one of them an input beside three buttons, and a table's intrinsic width wins over max-width on anything inside it — so every field on the page, including the ones above this section, ran off the right edge. Rows that stack are the fix, not a narrower table. */ .hook-list { margin: var(--sp-2) 0 var(--sp-4); } .hook-list li { padding: var(--sp-3) var(--sp-4); } .hook-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2) var(--sp-2); margin: 0; } /* A URL has no spaces, so it needs explicit permission to break or it is itself a minimum width the page must grow to. */ .hook-url { overflow-wrap: anywhere; min-width: 0; } .hook-state { font-size: 0.85em; color: var(--muted); } .hook-state.off { font-style: italic; } .hook-sub { font-size: 0.85em; color: var(--muted); margin: var(--sp-0) 0 var(--sp-2); } .hook-form { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: 0; } /* Grow to fill, shrink to nothing: min-width 0 is what lets a flex item actually get smaller than its content, which is the other half of why the row used to overflow. */ .hook-form input[name="events"] { flex: 1 1 11rem; min-width: 0; width: auto; } .hook-acts { display: flex; flex-wrap: wrap; gap: var(--sp-2); } .hook-lbl { flex: 0 0 auto; } .hook-add p { margin: var(--sp-2) 0; } .hook-add input[type="url"] { width: 100%; } /* An environment's run token ceiling, one labelled choice per kind of access, in a workflow's words rather than letters. */ .env-perms { border: 1px solid var(--rule); border-radius: var(--r-2); padding: var(--sp-2) var(--sp-3); margin: var(--sp-2) 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: var(--sp-2) var(--sp-4); } .env-perms legend { padding: 0 var(--sp-1); color: var(--muted); } .env-perm { display: flex; justify-content: space-between; align-items: center; gap: var(--sp-2); } .env-perm select { width: 11rem; flex: none; } .env-perms [data-hook="env-starter"] select { width: auto; max-width: 100%; } .env-perms [data-hook="env-perm-extra"] { grid-column: 1 / -1; margin: 0; } .env-perms [data-hook="env-starter"] { grid-column: 1 / -1; justify-content: flex-start; } /* A table sizes to its content, and an <input size=28> in a cell cannot shrink — so this table's min-content width exceeded a phone and made the WHOLE page wider than the viewport. Everything then ran off the right edge, which reads as fields having no padding. The table scrolls inside its own box instead; the page does not. */ .tablewrap { overflow-x: auto; max-width: 100%; } .caps-table { border-collapse: collapse; margin: var(--sp-2) 0; } .caps-table td { padding: var(--sp-1) var(--sp-2) var(--sp-1) 0; vertical-align: middle; } .caps-table input { font-family: var(--font-mono); } /* The data table: one shape for every admin listing (fleet People, sessions), the way a forge people already know draws them — a framed, full-width table, a quiet header row, one line per row, the actions right-aligned and small. Tables that were bare and narrow with stacked buttons read as unfinished; this is the same information with the same controls, composed. */ .dt-head { display: flex; align-items: baseline; gap: var(--sp-2); margin: var(--sp-5) 0 var(--sp-2); } .dt-head:first-child { margin-top: var(--sp-2); } .dt-title { font-size: var(--fs-lg); margin: 0; } .dt-n { font-size: var(--fs-sm); color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-4); padding: 0 var(--sp-2); font-variant-numeric: tabular-nums; } .dt-wrap { border: 1px solid var(--rule); border-radius: var(--r-2); overflow-x: auto; background: var(--surface); margin: 0 0 var(--sp-2); } .dt { width: 100%; border-collapse: collapse; font-size: var(--fs-body); } .dt th { text-align: left; font-size: var(--fs-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--rule); background: color-mix(in srgb, var(--rule) 22%, transparent); white-space: nowrap; } .dt td { padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--rule); vertical-align: middle; white-space: nowrap; } .dt tbody tr:last-child td { border-bottom: 0; } .dt tbody tr:hover td { background: color-mix(in srgb, var(--rule) 14%, transparent); } .dt tr.muted td { color: var(--muted); } .dt .who { display: inline-flex; align-items: center; gap: var(--sp-2); font-weight: 600; } .dt .who a { color: inherit; } .dt .sub { display: block; color: var(--muted); font-size: var(--fs-sm); font-weight: 400; margin-top: var(--sp-0); } .dt code.ip { font-size: var(--fs-sm); background: none; padding: 0; color: inherit; } .dt td.dt-act, .dt th.dt-act { text-align: right; width: 1%; } .dt td.dt-act form { display: inline-block; margin-left: var(--sp-2); } .dt td.dt-act .btn { font-size: var(--fs-sm); padding: var(--sp-1) var(--sp-2); } {{- /* A phone cannot hold a device name and a full IPv6 address side by side, and the wrapper's sideways scroll is one nobody finds inside a card. Below the fold the session table stops being a table: one block per device, each cell on its own line under the name it goes by. Only this table — the fleet's own are wider still and belong on a wider screen. This block sits AFTER the .dt base rules on purpose. It used to sit before them, where ".dt-stack td.dt-act { width: auto }" and ".dt td.dt-act { width: 1% }" have the same specificity and the later one wins: the action cell stayed 1% wide, the button was squeezed to its minimum content width, and overflow-wrap:anywhere — which is here for IPv6 — then broke "Sign out" one letter per line. Keep it last, and do not add .dt rules below it. */ -}} @media (max-width: 40rem) { .dt-stack thead { display: none; } .dt-stack, .dt-stack tbody, .dt-stack tr, .dt-stack td { display: block; width: auto; } {{- /* "main .dt-wrap > table { display: table }" further down the sheet — the rule that spares the forge's own wrapped tables from the rendered-content scroll treatment — outranks ".dt-stack" (0,1,1 against 0,1,0), so the table stayed a table while its rows became blocks. A display:table box shrink-wraps its content, so the stacked card was as wide as its widest cell and every row separator stopped mid-card. This wins on specificity rather than order, so it holds wherever it sits. */ -}} main .dt-wrap > table.dt-stack { display: block; } .dt-stack tr { padding: var(--sp-2) 0; border-bottom: 1px solid var(--rule); } .dt-stack tr:last-child { border-bottom: 0; } .dt-stack td { border: 0; padding: var(--sp-0) var(--sp-4); white-space: normal; overflow-wrap: anywhere; } .dt-stack td[data-label]::before { content: attr(data-label) " · "; color: var(--muted); } .dt-stack td.dt-act { text-align: left; width: auto; padding-top: var(--sp-1); } .dt-stack td.dt-act form { display: block; margin-left: 0; } {{- /* A control never breaks mid-word, whatever the cell around it needs for addresses. */ -}} .dt-stack td.dt-act .btn { overflow-wrap: normal; white-space: nowrap; } } .invite-card { border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--surface); padding: var(--sp-3) var(--sp-4) var(--sp-4); margin: var(--sp-2) 0 var(--sp-5); } .invite-card .dt-title { margin-bottom: var(--sp-2); } .invite-form { display: grid; grid-template-columns: 13rem minmax(0, 1fr) auto auto; gap: var(--sp-3); align-items: end; } .invite-form label { display: flex; flex-direction: column; gap: var(--sp-1); font-size: var(--fs-sm); color: var(--muted); } .invite-form label input:not([type=checkbox]) { font-size: var(--fs-body); width: 100%; } .invite-form .chk { flex-direction: row; align-items: center; gap: var(--sp-2); padding-bottom: var(--sp-2); white-space: nowrap; } .invite-form .btn-primary { padding: var(--sp-2) var(--sp-4); } @media (max-width: 40rem) { .invite-form { grid-template-columns: 1fr; } .invite-form .chk { padding-bottom: 0; } } /* The People section and the per-person editor (RFC 0008). A row is a name, chips, and one sentence: the chips are the role and the ways a person differs from it, so an ordinary row is one chip long. */ .roster { margin: var(--sp-2) 0; } .roster li { padding: var(--sp-2) var(--sp-4); } .roster-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); } .roster-can { margin: var(--sp-0) 0 0; color: var(--muted); font-size: var(--fs-body); } .chip.role { color: var(--fg); border-color: var(--fg); } /* A repository reached through a team holds no grant row, so its chip says that instead of a role. Muted, because it is the weaker claim of the two and should not read as loudly as a granted one. */ .chip.role.t-team { color: var(--muted); border-color: var(--rule); } .chip.plus { color: var(--ok); } .chip.minus { color: var(--warn); } .chip.owns { font-style: italic; } .roster-add { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); margin-top: var(--sp-2); } .person-form fieldset { border: 1px solid var(--rule); margin: var(--sp-2) 0; padding: var(--sp-2) var(--sp-3); } .person-form legend { font-weight: 600; padding: 0 var(--sp-1); } .person-form label { display: block; padding: var(--sp-0) 0; } .person-form .roles label small { color: var(--muted); } .person-form .abilities { display: grid; grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); gap: 0 var(--sp-4); } .person-form .abilities label.inherited { color: var(--muted); } .person-form .abilities .ab-from { font-size: .8em; color: var(--muted); white-space: nowrap; } .person-form label.adv { color: var(--muted); } .person-form .raw { margin: var(--sp-2) 0; } .status.err { color: var(--danger); } /* Settings (RFC 0008 refit). One long column of forms beside a rail that says where the reader is; a summary card above both. The footer flows here rather than floating: nothing on this page is read against it, and the last Save button used to sit under it. */ body.flow-footer { padding-bottom: 0; min-height: 100dvh; display: flex; flex-direction: column; } /* In the flow, but never adrift: a short page (fleet settings is one form) pushes the footer to the bottom of the viewport; a long one has it after the last control, where the reader arrives. */ /* A flex item with auto side margins is not stretched: it takes its fit-content width, and fit-content is at least min-content, which for the settings page is the nowrap rail — main grew to 1200px on a phone and the page scrolled sideways. A definite width restores the block behaviour: the rail and the clone line scroll inside it. */ body.flow-footer > main { flex: 1 0 auto; width: 100%; min-width: 0; box-sizing: border-box; } /* A flowing footer needs no safe-area inset, and adding one is what makes it look detached on a phone. The inset exists for the FIXED footer, which lies over the page and can be overlapped by the home indicator once Safari hides its toolbar. A flowing footer sits at the end of a 100dvh body, and dvh already excludes the browser chrome — so the inset is counted twice and leaves an empty band under the text. The gap above the text shrinks on a phone too: 2.5rem between the rule and the line is generous on a desktop and reads as a stranded bar on a short page. */ body.flow-footer footer { position: static; backdrop-filter: none; margin-top: auto; padding-top: var(--sp-6); padding-bottom: 0; } @media (max-width: 40rem) { body.flow-footer footer { padding-top: var(--sp-4); } } .set-summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-4); padding: var(--sp-3) var(--sp-4); margin: var(--sp-2) 0 var(--sp-4); border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--surface); } .set-name { margin: 0; font-size: var(--fs-lg); } .set-counts { margin: 0; display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); color: var(--muted); font-size: var(--fs-body); font-variant-numeric: tabular-nums; } .set-counts a { color: inherit; text-decoration: none; } .set-counts a:hover { color: var(--fg); } /* The clone line is a fact, not a setting; it keeps its <pre> so the text can be selected by hand where the copy button cannot appear. */ .set-clone { flex: 1 1 100%; display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } .set-clone pre { margin: 0; flex: 1 1 auto; min-width: 0; overflow-x: auto; font-size: var(--fs-md); } .set-copy { background: none; border: 0; color: var(--muted); cursor: pointer; padding: var(--sp-0); line-height: 1; display: inline-flex; align-items: center; } .set-copy .ic { width: 0.95rem; height: 0.95rem; } .set-copy:hover { color: var(--accent); } .set-copy.done { color: var(--add); } .set-grid { display: grid; grid-template-columns: 12rem minmax(0, 1fr); gap: 0 var(--sp-6); align-items: start; } /* The rail borrows the wiki TOC's shape: a rule down the left, the current entry drawn in the accent. Reset the global nav-link box first, as .w-toc does, or the current entry gets four sides. */ .set-rail { position: sticky; top: 0.6rem; font-size: var(--fs-md); } .set-rail ul { list-style: none; margin: 0; padding: 0; border-left: 2px solid var(--rule); } .set-rail ul ul { border-left: 0; } .set-rail a { display: block; padding: var(--sp-1) var(--sp-2); margin-left: -2px; border: 0; border-left: 2px solid transparent; border-radius: 0; background: none; text-decoration: none; color: var(--muted); line-height: 1.35; } .set-rail ul ul a { padding-left: var(--sp-5); font-size: var(--fs-sm); } .set-rail a:hover { color: var(--fg); } .set-rail a:focus-visible { outline: none; color: var(--accent); border-left-color: var(--accent); } .set-rail a[aria-current] { color: var(--accent); border-left-color: var(--accent); font-weight: 600; } .set-rail .n { font-variant-numeric: tabular-nums; opacity: 0.8; } .set-rail .n::before { content: "· "; } .set-body { min-width: 0; } .set-body > section { padding: var(--sp-2) 0 var(--sp-5); border-bottom: 1px solid var(--rule); scroll-margin-top: 0.8rem; } .set-body > section:last-child { border-bottom: 0; } .set-body h2 { font-size: var(--fs-lg); margin: var(--sp-2) 0 var(--sp-2); } .set-body h3 { font-size: var(--fs-base); margin: var(--sp-4) 0 var(--sp-1); } .set-body section:target > h2 { color: var(--accent); } .set-body details.why > summary { color: var(--muted); font-size: var(--fs-body); padding: var(--sp-1) 0; } details.capref > summary { cursor: pointer; padding: var(--sp-1) 0; } table.capref tr.unused td { color: var(--muted); opacity: 0.6; } /* A control that cannot act says why, beside itself, in the muted voice — not as prose three paragraphs away. */ .set-why { color: var(--muted); font-size: var(--fs-md); margin: var(--sp-1) 0 0; } /* A protected branch reads as a STATE, not a warning: the rule is ordinary and most repositories have one. Distinct from the closed badge beside it so the two are not read as the same kind of thing. */ .tl-badge.b-prot { border-color: var(--accent); color: var(--accent); } /* Qualified with .tl-badge on purpose: the base .tl-badge rule is LATER in this file and its border shorthand beats a bare .b-prot border-color on a specificity tie, so the border was silently dropped while the colour took. Same shape as the m-failing and imp-run rules above. */ /* A credential shown exactly once: it has to be selectable, legible and impossible to mistake for prose, and it must wrap rather than run off a phone. */ .push-token { font-family: var(--font-mono); font-size: var(--fs-body); /* break-all split the credential mid-GROUP on a phone (…-8VD / T), which is the one place a reader has to transcribe accurately. anywhere prefers the existing break opportunities — the group separators — and only splits a group if one alone overflows. No backtick in this comment: the stylesheet is a Go raw string. */ user-select: all; word-break: normal; overflow-wrap: anywhere; white-space: pre-wrap; background: var(--code-bg); border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-2); margin: var(--sp-2) 0; } /* A rejected submission hands the author their own text back, so the block has to be readable and must not run away with the page. */ .f-held-body { max-height: 18rem; overflow: auto; font-size: var(--fs-md); white-space: pre-wrap; background: var(--code-bg); border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-2); } .row-act { display: inline-flex; gap: var(--sp-2); align-items: center; margin: 0; } .roster-head .row-act { margin-left: auto; } .row-act button:disabled, .danger-row button:disabled { background: none; color: var(--muted); border-style: dashed; cursor: not-allowed; } .field-err { display: block; color: var(--danger); } input[aria-invalid="true"] { border-color: var(--danger); } /* The role picker as one segmented control: radios underneath, so the form posts the same field it always did. */ .segc { display: inline-flex; border: 1px solid var(--rule); border-radius: var(--r-row); padding: 0; margin: 0; overflow: hidden; } .segc label { position: relative; padding: var(--sp-1) var(--sp-3); cursor: pointer; } .segc label + label { border-left: 1px solid var(--rule); } .segc input { position: absolute; inset: 0; opacity: 0; margin: 0; } .segc label:has(input:checked) { background: var(--code-bg); font-weight: 600; } .segc label:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; } /* Chip inputs (unit 2): the text field is kept, hidden, and the script draws its tokens as chips with an entry beside them. A palette shows every option as a toggle — the event nouns — where an entry shows only what is chosen. Without the script the field is a plain input with its datalist. */ .chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1); min-width: 12rem; max-width: 100%; padding: var(--sp-1) var(--sp-2); border: 1px solid var(--rule); border-radius: var(--r-row); background: var(--bg); vertical-align: middle; } .chips:focus-within { outline: 2px solid var(--accent); outline-offset: -1px; } .chips .tok { border: 1px solid var(--rule); border-radius: var(--r-4); background: var(--code-bg); color: var(--fg); padding: var(--sp-0) var(--sp-2); font-size: var(--fs-md); line-height: 1.4; cursor: pointer; } .chips .tok::after { content: " ×"; color: var(--muted); } .chips-palette .tok { background: none; color: var(--muted); } .chips-palette .tok::after { content: none; } .chips-palette .tok.on { background: var(--code-bg); color: var(--fg); border-color: var(--accent); } .chips .chips-entry { flex: 1 1 6rem; min-width: 6rem; border: 0; background: none; padding: var(--sp-0) var(--sp-1); font: inherit; color: inherit; } .chips .chips-entry:focus { outline: none; } .set-body .caps-table td .chips { width: 100%; } button[aria-disabled="true"] { opacity: 0.55; } .set-filter { margin: var(--sp-2) 0; } .set-filter input { width: 100%; max-width: 20rem; } .set-rail a[data-dirty]::after { content: " ●"; color: var(--accent); font-size: 0.6em; vertical-align: middle; } /* A rule reads as a sentence; its fields live under the disclosure, so the list is scannable and the edit is one click away. */ /* A rule is a card row: the sentence, its warnings, then the editor folded under it. The row carries the card's inner padding — the list-card frame draws only the edges — and the editor's fields line up on one label column so the eye reads down, not across. */ .set-rules { list-style: none; margin: var(--sp-2) 0; padding: 0; } .set-rules > li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--rule); } .set-rules > li:last-child { border-bottom: 0; } .rule-edit, .set-rules .set-new > details { margin-top: var(--sp-1); } .rule-edit > summary:focus-visible, .set-rules .set-new > details > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--r-1); } .rule-edit[open] > summary, .set-rules .set-new > details[open] > summary { margin-bottom: var(--sp-2); } .rule-edit .row-act { margin: var(--sp-2) 0 var(--sp-0); } .rule-said { margin: 0 0 var(--sp-1); } .rule-warn { margin: var(--sp-0) 0; color: var(--warn); font-size: 0.92em; } .rule-edit > summary, .set-rules .set-new > details > summary { cursor: pointer; color: var(--muted); font-size: 0.92em; } .rule-f { margin: var(--sp-2) 0; } .rule-f label { display: grid; grid-template-columns: 8.5rem minmax(0, 1fr); align-items: center; gap: var(--sp-2) var(--sp-2); color: var(--muted); } .rule-f label:has(input[type="checkbox"]) { display: flex; align-items: center; } @media (max-width: 40rem) { .rule-f label { grid-template-columns: 1fr; } } .rule-f input[type="text"], .rule-f input:not([type]) { max-width: 100%; } /* A field with its unit shares the one column; the unit is not a third grid item on a row of its own. */ .rule-f .with-unit { display: flex; align-items: center; gap: var(--sp-2); } .rule-f .with-unit input { width: 7ch; } .roster .row-act { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); } .roster .row-act select { max-width: 12rem; } /* What you can DO to this line of work. Kept apart from the row above, which is where you GO — a live button next to the CLOSED marker made a status and a control read as the same kind of thing. The link and the buttons are given one style here rather than inherited from anywhere: a submit and an anchor start life looking nothing alike, and these two sit side by side. */ /* The queue's blind spot, set apart from the queue itself: same row shape, its own heading, a rule above so it does not read as more of the list. */ .unproposed { margin: var(--sp-5) 0 0; border-top: 1px solid var(--rule); padding-top: var(--sp-4); } /* The delivery log, folded under each endpoint: a status, what it was, when, and how long — the row an owner reads to answer "did it fire". */ .hook-log { margin: var(--sp-1) 0 0; } .hook-log summary { cursor: pointer; color: var(--muted); font-size: var(--fs-body); } .hook-att { width: 100%; border-collapse: collapse; margin: var(--sp-2) 0 0; font-size: var(--fs-md); } .hook-att td { padding: var(--sp-1) var(--sp-2) var(--sp-1) 0; vertical-align: top; } .d-st { font-weight: 600; font-variant-numeric: tabular-nums; } .d-st.ok { color: var(--add); } .d-st.bad { color: var(--del); } .d-when, .d-ms { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } .d-err { color: var(--del); overflow-wrap: anywhere; } /* A kind is one word and must not be hyphenated into a column of letters, which is what a four-character cell does to it. */ .hook-att td code { white-space: nowrap; } @media (max-width: 40rem) { /* Narrow: an attempt is a line that wraps, not a row of columns that cannot. Same move the caps table makes at this width. */ .hook-att, .hook-att tbody, .hook-att tr, .hook-att td { display: block; width: auto; } .hook-att tr { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-0) var(--sp-2); padding: var(--sp-2) 0; border-bottom: 1px solid var(--rule); } .hook-att tr:last-child { border-bottom: 0; } .hook-att td { padding: 0; } .hook-att td:empty { display: none; } .hook-att .row-act { margin-left: auto; } .d-err { flex-basis: 100%; } } .trail { list-style: none; margin: var(--sp-2) 0; padding: 0; } .trail li { padding: var(--sp-1) 0; border-bottom: 1px solid var(--rule); } .trail li:last-child { border-bottom: 0; } .trail-what { display: block; } .unproposed h2 { font-size: var(--fs-base); margin: 0 0 var(--sp-2); color: var(--muted); font-weight: 600; } .ci-acts { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-3); } .ci-acts form { display: contents; } .ci-acts a.btn, .ci-acts button { display: inline-flex; align-items: center; gap: var(--sp-2); font: inherit; font-weight: 600; color: var(--fg); text-decoration: none; background: var(--code-bg); border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-1) var(--sp-3); cursor: pointer; } .ci-acts a.btn:hover, .ci-acts button:hover { border-color: var(--fg); text-decoration: none; } .ci-acts .set-why { margin: 0; } .rule-probe { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: var(--sp-2) 0 var(--sp-1); } .rule-probe label { display: flex; align-items: center; gap: var(--sp-2); color: var(--muted); } .probe-said { margin: var(--sp-1) 0 0; } .danger-row { padding: var(--sp-1) 0; } .danger-row + .danger-row { border-top: 1px solid var(--rule); margin-top: var(--sp-2); } /* One dialog for every destructive button: the sentence of consequence and the button's own word, nothing else. */ dialog.confirm { border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--bg); color: var(--fg); padding: var(--sp-4) var(--sp-4); max-width: 26rem; box-shadow: var(--shadow-1); } dialog.confirm::backdrop { background: rgba(0, 0, 0, 0.4); } .confirm-why { margin: 0 0 var(--sp-4); } .confirm-acts { display: flex; justify-content: flex-end; gap: var(--sp-2); margin: 0; } .danger-btn { color: var(--del); border-color: var(--del); } @media (max-width: 40rem) { /* The rail becomes a strip pinned under the repo bar — the one sticky thing on the page — and every nested entry joins it. */ .set-grid { display: block; } .set-rail { position: sticky; top: 0; z-index: 4; background: var(--bg); margin: 0 calc(-1 * var(--gutter)); padding: var(--sp-2) var(--gutter); border-bottom: 1px solid var(--rule); overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; } .set-rail ul { display: flex; gap: var(--sp-1); border-left: 0; } .set-rail li, .set-rail ul ul { display: contents; } .set-rail a { display: inline-block; margin: 0; padding: var(--sp-1) var(--sp-3); border: 1px solid var(--rule); border-radius: var(--r-4); } .set-rail ul ul a { padding-left: var(--sp-3); font-size: var(--fs-sm); } .set-rail a[aria-current], .set-rail a:focus-visible { border-color: var(--accent); } .set-body > section { scroll-margin-top: 3.4rem; } /* Tables become cards: one rule per card, each cell labelled by the name the desktop kept in aria-label. Nothing scrolls sideways. */ .set-body .tablewrap { overflow: visible; } .set-body .caps-table, .set-body .caps-table tbody, .set-body .caps-table td { display: block; } /* Once the rows stack there is no column for a header to sit over, and a thead left in table layout renders as one bold heading per column above the whole table. The cells carry data-lbl instead. */ .set-body .caps-table thead { display: none; } .set-body .caps-table tr { display: grid; gap: var(--sp-1); padding: var(--sp-2) 0; border-bottom: 1px solid var(--rule); } .set-body .caps-table td { padding: 0; } .set-body .caps-table td:empty { display: none; } .set-body .caps-table td[data-lbl]::before { content: attr(data-lbl); display: block; font-size: var(--fs-xs); color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } .set-body .caps-table input:not([type=checkbox]) { width: 100%; } .set-body input:not([type=checkbox]):not([type=radio]):not([type=file]) { max-width: 100%; } .set-body code, .set-body .hook-url { overflow-wrap: anywhere; } .set-summary { padding: var(--sp-2) var(--sp-3); } /* Thumb-sized rows for the radios; the segmented control fills the width. */ .vis-form label { min-height: 2.75rem; display: flex; align-items: center; gap: var(--sp-2); } /* The segmented control becomes pills that wrap. As one strip it had a min-content width wider than a phone (five roles, none of them able to shrink), so it did not scroll — it was CLIPPED, and with it the sentence beside it, which a flex column had stretched to the strip's width. Losing a role you cannot see is worse than losing the joined-up look. */ .segc { display: flex; flex-wrap: wrap; gap: var(--sp-1); width: 100%; border: 0; overflow: visible; } .segc label, .segc label + label { flex: 1 1 auto; min-width: 0; min-height: 2.75rem; display: flex; align-items: center; justify-content: center; border: 1px solid var(--rule); border-radius: var(--r-row); } .roster-add { flex-direction: column; align-items: stretch; } .roster-add > label { display: flex; align-items: center; gap: var(--sp-2); } .roster-add > label input { flex: 1; min-width: 0; } .roster-head .row-act { margin-left: 0; width: 100%; } .hook-acts { width: 100%; } .hook-acts button { flex: 1; } /* The Save bar sticks to the bottom only while the form is dirty (Datastar sets the class); clean, it is a paragraph like any other. */ form.dirty .save-bar { position: sticky; bottom: 0; z-index: 3; margin: 0 calc(-1 * var(--gutter)); padding: var(--sp-2) var(--gutter); background: var(--surface); border-top: 1px solid var(--rule); } form.dirty .save-bar button { width: 100%; } } .ci-override { margin-top: var(--sp-2); } /* Inside the decision row the override is a LINE of its own under the controls, not a flex item wedged between two buttons: the summary reads as a quiet link, and opening it lays the reason and the button out on one wrapping line. */ .d-act .ci-override { flex-basis: 100%; margin: 0; order: 9; } .d-row.d-armed { background: color-mix(in oklab, var(--accent) 8%, transparent); } .d-act .ci-override summary { display: inline-block; padding: var(--sp-0) 0; font-size: 0.95em; } .d-act .ci-override p { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: var(--sp-2) 0 0; } .d-act .ci-override input { flex: 1 1 16rem; min-width: 0; } /* The run page's log panes (RFC 0009 §logs). */ /* A red check on the decision card: the row is the summary, the failed step's tail is the body, open by default — red is exactly when the reader needs the why without leaving the decision. */ /* The CI list: every row answers what happened. The chip and the title both open the run; a red row says which step and quotes the line. */ .ci-filter { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; margin: 0 0 var(--sp-4); } .ci-health { display: flex; gap: var(--sp-2); align-items: baseline; flex-wrap: wrap; border: 1px solid var(--rule); border-radius: var(--r-2); padding: var(--sp-2) var(--sp-4); margin: 0 0 var(--sp-2); } /* The tripwire strip: the acknowledge button sits at the row's end, the "all" form reads as one line with the history link. */ .ci-gate form { margin-left: auto; } .ci-gate-foot form.inline { display: inline; } .ci-pager { display: flex; gap: var(--sp-2); justify-content: space-between; margin: var(--sp-2) 0 0; } .ci-pager a:only-child:last-child { margin-left: auto; } /* The People tab: an invite link must survive being copied whole, and a shut account reads as shut. */ .invite-link { word-break: break-all; user-select: all; } /* Sign-in and invite forms: fields a hand's width, not a screen's. */ /* A page that is one form: the COLUMN narrows, so the heading stays over the fields it names. Centring the form inside a listing's column would have left the h1 at the far left of it. */ main.narrow { max-width: 34rem; } .auth-form { max-width: 24rem; } .auth-form input { width: 100%; box-sizing: border-box; font-size: max(var(--fs-base), 1em); } tr.muted td { color: var(--muted); } .ci-rows { border: 1px solid var(--rule); border-radius: var(--r-2); } .ci-row { display: flex; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--rule); } .ci-row:last-child { border-bottom: none; } .ci-chip { text-decoration: none; padding-top: var(--sp-0); } .ci-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-0); } .ci-title { color: var(--ink, inherit); font-weight: 600; text-decoration: none; overflow-wrap: anywhere; } .ci-title:hover { text-decoration: underline; } .ci-why { font-size: var(--fs-md); overflow-wrap: anywhere; } .ci-why code { font-size: 0.85em; } .ci-meta { color: var(--muted); font-size: var(--fs-sm); } .ci-side { display: flex; gap: var(--sp-3); align-items: baseline; white-space: nowrap; } .ci-dur { font-variant-numeric: tabular-nums; } .ci-rerun { font-size: var(--fs-sm); padding: var(--sp-0) var(--sp-2); } .ci-pipes { margin-top: var(--sp-3); } /* On a phone the row's three columns do not fit: duration, age and Re-run are nowrap and take about 200px, leaving the title 150 and forcing it to break mid-word — "gofmt cmd/quarr / y/main.go". The side drops to its own line instead, which gives the title the full width and lets the badge stay beside the words it qualifies. */ @media (max-width: 34rem) { .ci-row { flex-wrap: wrap; gap: var(--sp-2); } .ci-main { flex: 1 1 100%; } .ci-side { width: 100%; justify-content: flex-end; } /* Break only where a word genuinely cannot fit: overflow-wrap anywhere splits ordinary prose mid-syllable, which is what made these unreadable at this width. */ .ci-title, .ci-why { overflow-wrap: break-word; } } .ci-editor textarea { width: 100%; font: var(--fs-md)/1.55 var(--font-mono); background: none; color: inherit; border: 1px solid var(--rule); border-radius: var(--r-2); padding: var(--sp-3) var(--sp-4); resize: vertical; } details.l-check summary { list-style: none; cursor: pointer; display: flex; align-items: baseline; gap: var(--sp-2); flex-wrap: wrap; } details.l-check summary::-webkit-details-marker { display: none; } details.l-check .runlog { margin: var(--sp-2) 0 var(--sp-0); max-height: 22rem; overflow-y: auto; } /* The steps are a LIST of one run's parts, so they read as one object with rules between them rather than three cards with gaps. The whole list takes the border; each step takes a divider. The caret is the affordance the native triangle used to be, on the far side of the row: the status icon leads, because what a reader scans for is which step failed, not which step opens. */ .runsteps { border: 1px solid var(--rule); border-radius: var(--r-2); overflow: hidden; margin: var(--sp-2) 0 var(--sp-4); } .runstep + .runstep { border-top: 1px solid var(--rule); } .runstep > summary { align-items: center; } .runstep > summary:hover { background: var(--surface); } .runstep[open] > summary { border-bottom: 1px solid var(--rule); } .runstep .d-line { flex: 1; } .runstep .d-caret { flex: none; width: 14px; height: 14px; color: var(--muted); transition: transform var(--t-fast) ease; } .runstep[open] .d-caret { transform: rotate(180deg); } .runstep .d-body { padding: var(--sp-2) var(--sp-4) var(--sp-3); background: var(--bg); } /* A retried step's attempts, each its own fold inside the step. */ .runattempt + .runattempt { margin-top: var(--sp-2); } .runattempt > summary { display: flex; align-items: center; gap: var(--sp-1); cursor: pointer; color: var(--muted); } /* A log line is as long as the command that wrote it, and nothing wraps it. The block scrolls inside its own box or the page scrolls sideways — which on a phone is the whole page, for one line of somebody's stack trace. */ .runlog { line-height: 1.45; overflow-x: auto; overflow-y: auto; max-width: 100%; min-width: 0; } /* The run page's log controls. A row that wraps to a column on a phone rather than scrolling sideways, because the log already owns the horizontal axis. */ .run-tools { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-4); align-items: center; justify-content: space-between; margin: var(--sp-4) 0 var(--sp-2); } .run-filter, .run-acts { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; font-size: var(--fs-md); } .run-filter input[type="search"] { min-width: 12rem; } .run-acts form { display: inline; } .run-clear { font-size: var(--fs-md); } /* The wrap toggle is a checkbox and a sibling selector: rewrapping text that is already on the page must not cost a round trip. */ .run-wrapbox { margin: 0; } .run-tools:has(.run-wrapbox:checked) ~ * .runlog, .run-tools:has(.run-wrapbox:checked) ~ .runlog { white-space: pre-wrap; overflow-wrap: anywhere; } /* A long step's log scrolls past its own heading, and then nothing on screen says which step you are reading. */ details.runstep > summary { position: sticky; top: 0; z-index: 1; background: var(--bg); padding-top: var(--sp-1); padding-bottom: var(--sp-1); } /* The row that HOLDS the log has to be shrinkable too: a pre can only scroll inside a parent that is itself allowed to be narrower than the text. Without this the panel widened and the log ran off the card. */ details.l-check, details.l-check > summary, .l-decide { min-width: 0; } details.l-check > summary { flex-wrap: wrap; } .runlog .ll { display: block; } .runlog .ll-err { color: var(--del); } .runlog .ll-mark { color: var(--muted); font-style: italic; } .ll-n { display: inline-block; min-width: 3ch; margin-right: 1ch; color: var(--muted); text-align: right; text-decoration: none; user-select: none; } .ll:target { background: var(--mark); } /* RFC 0009: the decision card. Status rows lead — icon, headline, muted subline — the verbs close the card; nothing about the decision lives anywhere else on the page. */ .l-decide { border: 1px solid var(--rule); border-radius: var(--r-2); margin: var(--sp-3) 0; } /* One row, wherever it is drawn. These four rules were scoped to .l-decide, the landing's decision card. The run page draws the SAME <details class="d-row"> markup and sat outside that scope, so it got nothing but the border .runstep adds: padding 0, summary still display:list-item, and the browser's own disclosure triangle beside a status icon already saying the same thing. Three cramped boxes with a stray arrow, which is what dmestas photographed. What is genuinely the decision card's — the divider between its rows, and its non-details rows laying out as flex — stays scoped below. */ .d-row { display: block; padding: var(--sp-3) var(--sp-4); } .d-row > summary { list-style: none; cursor: pointer; display: flex; align-items: baseline; gap: var(--sp-2); } .d-row > summary::-webkit-details-marker { display: none; } .l-decide .d-row { border-bottom: 1px solid var(--rule); } .l-decide div.d-row { display: flex; align-items: baseline; gap: var(--sp-2); } .d-ic { flex: none; width: 16px; height: 16px; transform: translateY(2px); } .d-ic.pass { color: var(--add); fill: var(--add); } .d-ic.fail { color: var(--del); fill: var(--del); } .d-ic.wait { color: var(--muted); fill: var(--muted); } /* A lane the engine still reports after the verdict is in. */ .d-ic.stopped { color: var(--muted); fill: var(--muted); } .d-line { min-width: 0; } .d-sub { color: var(--muted); font-size: var(--fs-body); } .d-meta { color: var(--muted); font-size: var(--fs-md); margin-left: auto; white-space: nowrap; } .d-meta a { color: var(--muted); } .d-body { padding: var(--sp-2) 0 0 var(--sp-5); } .d-body .l-check { margin: var(--sp-1) 0; } /* The waiting row is a flex line of badge, name and note. The details row is NOT: a details element made flex puts its body in a slot the sheet cannot reach, and a log inside that slot grew to its longest line and ran off the card. The details stays a block; only its summary lays out as a line. */ p.l-check { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); } details.l-check { display: block; } .d-act { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); row-gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); } /* A refused verb must not look pressable: the card's disabled buttons go ghost — dashed, muted, no fill — the same honesty the reason sentence beside them speaks. */ .d-act button:disabled { background: none; color: var(--muted); border: 1px dashed var(--rule); cursor: not-allowed; } /* acting: the form has been submitted and the server is working. The pressed control says so with a spinner; the rest stop taking presses, so a second click cannot start a second one. Its own class, not part of .d-act, because .d-act also carries the landing card's layout, its ghost-disabled rule and the CI override block -- four concerns under one name, which is why this behaviour lived on exactly one page and 138 other submit buttons across the forge had no pending state at all. A form opts in with the acting class plus data-signals:busy; a button opts in with data-class:is-busy and a .b-busy label. Without script it is a plain form, as before. */ .acting .b-busy, .acting .d-progress { display: none; } .acting button.is-busy .b-long, .acting button.is-busy .b-abbr, .acting button.is-busy .b-idle { display: none; } .acting button.is-busy .b-busy { display: inline-flex; align-items: center; gap: var(--sp-2); } .acting button.is-busy .b-busy::before { content: ""; width: 0.8em; height: 0.8em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: d-spin 0.8s linear infinite; } .acting.busy button, .acting.busy summary { pointer-events: none; opacity: 0.55; } .acting.busy button.is-busy { opacity: 1; } .acting .d-progress.show { display: inline; } @keyframes d-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .acting button.is-busy .b-busy::before { animation: none; border-right-color: currentColor; opacity: 0.5; } } .l-decide > :last-child { border-bottom: none; } .ci-override summary { color: var(--muted); cursor: pointer; } .vis-form label { display: block; padding: var(--sp-0) 0; } .f-post-f { display: flex; justify-content: flex-end; padding: 0 var(--sp-3) var(--sp-3); } .f-replybtn { font-size: var(--fs-md); padding: var(--sp-1) var(--sp-3); } .f-reply-note { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--rule); background: var(--surface); border-radius: var(--r-2) var(--r-2) 0 0; font-size: var(--fs-md); color: var(--muted); } .f-post-h { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; padding: var(--sp-2) var(--sp-3); background: var(--surface); border-bottom: 1px solid var(--rule); border-radius: var(--r-2) var(--r-2) 0 0; font-size: var(--fs-md); color: var(--muted); } .f-post-h strong { color: var(--fg); } .f-post-h .f-spacer { margin-left: auto; } .f-post-b { padding: var(--sp-4) var(--sp-4) var(--sp-0); } .f-post-b > :first-child { margin-top: 0; } /* The vote pill: one bordered capsule, arrow · score · arrow. The two forms inside are the no-script mechanism; the pill is just clothes. */ .vote-pill { display: inline-flex; align-items: center; border: 1px solid var(--rule); border-radius: var(--r-4); background: var(--bg); line-height: 1.2; } .vote-pill form { margin: 0; display: inline-flex; } .vote-pill button { border: 0; border-radius: var(--r-4); background: none; padding: var(--sp-0) var(--sp-2); font-weight: normal; font-size: var(--fs-md); } .vote-pill button:hover { background: var(--code-bg); border-color: transparent; } .vote-pill .vscore { padding: 0 var(--sp-0); font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; } .vote-cast { color: var(--accent); } /* Moderator actions fold behind a no-script kebab: details/summary. */ .f-kebab { position: relative; display: inline-block; } .f-kebab > summary { list-style: none; cursor: pointer; padding: 0 var(--sp-2); border-radius: var(--r-1); color: var(--muted); font-weight: 600; } .f-kebab > summary::-webkit-details-marker { display: none; } .f-kebab > summary:hover { background: var(--code-bg); color: var(--fg); } .f-kebab-pop { position: absolute; right: 0; top: 1.6rem; z-index: 5; display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2); background: var(--bg); border: 1px solid var(--rule); border-radius: var(--r-row); box-shadow: 0 4px 14px rgba(0,0,0,0.25); white-space: nowrap; } .f-kebab-pop form { margin: 0; display: flex; align-items: center; gap: var(--sp-2); } .f-kebab-pop input { font-size: var(--fs-md); } .f-kebab-pop button { font-size: var(--fs-md); } /* The reply composer: textarea in a card, actions in a footer strip — the comment box every forge reader already knows. */ .f-reply { border: 1px solid var(--rule); border-radius: var(--r-2); margin-top: var(--sp-4); overflow: clip; } .f-reply:focus-within { border-color: var(--accent); } .f-reply textarea { border: 0; border-radius: var(--r-2) var(--r-2) 0 0; } .f-reply textarea:focus-visible { outline: none; } /* The decision row is ONE line. It grew from three verbs to five as landings learned to update and to land a whole strata, and the old rule let flex shrink the buttons: every label folded to three lines and the leftmost still ran off the side of a phone. flex:none and nowrap keep each button one line at its own width; where five cannot fit, the LABELS shorten rather than the row breaking. */ /* A confirmation is an EVENT, so it has a lifetime. It holds long enough to be read, then fades and COLLAPSES — collapsing as well as fading, so the page does not keep a paragraph of empty space where a sentence used to be. Done in CSS, not script: this page must behave the same without JavaScript, and an animation is the one way to give an element a lifetime that costs nothing when the script is absent. role="status" announces it on insert, before any of this runs, so a screen reader has already heard it. Reduced motion keeps it: someone who has asked for less movement is asking for less MOVEMENT, not for the message to be taken away. */ /* The confirmation lives ON the control that did the saving. Both labels sit in the same grid cell, so the button is sized by the wider of them and NOTHING moves when they swap — the whole reason to put it here rather than in the page's flow. The revert is CSS too, on the same four-second beat as the old flash: without script the label must still find its way back to "Save", and an animation is what gives it a lifetime for free. */ .btn-swap { display: inline-grid; align-items: center; justify-items: center; } .btn-swap > span { grid-area: 1 / 1; } /* Green, from the palette's own token — the same --add that marks a passing run and an open glyph, so "it worked" reads the same colour everywhere rather than this button inventing one. */ .sw-b { opacity: 0; color: var(--add); } .did-save { border-color: var(--add); } .did-save .sw-a { opacity: 0; animation: sw-back-a 300ms ease 4s forwards; } .did-save .sw-b { opacity: 1; animation: sw-back-b 300ms ease 4s forwards; } @keyframes sw-back-a { to { opacity: 1; } } @keyframes sw-back-b { to { opacity: 0; } } /* Reduced motion keeps the word and drops the movement, as the flash does: the button simply stays "Saved" until the page next renders. */ @media (prefers-reduced-motion: reduce) { .did-save .sw-a, .did-save .sw-b { animation: none; } } .flash-once { overflow: hidden; animation: flash-out 400ms ease 4s forwards; } @keyframes flash-out { to { opacity: 0; max-height: 0; margin-top: 0; margin-bottom: 0; padding-top: 0; padding-bottom: 0; } } @media (prefers-reduced-motion: reduce) { .flash-once { animation: none; } } /* Wraps: the decision row grows a refusal sentence and an override disclosure (RFC 0009), and clipping a button is never the answer. */ .f-reply-foot { display: flex; flex-wrap: wrap; row-gap: var(--sp-2); justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-2) var(--sp-2); border-top: 1px solid var(--rule); background: var(--surface); border-radius: 0 0 var(--r-2) var(--r-2); } /* One line per button, always: a label that folds makes a control three times the height of its neighbours, which reads as a different kind of thing rather than as the same button with more words. */ .f-reply-foot > * { flex: none; white-space: nowrap; } /* Below the width where five verbs fit, the words give way — not the line. The short form keeps the same verb, so nothing has to be relearned on a phone. Reject keeps its word: there is no X in the sprite, and an unlabelled destructive control is worse than a cramped one. */ .b-abbr, .b-icon { display: none; } @media (max-width: 30rem) { .f-reply-foot { gap: var(--sp-1); padding-left: var(--sp-1); padding-right: var(--sp-1); } .f-reply-foot > * { font-size: var(--fs-sm); padding-left: var(--sp-2); padding-right: var(--sp-2); } .b-long { display: none; } .b-abbr { display: inline; } } /* A second step for the narrowest phones still sold. Measured at 320: five abbreviated buttons want 364px against 302 of room, and Comment is the widest word left. It has a sprite icon and is the one action here that destroys nothing, so it is the one that loses its label. */ @media (max-width: 22rem) { .f-reply-foot > * { font-size: var(--fs-xs); padding-left: var(--sp-2); padding-right: var(--sp-2); } .b-word { display: none; } .b-icon { display: inline-block; } } .btn-primary { background: var(--accent); color: var(--bg); border-color: var(--accent); } .btn-primary:hover { border-color: var(--accent); filter: brightness(1.1); } /* btn-danger: the one convention for an act that cannot be undone. Outlined rather than filled, because a destructive control should read as different, not as the thing the page most wants pressed -- that is btn-primary's job and there is one per view. --del is the same red the diff uses for a removed line, in both themes. This class used to exist only inside .dt td.dt-act, so it worked in one session table and nowhere else; every other irreversible control on the forge -- discarding a ticket, removing a webhook and its secret, revoking a token, unregistering a repository -- shipped with no class at all, visually identical to Save beside it. */ /* btn-quiet: the third weight, for a control that belongs in the row but is not what the row is for -- Ping and Rotate secret beside a webhook's Save. It keeps the button's shape and hit area and gives up the fill and the weight, so a row of five reads as one action, some peers, and two utilities instead of five equal offers. Deliberately NOT applied widely. The audit read "no secondary or tertiary tier" as a forge-wide gap; counting the actual clusters found four rows with three or more controls, and three of them are genuine peers or already correct -- the ticket footer has Comment as its primary and the rest neutral, which is right. One row needed this. A weight nobody needs is another thing to keep consistent. */ .btn-quiet { background: none; font-weight: 400; color: var(--muted); border-color: var(--rule); } .btn-quiet:hover { color: var(--fg); border-color: var(--fg); } .btn-danger { color: var(--del); border-color: color-mix(in srgb, var(--del) 45%, var(--rule)); } .btn-danger:hover { border-color: var(--del); background: color-mix(in srgb, var(--del) 10%, transparent); } /* The markdown composer: GitHub's Write | Preview tabs over a raw textarea. The tab bar ships hidden and JS unhides it — without script the composer is simply a textarea, which is the whole fallback story. Preview renders through the PRODUCTION pipeline. */ .f-composer .f-tabs { display: flex; gap: var(--sp-1); padding: var(--sp-2) var(--sp-2) 0; background: var(--surface); border-bottom: 1px solid var(--rule); border-radius: var(--r-2) var(--r-2) 0 0; } .f-tab { border: 1px solid transparent; background: none; font-size: var(--fs-md); padding: var(--sp-1) var(--sp-3); border-radius: var(--r-row) var(--r-row) 0 0; color: var(--muted); font-weight: 600; } .f-tab:hover { color: var(--fg); border-color: transparent; } .f-tab.here { background: var(--bg); border-color: var(--rule); border-bottom-color: var(--bg); color: var(--fg); position: relative; top: 1px; } .f-composer textarea { border: 0; border-radius: 0; } .f-preview { padding: var(--sp-4) var(--sp-4); min-height: 9rem; } .f-composer [hidden] { display: none; } .f-preview > :first-child { margin-top: 0; } /* Live split: raw markdown left, the render growing beside it as you type — the pane is fed by the production renderer, debounced. JS unhides it; without script the composer is a full-width textarea. */ .f-split { display: grid; grid-template-columns: 1fr; } .f-split:has(.f-live:not([hidden])) { grid-template-columns: 1fr 1fr; } .f-live { border-left: 1px solid var(--rule); padding: var(--sp-4) var(--sp-4); overflow-y: auto; font-size: 0.95em; } .f-live-pane > :first-child { margin-top: 0; } .f-live-pane:empty::before { content: "rendered preview appears as you type"; color: var(--muted); font-size: var(--fs-md); } @media (max-width: 52rem) { .f-split:has(.f-live:not([hidden])) { grid-template-columns: 1fr; } .f-live { border-left: 0; border-top: 1px solid var(--rule); } } /* The poll widget as previewed (ballot-less) and the poll BUILDER — structured inputs in the live pane that write the fenced poll block back into the raw source. The markdown stays the source of truth. */ .f-pollprev, .f-poll-builder:not(:empty) { border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-2) var(--sp-3); max-width: 30rem; margin: var(--sp-2) 0; } .f-pollprev-o { margin: var(--sp-1) 0; } .f-pollbar { height: 6px; background: var(--rule); border-radius: var(--r-1); } .f-poll-builder label { display: block; font-size: var(--fs-sm); color: var(--muted); margin-top: var(--sp-2); } .f-poll-builder input { width: 100%; margin-top: var(--sp-0); } .f-poll-builder .pb-row { display: flex; gap: var(--sp-1); align-items: center; } .f-poll-builder .pb-row button { padding: var(--sp-1) var(--sp-2); font-size: var(--fs-md); } .f-poll-builder .pb-add { margin-top: var(--sp-2); font-size: var(--fs-md); } .f-poll-builder h4 { margin: 0; font-size: var(--fs-body); } /* The slash menu (composer.js): a popover of block snippets. */ .slash-menu { position: absolute; z-index: 6; background: var(--bg); border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-1); box-shadow: 0 4px 14px rgba(0,0,0,0.25); min-width: 11rem; } .slash-menu button { display: block; width: 100%; text-align: left; border: 0; background: none; padding: var(--sp-1) var(--sp-2); border-radius: var(--r-1); font-weight: normal; font-size: var(--fs-body); } .slash-menu button:hover, .slash-menu button.sel { background: var(--surface); } .slash-menu small { color: var(--muted); margin-left: var(--sp-2); } /* A described check-in reference: the comment excerpt beside the hash. */ .cref { color: var(--muted); font-size: 0.9em; } /* The forum list: rows with anatomy — title, opener excerpt, author mark, and on the right the participant stack, count, and the activity sparkline. Threads YOU have read mute; anything with something new in it carries full ink. */ /* The sort links and "mark all read" share a line: one is where you are, the other is a sweep of the whole page's state. */ .ft-bar { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-4); margin: var(--sp-4) 0; } /* A button that is honestly a button — it POSTs and changes state — but reads at the weight of the quiet text beside it. */ button.linkish { background: none; border: 0; padding: 0; cursor: pointer; color: var(--muted); font: inherit; font-size: var(--fs-sm); } button.linkish:hover { color: var(--fg); text-decoration: underline; } .ft-list { margin: var(--sp-2) 0 var(--sp-4); } .ft-row { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-2) var(--sp-1); border-top: 1px solid var(--rule); } .ft-row:first-child { border-top: 0; } /* Read rows fade UNIFORMLY. Muting only the title's link color read as two kinds of link — meaning must never ride on link color. The fade is gentle because read rows are the MAJORITY on a forum you keep up with: at 0.62 a page you had merely kept current with looked disabled. */ .ft-row.dim { opacity: 0.78; } .ft-row.dim:hover { opacity: 1; } .ft-main { min-width: 0; flex: 1; } .ft-t { font-weight: 600; } /* The title is already the obvious target — it is the biggest, boldest thing on the row and the whole row leads to it — so at rest it wears the page's ink and lets the read/unread fade be the only signal it carries. The link color arrives on hover, WHERE HOVER EXISTS: a touch device would otherwise be left with the tap-emulated hover stuck on the last title tapped. :active pays the affordance back to touch, which never gets the hover state at all. */ .ft-t a { color: inherit; text-decoration: none; -webkit-tap-highlight-color: transparent; } @media (hover: hover) { .ft-t a:hover { color: var(--accent); text-decoration: underline; } } .ft-t a:active { color: var(--accent); text-decoration: underline; } .ft-score { font-size: var(--fs-sm); color: var(--accent); margin-left: var(--sp-1); } .ft-x { margin: var(--sp-0) 0 0; color: var(--muted); font-size: var(--fs-body); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* A sentence, not a row. As a flex container every bare text run between the elements — " · started ", " · last reply " — became its own anonymous flex item, and with the default nowrap a narrow screen shrank each item until its text wrapped INSIDE it. The result was the words stacked in columns rather than the line wrapping once. Normal flow wraps it the way prose wraps. */ .ft-meta { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-sm); } /* The pieces that must not be split mid-way: a name and its mark are one thing, and a date is one thing. Wrapping happens BETWEEN them. */ .ft-meta .who, .ft-meta time { white-space: nowrap; } .ft-meta .idn { width: 14px; height: 14px; } .ft-side { flex: none; display: flex; align-items: center; gap: var(--sp-3); color: var(--muted); } .ft-stack { display: inline-flex; } .ft-stack .idn { width: 18px; height: 18px; border-radius: var(--r-1); outline: 2px solid var(--bg); margin-left: -5px; } .ft-stack .idn:first-child { margin-left: 0; } .ft-count { font-size: var(--fs-md); font-variant-numeric: tabular-nums; min-width: 1.4rem; text-align: right; } .ft-spark { color: var(--accent); } @media (max-width: 40rem) { .ft-side { display: none; } } /* Bar actions are rounded SQUARES; the account stays a circle — a button is a thing you do, an avatar is who you are. Icons render at full size in the page's ink, not the avatar's small caption size. */ .bar-btn { flex: none; width: 2rem; height: 2rem; padding: 0; border-radius: var(--r-row); background: none; border: 1px solid var(--rule); color: var(--muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } /* GitHub's header grammar: rest is quiet (muted ink, hairline border), hover is a FILL, and keyboard focus gets a real ring — the hover affordance and the focus affordance are different signals. */ .bar-btn:hover { background: var(--code-bg); border-color: var(--rule); color: var(--fg); } .bar-btn:focus-visible, .avatar:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .bar-btn .ic { width: 1rem; height: 1rem; vertical-align: 0; } a.inboxbtn { position: relative; } /* The unread badge: a count, not a mystery dot. It informs and holds still — no pulse, no animation. */ #inbox-dot { display: none; } #inbox-dot.on { display: block; position: absolute; top: -0.35rem; right: -0.35rem; min-width: 1rem; height: 1rem; padding: 0 var(--sp-1); box-sizing: border-box; border-radius: var(--r-4); background: var(--accent); color: var(--bg); font-size: var(--fs-xs); font-weight: 700; line-height: 1rem; text-align: center; } /* Toasts (RFC 0011 §5). A stack in the corner, above everything, that dismisses ITSELF: the animation is the timer, so no script holds a handle on a page that may be left open for a day. The keyframes hold the toast still for its whole readable life and only then fade, and visibility: hidden at the end takes it out of the tab order as well as out of sight. Pointer events stay off the container so an empty corner never eats a click on the page beneath it. */ #toasts { position: fixed; right: 1rem; bottom: 1rem; z-index: 50; display: flex; flex-direction: column; gap: var(--sp-2); max-width: min(24rem, calc(100vw - 2rem)); pointer-events: none; } #toasts .toast { pointer-events: auto; background: var(--bg); border: 1px solid var(--rule); border-left: 3px solid var(--accent); border-radius: var(--r-row); padding: var(--sp-2) var(--sp-3); box-shadow: 0 2px 10px rgb(0 0 0 / 0.18); animation: toast-life 9s forwards; } #toasts .toast a { font-weight: 600; } #toasts .toast small { display: block; color: var(--muted); } #toasts .attn-reason { font-weight: 600; } /* The banner carrier for a desktop notification when banners are off: it exists to hold one expression and must never be seen. */ #toasts .attn-notify { display: none; } /* The inbox's own list of what was addressed to this account. A plain list (Principle 3): the reason, when, and the words underneath. */ .attn-rows { margin: var(--sp-2) 0; } .attn-row { padding: var(--sp-2) var(--sp-4); } .attn-row.unread .attn-reason { font-weight: 600; } .attn-summary { color: var(--muted); } .attn-head { font-size: var(--fs-body); margin: var(--sp-4) 0 var(--sp-1); } @keyframes toast-life { 0%, 88% { opacity: 1; } 100% { opacity: 0; visibility: hidden; } } /* Reduced motion still needs the dismissal — it is the toast's whole lifecycle, not decoration — so the fade becomes a cut. */ @media (prefers-reduced-motion: reduce) { #toasts .toast { animation: toast-cut 9s forwards; } @keyframes toast-cut { 0%, 99% { opacity: 1; } 100% { opacity: 0; visibility: hidden; } } } /* A link dressed as a button, matching the button rule above — for "new thread"-style actions that are navigations, not submissions. */ a.btn, label.btn { display: inline-block; background: var(--code-bg); color: var(--fg); border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-1) var(--sp-2); font-weight: 600; text-decoration: none; } a.btn:hover, label.btn:hover { border-color: var(--fg); } /* A file input cannot be styled, so its LABEL is the control and the input behind it is what keyboard focus lands on. Without this the label read as a sentence beside a button rather than as the thing to press. */ label.btn { cursor: pointer; } /* a.btn is element+class and outranks .btn-primary, so every primary ANCHOR button in the forge quietly rendered as a plain one — the New ticket call to action included. Matched here rather than by reordering, which the next rule appended below would silently undo again. */ a.btn.btn-primary { background: var(--accent); color: var(--bg); border-color: var(--accent); } a.btn.btn-primary:hover { border-color: var(--accent); filter: brightness(1.1); } .fleet-count { margin: 0 0 var(--sp-2); } .input-group { display: inline-flex; align-items: center; min-width: 0; border: 1px solid var(--rule); border-radius: var(--r-row); background: var(--bg); } .input-group:focus-within { outline: 2px solid var(--muted); outline-offset: 1px; } .input-group > input { flex: 1 1 auto; min-width: 0; border: 0; background: none; border-radius: var(--r-row); } .input-group > input:focus-visible { outline: none; } .clearq { flex: none; color: var(--muted); text-decoration: none; font-size: var(--fs-lg); line-height: 1; padding: 0 var(--sp-2); } .clearq:hover { color: var(--fg); } /* select keeps its native disclosure arrow — replacing it costs a hand-drawn caret that has to be redrawn for both themes. */ .readme-head { margin-top: var(--sp-5); padding-bottom: var(--sp-1); border-bottom: 1px solid var(--rule); font-size: var(--fs-base); } /* Each entry is one unit — a pill with its count inside — rather than a word and a loose number. Loose numbers made every repository's nav a different shape and, with a wide count, a different number of lines. "here" is a filled pill rather than a colour so it survives both themes and does not depend on hue perception; "empty" is dimmed but still a link. */ /* The nav is the ONLY thing in the bar allowed to shrink, and it shrinks by scrolling, never by pushing: the … button sits outside it and is always whole. This is the structural guarantee behind every width — a surprising repo name or count can at worst make the tabs swipeable, exactly GitHub's mobile behavior. */ .repobar nav { display: flex; flex-wrap: nowrap; gap: var(--sp-4); margin-left: auto; min-width: 0; flex: 0 1 auto; overflow-x: auto; scrollbar-width: none; } .repobar nav::-webkit-scrollbar { display: none; } .navmore-btn { flex: none; } nav a { display: inline-flex; align-items: baseline; gap: var(--sp-1); padding: var(--sp-0) var(--sp-2); border-radius: var(--r-4); text-decoration: none; border: 1px solid transparent; } nav a:hover { border-color: var(--rule); } nav a.here { font-weight: 600; background: var(--code-bg); color: var(--fg); border-color: var(--rule); } nav a.empty { color: var(--muted); } /* The repobar and explore navs are underline TABS, not pills: the bar is a row of places, and GitHub taught everyone what that looks like. The underline element carries a view-transition-name, so on browsers with cross-document view transitions (Chrome 126+, Safari 18.2+) the highlight glides from the old tab to the new one across the page load; everywhere else navigation is simply instant. */ .repobar nav a, nav.tabs a { border-radius: 0; border: 0; padding: var(--sp-2) var(--sp-0); border-bottom: 2px solid transparent; background: none; color: var(--muted); position: relative; } .repobar nav a:hover, nav.tabs a:hover { border-color: transparent; color: var(--fg); } .repobar nav a.here, nav.tabs a.here { background: none; border: 0; font-weight: 600; color: var(--fg); border-bottom: 2px solid transparent; } .ink { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: var(--accent); border-radius: var(--r-1); } .repobar nav a.here .ink { view-transition-name: repo-tab; } nav.tabs a.here .ink { view-transition-name: explore-tab; } nav.doc-tabs a.here .ink { view-transition-name: doc-tab; } nav.fleet-tabs a.here .ink { view-transition-name: fleet-tab; } @view-transition { navigation: auto; } @media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } } /* The bars are ISOLATED from the root crossfade: identical on both sides of a navigation, so their groups render static instead of flashing with the page — the fixed chrome fading in and out was most of what read as "weird". Everything else is kept brisk: at 0.4s a crossfade is an effect, at 0.15s it is a page load. */ .topbar { view-transition-name: topbar; } .repobar { view-transition-name: repobar; } footer { view-transition-name: sitefooter; } ::view-transition-group(*) { animation-duration: 0.15s; } /* Only the chrome animates — the orange underline slides, the bars hold still. The root snapshot does NOT cross-fade: two translucent copies of a dark page over a dark backdrop dip in brightness mid-fade, which reads as a flash on every slow navigation. Content just swaps. */ ::view-transition-old(root), ::view-transition-new(root) { animation: none; } /* The collapse, GRADUATED: each tier folds at its own width and the … menu reveals exactly the tier the bar just hid — a landscape phone keeps six tabs and folds two; a portrait phone keeps three. All-at- once hiding read as wrong precisely there. Measured collapse needs script; tiers are what owning the layout costs in pure CSS. */ /* Fixed square, self-centered: sized by its own box rather than by the bar's baseline alignment, which squashed it once it left the nav. */ .navmore-btn { display: none; align-items: center; justify-content: center; width: 1.9rem; height: 1.9rem; padding: 0; align-self: center; border: 1px solid var(--rule); border-radius: var(--r-row); background: none; color: var(--muted); cursor: pointer; anchor-name: --navmore; } .navmore-btn .ic, .fleetmore-btn .ic { width: 1rem; height: 1rem; } .fleetmore-btn { display: none; align-items: center; justify-content: center; width: 1.9rem; height: 1.9rem; padding: 0; align-self: center; flex: none; border: 1px solid var(--rule); border-radius: var(--r-row); background: none; color: var(--muted); cursor: pointer; anchor-name: --fleetmore; } .menu.fleetmore .f1, .menu.fleetmore .f2 { display: none; } /* Mirrors goes first, then Operations. Stores is the way home and Exceptions carries the count an operator is here for, so neither folds. An active tab never folds: it stays lit where it is. */ @media (max-width: 44rem) { .repobar nav.fleet-tabs a.f1:not(.here) { display: none; } .fleetmore-btn { display: inline-flex; } .menu.fleetmore .f1 { display: block; } } @media (max-width: 34rem) { .repobar nav.fleet-tabs a.f2:not(.here) { display: none; } .menu.fleetmore .f2 { display: block; } } .menu.navmore .m1, .menu.navmore .m2, .menu.navmore .m3, .menu.navmore .my { display: none; } .menu.navmore .cl { display: none; } @media (max-width: 62rem) { .repobar nav a.aux1:not(.here) { display: none; } .navmore-btn { display: inline-flex; } .menu.navmore .m1 { display: block; } /* An active folded tab stays out of the drawer — selected, visible — and wiki (the last tier standing) yields its place for it. */ .repobar nav:has(a.aux1.here, a.aux2.here) a.aux3:not(.here) { display: none; } .repobar nav:has(a.aux3.here) a.aux2:not(.here) { display: none; } } @media (max-width: 52rem) { .repobar nav a.aux2:not(.here) { display: none; } .menu.navmore .m2 { display: block; } } @media (max-width: 44rem) { .repobar nav a.aux3:not(.here) { display: none; } .menu.navmore .m3 { display: block; } /* A surfaced drawer tab needs the room a core tab was using: tickets yields. The row must NEVER exceed the viewport — an overflowing bar drags the whole page sideways — and the clip below is the hard guarantee behind these yields. */ .repobar nav:has(a.aux1.here, a.aux2.here, a.aux3.here) a.yield:not(.here) { display: none; } /* ...and whatever yields is then reachable from the drawer, under exactly the condition that hid it. The drawer is outside the nav, so the test is on the page. */ body:has(.repobar nav a.aux1.here, .repobar nav a.aux2.here, .repobar nav a.aux3.here) .menu.navmore .my { display: block; } } /* The drawer never lists the tab that is currently OUT of it. */ .menu.navmore span:has(a.here) { display: none !important; } /* A tab repotabs.js found clipped leaves the bar and joins the drawer. */ .repobar nav a.clip { display: none; } .menu.navmore span.clipped { display: block; } .menu.navmore a { display: flex; border-radius: var(--r-row); padding: var(--sp-1) var(--sp-2); } .menu.navmore a.here { background: var(--surface); border: 0; font-weight: 600; } @supports (anchor-name: --a) { .menu.navmore { position-anchor: --navmore; inset: auto; top: anchor(bottom); right: anchor(right); margin-top: var(--sp-1); } /* The fleet drawer wears .navmore for its looks but hangs off its OWN button: --navmore is not on the page at all here, and an anchor that resolves to nothing drops the menu in the viewport's top-left corner, over the search field. */ .menu.fleetmore { position-anchor: --fleetmore; } } nav .n { color: var(--muted); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; background: var(--code-bg); border-radius: var(--r-4); padding: 0 var(--sp-1); } nav a.here .n { background: var(--bg); } .repo-sub { color: var(--muted); font-size: var(--fs-body); margin: -0.6rem 0 var(--sp-4); } .newrepo { margin: 0 0 var(--sp-3); } /* The new-repository form had no styling of its own at all. Two of its inputs carry size="52" — about 460px — and a bare <fieldset> takes the UA's min-inline-size: min-content, so the Contents box REFUSED to shrink below them: measured at a 390px viewport it laid out 491px wide, and because nothing scrolls (main clips), the right-hand end of every line in it was simply gone. The page nobody reaches until they are making their first repository was the one page never given a phone pass. size="52" stays. It is the right width with no stylesheet at all, and the cap below is what makes it a preference rather than a floor. */ .newrepo-form p { margin: var(--sp-3) 0; } .newrepo-form fieldset { min-inline-size: 0; box-sizing: border-box; border: 1px solid var(--rule); border-radius: var(--r-2); margin: var(--sp-4) 0; padding: var(--sp-1) var(--sp-4) var(--sp-3); } .newrepo-form legend { padding: 0 var(--sp-1); color: var(--muted); font-size: var(--fs-sm); letter-spacing: 0.05em; text-transform: uppercase; } /* Every free-text field fills its column: name, description, and the upstream URL, which is the longest thing anybody types here. */ .newrepo-form input:not([type]), .newrepo-form input[type=text], .newrepo-form input[type=url] { width: 100%; max-width: 100%; box-sizing: border-box; } .newrepo-form input[type=number] { width: 8ch; } /* A row of independent choices, not a sentence: it wraps rather than running off the side. */ .newrepo-form .seed { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); } .newrepo-form .seed label { display: inline-flex; align-items: center; gap: var(--sp-1); } /* The clone row is a URL and an interval, and at phone width they stack instead of squeezing each other. */ .newrepo-form .clone-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); } .newrepo-form .clone-row > input { flex: 1 1 16rem; min-width: 0; } .newrepo-form .clone-row > label { display: inline-flex; align-items: center; gap: var(--sp-1); } .about { margin: -0.3rem 0 var(--sp-3); color: var(--fg); } /* Red and green are the same two tokens the diff uses. They were literals with the dark pair behind an unguarded media query, so an explicit light choice on a dark-mode device drew the dark red on the light ground — the same defect the highlight had. */ .danger-head { color: var(--del); } .danger { border: 1px solid var(--del); border-radius: var(--r-1); padding: var(--sp-1) var(--sp-4); } .danger form + form { border-top: 1px solid var(--rule); margin-top: var(--sp-2); } /* The listing's numeric and date columns take only what they need, so the description and tip get the width instead of "Check-ins" wrapping into two lines beside a mostly-empty row. */ table.fleet th, table.fleet td { vertical-align: top; } /* The name column takes the slack; the numeric ones take only what they need and never wrap. Right-aligned tabular figures line up down the page by construction — which a per-row grid could not do. */ table.fleet td.r-name { width: 100%; } /* The repository's own mark, the size a name is — every other named thing on the forge carries one, and the listing of repositories was the place that did not. Baseline-aligned rather than centred: it sits beside a link in running text, not in a cell of its own. */ .r-mark { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.22em; margin-right: var(--sp-2); } .r-mark .idn { width: 100%; height: 100%; display: block; border-radius: var(--r-1); } /* On a bar the mark sits against a name set larger than a listing's, so it takes the name's size rather than the listing's. */ .bar-mark { width: 1.05em; height: 1.05em; vertical-align: -0.16em; margin-right: var(--sp-2); } /* The image editor: the mark at the size it is being judged at, with its controls beside it rather than under it. The file input is hidden and reached through its label, because a bare file input cannot be styled and reads as a different kind of control from every other button on the page — the label IS the control, and keyboard focus lands on the input behind it. */ .av-edit { display: flex; flex-wrap: wrap; gap: var(--sp-4); align-items: flex-start; margin: var(--sp-2) 0 var(--sp-4); } .av-acts { flex: 1 1 18rem; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); } .av-form { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; } .av-form input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; } .av-form input[type=file]:focus-visible + button, .av-form label.btn:hover { border-color: var(--accent); } .avatar.lg .idn-img { width: 100%; height: 100%; object-fit: cover; } /* A built-in mark is line art, not a generated block: it takes the text colour and a little room, where an identicon fills its box. */ /* The mark's inset is in its viewBox, not in padding. Percentage padding resolves against the CONTAINING BLOCK's width, not the element's own: the same 12% was about 2px inside a narrow table cell and about 43px once that cell became a full-width block at phone width, which swamped the 14px box and squeezed the glyph away. A viewBox margin is the element's own geometry and cannot be moved by whatever it is placed in. */ .idn-mark { color: var(--fg); } /* A drop target while a file is over it, the same dashed rule attach.js's targets use — one vocabulary for "let go here". */ .av-edit[data-over] { outline: 2px dashed var(--accent); outline-offset: 4px; border-radius: var(--r-2); } /* An unsaved preview says so: the mark is showing something the forge is not serving yet, and nothing else on the page would tell you. The warning REPLACES the limits in the line that is already there, rather than adding one: a line that appears pushes the buttons down under the pointer about to press them, and a line reserved for it is a permanent blank gap paid for a state that is usually absent. */ .av-edit[data-preview] .avatar.lg { outline: 2px solid var(--accent); outline-offset: 2px; } .av-unsaved { display: none; color: var(--accent); } /* The phone's paste target. A long-press menu only offers Paste over something editable, so this is the thing to press on — it is not a place to type, and anything that is not an image is put straight back out. Shipped hidden: without script it would be a box that does nothing. */ .av-paste { min-height: 2.75rem; display: flex; align-items: center; padding: 0 var(--sp-3); border: 1px dashed var(--rule); border-radius: var(--r-row); color: var(--muted); font-size: var(--fs-md); cursor: text; } .av-paste:empty::before { content: "Paste an image here"; } .av-paste:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .av-paste img { max-height: 2rem; } .av-edit[data-preview] .av-limits { display: none; } .av-edit[data-preview] .av-unsaved { display: inline; } table.fleet th.n, table.fleet td.n { text-align: right; white-space: nowrap; width: 1%; padding-left: var(--sp-4); font-variant-numeric: tabular-nums; color: var(--muted); } table.fleet td.n.last { color: var(--fg); } table.fleet th.n { padding-bottom: var(--sp-1); } /* The pair reads as one entry: no rule between a name and its description, the rule goes under the description instead. */ table.fleet tr.r-top td { padding-top: var(--sp-2); border-top: 1px solid var(--code-bg); } table.fleet tr.r-desc td { padding: 0 0 var(--sp-0); color: var(--muted); font-size: var(--fs-body); border: 0; } table.fleet tr.r-desc + tr.r-top td { border-top: 1px solid var(--code-bg); } @media (max-width: 30rem) { /* A phone has room for the name and three numbers, not four. Mirror freshness is the most specialised of them and the first to go — the repository page still reports it. Tighter gutters, and a long name may wrap rather than push the table into a sideways scroll. */ table.fleet th.n, table.fleet td.n { padding-left: var(--sp-2); } table.fleet td.r-name { white-space: normal; overflow-wrap: anywhere; } table.fleet th.n:nth-child(4), table.fleet td.n:nth-child(4) { display: none; } } .ci-pass { border-color: var(--add); color: var(--add); } .ci-fail { border-color: var(--del); color: var(--del); } /* Running is the only state that changes without the reader acting, so it is the only badge that moves. Grey and static, it read exactly like the required chip beside it and nothing said "wait". */ .ci-run { border-color: var(--accent); color: var(--accent); } .ci-run::before { content: ""; display: inline-block; width: 0.65em; height: 0.65em; margin-right: 0.35em; vertical-align: -0.05em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: ci-spin 0.9s linear infinite; } @keyframes ci-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .ci-run::before { animation: none; border-right-color: currentColor; opacity: 0.5; } } /* Ticket fields are label/value pairs, so a dl says what they are and escapes the global table rules that banded every label with a border. The label column sizes to its own widest label instead of a fixed 10rem: fossil ticket schemas are arbitrary, so the widest label is whatever this repository's tracker defines. Values may be 40-char hashes, which must wrap rather than push the page sideways. */ dl.tkt-head { display: grid; grid-template-columns: minmax(0, max-content) minmax(0, 1fr); gap: var(--sp-1) var(--sp-4); margin: var(--sp-3) 0; } dl.tkt-head dt { color: var(--muted); font-size: var(--fs-body); } dl.tkt-head dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } .tkt-id { overflow-wrap: anywhere; } .tkt-body { margin: var(--sp-2) 0 var(--sp-4); } .tkt-comment { border-top: 1px solid var(--rule); padding: var(--sp-2) 0; } .tkt-comment:first-of-type { border-top: 0; } /* An import in progress: the same row shape as a store, so the eye reads one list, with a state badge that says it is still arriving. */ .imports { margin-bottom: var(--sp-5); } .imports h2 { font-size: var(--fs-base); margin: 0 0 var(--sp-2); } .tl-badge.imp-run { border-color: var(--accent); color: var(--accent); } /* The ticket editor: body and timeline left, metadata right; one column on a phone with the metadata after the body. */ /* Editing a ticket in place. The control lives ON the thing it edits and TAKES ITS PLACE when pressed — the old fold sat underneath saying "edit description", which names a thing rather than offering a way to do it. A hidden checkbox drives it, so the field, Cancel and Save share one row and cancelling costs no round trip; <details> cannot, because the browser slots its content into an anonymous box that will not sit in a row beside the summary. */ .t-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); min-width: 0; } .t-title { margin: 0; min-width: 0; } /* Quiet until wanted, and never hidden from the keyboard. */ .t-pen { cursor: pointer; color: var(--muted); opacity: 0.5; border-radius: var(--r-1); padding: var(--sp-1) var(--sp-1); display: inline-flex; align-items: center; } .t-head:hover .t-pen, .t-desc:hover .t-pen, .t-toggle:focus-visible ~ .t-title .t-pen, .t-pen:hover { opacity: 1; color: var(--fg); background: var(--surface); } .t-toggle:focus-visible ~ .t-title .t-pen { outline: 2px solid var(--accent); outline-offset: 1px; } /* The title's pencil rides the heading's last line, after its last word, rather than taking a line of its own under a long title. */ .t-title .t-pen { vertical-align: middle; margin-left: var(--sp-1); font-size: var(--fs-base); font-weight: 400; } /* Closed: only the heading and its pencil. */ .t-title-form, .t-save, .t-cancel, .t-desc-form { display: none; } /* Open: the heading gives way to the field, and the row reads field, Cancel, Save — the order a hand uses. */ .t-toggle:checked ~ .t-title { display: none; } /* A text area, grown to the title, so a long one is read and edited whole instead of scrolled through one line; Cancel and Save follow on their own line. */ .t-toggle:checked ~ .t-title-form { display: block; flex: 1 1 100%; min-width: 0; } .t-toggle:checked ~ .t-title-form textarea { width: 100%; font-size: var(--fs-lg); font-weight: 600; padding: var(--sp-2); resize: vertical; field-sizing: content; min-height: 2lh; } .t-toggle:checked ~ .t-cancel, .t-toggle:checked ~ .t-save { display: inline-block; } .t-cancel { cursor: pointer; border: 1px solid var(--rule); border-radius: var(--r-1); padding: var(--sp-2) var(--sp-3); white-space: nowrap; } .t-cancel:hover { background: var(--surface); } /* The description's pencil sits in the bottom-right corner, in a strip the body reserves for it, so it never covers a line however the text falls; the body gives way to the composer. The strip is the button's icon plus its padding, and a gap. */ .t-desc { position: relative; } .t-pen-desc { position: absolute; bottom: 0; right: 0; z-index: 1; } .tkt-body-pen { padding-bottom: calc(1em + var(--sp-1) * 2 + var(--sp-1)); } .t-toggle:checked ~ .tkt-body { display: none; } .t-toggle:checked ~ .t-desc-form { display: block; } .t-desc-form .f-reply-foot { display: flex; gap: var(--sp-2); justify-content: flex-end; align-items: center; } .t-nodesc { font-style: italic; } /* "Your recent work" in the dashboard sidebar. The heading needs air above it: it follows the repositories' own trailing link, and without this the two read as one block. */ /* .dash h2 (0,1,1) outranked this, so the section sat flush under the New repository button. */ /* The empty state is a NOTE, not the page's content — at body size it shouted louder than the work it was standing in for. */ .dash-mine-none { font-size: var(--fs-md); color: var(--muted); max-width: 26rem; } .tkt-layout { display: grid; grid-template-columns: minmax(0, 1fr) 17rem; gap: 0 var(--sp-6); align-items: start; } .tkt-side { font-size: var(--fs-body); } .tkt-fields { display: grid; gap: var(--sp-3); margin: 0 0 var(--sp-4); } .tkt-fields label { display: grid; gap: var(--sp-1); } .tkt-fields select, .tkt-fields input { width: 100%; box-sizing: border-box; font: inherit; } .tkt-k { color: var(--muted); font-size: var(--fs-sm); text-transform: capitalize; } .tkt-v { padding: var(--sp-1) 0; } .tkt-meta { color: var(--muted); font-size: var(--fs-sm); border-top: 1px solid var(--rule); padding-top: var(--sp-2); } .tkt-event { border-top: 1px solid var(--rule); padding: var(--sp-2) 0; color: var(--muted); font-size: var(--fs-body); } .tkt-event b { color: var(--fg); font-weight: 600; } .tkt-remark .f-reply-foot { align-items: center; } .tkt-edit { margin: 0 0 var(--sp-4); } .tkt-edit > summary { color: var(--muted); font-size: var(--fs-md); cursor: pointer; width: max-content; } .tkt-edit[open] > summary { margin-bottom: var(--sp-2); } .tkt-retitle { margin-top: -0.5rem; } .tkt-retitle form { display: flex; gap: var(--sp-2); } .tkt-retitle input { flex: 1; font: inherit; } .tkt-close { display: inline-flex; align-items: center; gap: var(--sp-1); color: var(--muted); font-size: var(--fs-md); } .tkt-close select { font: inherit; } @media (max-width: 52rem) { .tkt-layout { grid-template-columns: minmax(0, 1fr); } .tkt-side { border-top: 1px solid var(--rule); padding-top: var(--sp-3); margin-top: var(--sp-4); } } .tl-badge { border: 1px solid var(--rule); border-radius: var(--r-1); padding: 0 var(--sp-1); font-size: var(--fs-xs); } .tl-pill { display: inline-block; border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-0) var(--sp-2); font-weight: bold; margin: var(--sp-2) 0 var(--sp-1); } /* Filter controls wrap as WHOLE units: a label never separates from its select, a branch chip never breaks mid-name. The chip is GitHub's dismissible filter token — the name truncates before the × detaches. */ /* GRID, not flex: one line is structural. fit-content is GitHub's branch-selector rule in one function — sized to its label, capped (12rem), and clamped to the space the other two leave, so the row cannot overflow whatever the branch is called and a short name gets a short control. */ .tl-controls { color: var(--muted); font-size: var(--fs-body); display: grid; grid-template-columns: fit-content(12rem) max-content max-content max-content; gap: var(--sp-2); align-items: center; margin-bottom: var(--sp-2); max-width: 100%; } .tl-controls select { min-width: 0; max-width: 100%; } /* The feed link sits in the filter row because it follows whatever the filters say; a visitor without an account reads it as the "follow". */ .tl-feed { display: inline-flex; align-items: center; gap: var(--sp-1); white-space: nowrap; } /* The fleet bar's first grid cell is a FORM holding field and sort; left as a block it stacks them. Flex puts them on the one line the grid promised, with the New repository link beside them. */ .fleet-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); } .fleet-bar > form { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } .tl-chip { display: inline-flex; align-items: center; gap: var(--sp-1); min-width: 0; max-width: 100%; border: 1px solid var(--rule); border-radius: var(--r-4); padding: var(--sp-0) var(--sp-2); background: var(--surface); white-space: nowrap; } .tl-chip b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: min(14rem, 55vw); } .tl-chip a { text-decoration: none; } .repobar nav a.cog { display: inline-flex; align-self: center; padding: var(--sp-2) var(--sp-0); } /* Group labels are help text, shown where there is room for help. */ .tl-help { color: var(--muted); font-size: var(--fs-sm); } @media (max-width: 40rem) { .tl-help { display: none; } } pre { overflow-x: auto; background: var(--code-bg); padding: var(--sp-2); border-radius: var(--r-1); } /* A plaintext DOCUMENT (a license, an old-school README) is prose that happens to be monospace — it reads straight off the page, not out of a code box. Fenced code inside markdown keeps the box. */ pre.plaindoc { background: none; padding: 0; border-radius: 0; } pre.diff .add { color: var(--add); } pre.diff .del { color: var(--del); } pre.diff .hunk { color: var(--muted); } pre.diff .head { font-weight: bold; } /* Content can contain tokens no viewport fits — a hash, a URL, a wiki page literally named "checkin/2cbbabdf5ef624d809fbb40d2d312a29". Unbroken they push the whole page sideways, so a phone scrolls to read anything. break-word rather than anywhere: it breaks only when a word genuinely does not fit, leaving ordinary prose alone. <pre> keeps its own horizontal scroll, which is correct for code. */ main { overflow-wrap: break-word; } /* A date is one token to a reader: breaking "2020-12-19T13:20:19" across two lines makes it unreadable and is what content-driven wrapping does to it by default. */ time { white-space: nowrap; } /* A ticket list is read title-first. A table gave the 10-character id its own column and left the title the narrowest one on a phone; this gives the title the full width and demotes id/status/date to a meta line under it. Same shape at every width — no breakpoint to diverge. */ /* Columns that scroll independently: a board's whole value is seeing the states side by side, so the page must not grow to the tallest column and make the reader scroll past one to reach another. On a phone the columns stack, because four 80px columns are not a board. */ /* The board never widens the page. Open columns share the row with a 16rem floor and wrap past it — a second row beats a sideways scroll that hides the columns that matter. Empty columns fold to strips (GitHub's collapsed column), which is what keeps a seven-status vocabulary on one row in the common case. */ /* The board is a canvas, not prose: it is the one view that leaves the reading column and takes the window. The controls above it keep the column so they line up with every other page. A column has ONE width (GitHub's 350px, here 22rem) so a card is the same object wherever it sits; the board is what varies — columns fold, and past the window they wrap. The controls leave the column with it, so the board's left edge and its filters line up (GitHub does the same). */ /* Width follows the TOGGLE, not a class on main. It has to: main sits outside #tkt-view, so a morph — which patches only that region — can never change a class on it. Keying the rule to the button's own pressed state means the width is correct in both worlds from one declaration: a full page load renders the toggle pressed and :has widens the page, and a morph swaps the toggle and :has re-evaluates. No signal, no second patch, nothing to keep in step. This used to fire automatically on the board view, so choosing "board" silently re-laid-out the whole page under the click. */ main:has(.t-wide a[aria-pressed="true"]) { max-width: none; } /* A toggle, and it looks like one: pressed carries the selected fill and full-strength ink, unpressed is muted. Same geometry either way, so pressing it moves nothing but itself. */ .t-wide a { display: inline-flex; align-items: center; padding: var(--sp-2) var(--sp-2); color: var(--muted); } .t-wide a[aria-pressed="true"] { color: var(--fg); } .t-wide .ic { width: 1rem; height: 1rem; } .board { display: flex; flex-wrap: wrap; gap: var(--sp-3); align-items: stretch; padding-bottom: var(--sp-2); } .board-col { flex: 0 0 22rem; min-width: 0; border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--surface); padding: var(--sp-2); } .board-col.folded { flex: 0 0 auto; padding: var(--sp-2) var(--sp-1); display: flex; align-items: flex-start; justify-content: center; } .board-col.folded h3 { margin: 0; writing-mode: vertical-rl; transform: rotate(180deg); display: flex; align-items: center; gap: var(--sp-2); } .board-col.folded .unfold { text-decoration: none; color: inherit; display: inline-flex; align-items: center; gap: var(--sp-2); } /* Rotated, a pill becomes an oval: the strip names the column in plain text, the pill's colour kept, its box dropped. */ .board-col.folded .t-status { border: 0; background: none; padding: 0; border-radius: 0; font-weight: 600; letter-spacing: 0.02em; } .board-col.folded .unfold:hover .t-status { text-decoration: underline; } /* The chevron is the one control a column has: fold on an open one, open on a strip. Same glyph family, same spot, both directions. */ .board-col h3 .fold { margin-left: auto; color: var(--muted); text-decoration: none; padding: 0 var(--sp-1); border-radius: var(--r-1); font-size: var(--fs-lg); line-height: 1; } .board-col h3 .fold:hover { color: var(--fg); background: var(--bg); } .board-col.folded .chev { color: var(--muted); font-size: var(--fs-lg); } /* Sticky: in a 38-card column the header is the answer to "which column am I in" once the top has scrolled away. */ .board-col h3 { position: sticky; top: 0; z-index: 1; background: var(--surface); margin: 0 0 var(--sp-2); padding: var(--sp-0) 0; font-size: var(--fs-body); display: flex; align-items: center; gap: var(--sp-2); } .board-col h3 .n { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; } /* A lane is a horizontal cut across the same columns. The heading is sticky because the whole point of a lane is knowing which one you are reading while you scroll down a long one. */ .lane-h { position: sticky; top: 0; z-index: 1; margin: var(--sp-4) 0 var(--sp-2); font-size: var(--fs-md); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); background: var(--bg); padding: var(--sp-1) 0; display: flex; align-items: center; gap: var(--sp-2); } .lane-h .lane-name { color: var(--fg); } /* The filing form (ticketNewPage). Type pills, a large title, the composer, a row of chip-selects, then the footer; a side column for the keyboard and the API that folds under the form on a narrow screen. Chips are native selects dressed as pills: the unset state is read off the selected option, so it needs no script. */ .tn-wrap { display: grid; grid-template-columns: minmax(0, 1fr) 17rem; gap: var(--sp-6); align-items: start; } @media (max-width: 60rem) { .tn-wrap { grid-template-columns: minmax(0, 1fr); gap: var(--sp-5); } } .tn { display: flex; flex-direction: column; gap: var(--sp-4); min-width: 0; } .tn-types { border: 0; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--sp-2); } .tn-type { position: relative; display: inline-flex; align-items: center; min-height: 2.25rem; padding: 0 var(--sp-4); border: 1px solid var(--rule); border-radius: var(--r-4); color: var(--muted); font-size: var(--fs-body); cursor: pointer; } .tn-type input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; } .tn-type:has(input:checked) { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; } .tn-type:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; } .tn-title { width: 100%; box-sizing: border-box; font-size: var(--fs-xl); font-weight: 600; line-height: 1.25; padding: var(--sp-1) var(--sp-0); background: transparent; border: 0; border-bottom: 1px solid var(--rule); border-radius: 0; color: var(--fg); } .tn-title:focus, .tn-title:focus-visible { outline: none; border-bottom-color: var(--accent); box-shadow: 0 1px 0 var(--accent); } @media (max-width: 40rem) { .tn-title { font-size: var(--fs-lg); } } .tn-similar:empty { display: none; } .tn-similar { border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--surface); padding: var(--sp-3) var(--sp-4); font-size: var(--fs-body); } .tn-sim-head { margin: 0 0 var(--sp-2); color: var(--muted); font-size: var(--fs-md); } .tn-similar ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--sp-1); } .tn-similar a { display: flex; align-items: baseline; gap: var(--sp-2); text-decoration: none; color: var(--fg); } .tn-similar a:hover .tn-sim-t { text-decoration: underline; } .tn-sim-st { flex: none; font-size: var(--fs-xs); font-weight: 600; padding: var(--sp-0) var(--sp-2); border-radius: var(--r-4); border: 1px solid var(--rule); color: var(--muted); } .tn-sim-t { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tn-similar code { flex: none; color: var(--muted); font-size: var(--fs-xs); } .tn-body .f-composer { margin: 0; border: 1px solid var(--rule); border-radius: var(--r-3); overflow: hidden; } .tn-body .f-composer:focus-within { border-color: var(--accent); } .tn-body textarea { width: 100%; box-sizing: border-box; min-height: 14rem; padding: var(--sp-4) var(--sp-4); background: transparent; } .tn-body .attach-composer { padding: var(--sp-2) var(--sp-4); border-top: 1px dashed var(--rule); } .tn-chips { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: flex-start; } .chip-cell { display: inline-flex; flex-direction: column; gap: var(--sp-1); } /* The form's chips are CONTROLS, so their rules live under .tn-chips: unscoped, the 2.15rem touch height here inflated every static .chip on the site (the ticket list's type tags stood 36px tall). */ .tn-chips .chip { position: relative; display: inline-flex; align-items: center; gap: var(--sp-2); min-height: 2.15rem; padding: 0 var(--sp-3); border: 1px solid var(--rule); border-radius: var(--r-4); background: var(--surface); font-size: var(--fs-md); color: var(--fg); cursor: pointer; } .chip-k { color: var(--muted); } .tn-chips .chip select, .tn-chips .chip input { appearance: none; -webkit-appearance: none; background: transparent; border: 0; padding: 0; margin: 0; color: inherit; font: inherit; min-width: 0; } .tn-chips .chip select { cursor: pointer; field-sizing: content; } .tn-chips .chip select:focus, .tn-chips .chip input:focus { outline: none; } .tn-chips .chip:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); } /* Unset: a dashed ghost that says what it would set. The label is inside the empty option ("+ Priority"), so the key is dropped. */ .tn-chips .chip:has(select option[value=""]:checked) { border-style: dashed; background: transparent; color: var(--muted); } .tn-chips .chip:has(select option[value=""]:checked) .chip-k { display: none; } .tn-chips .chip[data-field="status"] .chip-k { display: none; } .chip-text input { width: 9rem; } .chip-text input::placeholder { color: var(--muted); } .chip-add { list-style: none; border-style: dashed; background: transparent; color: var(--muted); } .chip-add::-webkit-details-marker { display: none; } .chip-cell .field-new { font-size: var(--fs-md); width: 11rem; } .tn-more { display: contents; } .tn-more[open] > .fieldrow { flex-basis: 100%; margin: var(--sp-2) 0 0; } .tn-context { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-2); font-size: var(--fs-md); color: var(--muted); } .tn-ctx { padding: var(--sp-0) var(--sp-2); border-radius: var(--r-row); background: var(--code-bg); color: var(--fg); overflow-wrap: anywhere; } .tn-context:has(input[name=ctx]:not(:checked)) .tn-ctx { text-decoration: line-through; opacity: 0.6; } .tn-foot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3) var(--sp-4); padding-top: var(--sp-4); border-top: 1px solid var(--rule); } .tn-dest { display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-md); color: var(--muted); } .tn-another { margin-left: auto; display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-md); color: var(--muted); } .tn-submit { min-height: 2.75rem; padding: 0 var(--sp-4); border-radius: var(--r-2); font-weight: 700; font-size: var(--fs-base); } .tn-submit kbd { margin-left: var(--sp-2); font: inherit; font-size: var(--fs-xs); font-weight: 600; opacity: 0.75; } .tn-aside { display: flex; flex-direction: column; gap: var(--sp-3); font-size: var(--fs-md); color: var(--muted); padding-top: var(--sp-7); } @media (max-width: 60rem) { .tn-aside { padding-top: 0; } } .tn-aside h2 { margin: var(--sp-2) 0 0; font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); } .tn-aside p { margin: 0; line-height: 1.55; } .tn-keys { display: grid; grid-template-columns: 5.2rem minmax(0, 1fr); gap: var(--sp-2) var(--sp-2); margin: 0; } .tn-keys dt kbd { font: inherit; color: var(--fg); white-space: nowrap; } .tn-keys dd { margin: 0; } .tn-curl { margin: 0; padding: var(--sp-2) var(--sp-3); border-radius: var(--r-2); background: var(--code-bg); color: var(--fg); font-size: var(--fs-xs); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } .fieldrow { display: grid; gap: var(--sp-2) var(--sp-4); margin: 0 0 var(--sp-4); grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); } .fieldrow label { font-size: var(--fs-body); } .fieldrow input, .fieldrow select { width: 100%; box-sizing: border-box; } /* Select and its "name a new one" box are ONE cell, stacked, so revealing the box grows this field and moves no other. */ .field-pick { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; } .field-pick label { min-width: 0; } .field-new { font-size: var(--fs-body); } /* A title is a sentence and often carries a URL. It was the same width as a one-word triage field. */ .title-in { width: 100%; box-sizing: border-box; font-size: var(--fs-lg); padding: var(--sp-2) var(--sp-2); } .board-axes { display: flex; gap: var(--sp-4); align-items: center; flex-wrap: wrap; margin: 0 0 var(--sp-3); font-size: var(--fs-md); color: var(--muted); } .board-trunc { color: var(--muted); font-size: var(--fs-sm); } .board-axes select { font-size: var(--fs-md); padding: var(--sp-0) var(--sp-1); } /* Drag: the grip and the cursor say a card moves; the card dims while it is in the air; the column under the pointer lights up, which is the answer to "where will this land". */ .board .card[draggable="true"] { cursor: grab; } .board .card.held { opacity: 0.5; cursor: grabbing; } .board .card .grip { float: right; color: var(--muted); font-size: var(--fs-base); line-height: 1; margin-left: var(--sp-2); user-select: none; } .board-col.over { outline: 2px dashed var(--accent); outline-offset: -2px; } /* The last move, with its undo, sits above the columns. */ .board-moved { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); border: 1px solid var(--rule); border-left: 3px solid var(--accent); border-radius: var(--r-row); padding: var(--sp-2) var(--sp-2); margin: 0 0 var(--sp-3); font-size: var(--fs-body); } .board-moved form { display: contents; } .board-moved button { font-size: var(--fs-sm); padding: var(--sp-0) var(--sp-2); } /* The Move menu: one button, a popover of the other columns. */ .board .card .t-meta { display: flex; align-items: center; gap: var(--sp-1); flex-wrap: wrap; } .board .card .t-id { font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--muted); } .board .card .mv-btn { margin-left: auto; font-size: var(--fs-xs); padding: var(--sp-0) var(--sp-2); font-weight: 600; } .board .card .mv-btn .caret { font-size: 0.7em; } @supports (anchor-name: --a) { .menu.mv { top: anchor(bottom); right: anchor(right); margin-top: var(--sp-1); min-width: 9rem; } } /* Triage badges. Same weight as the metadata line — they are for scanning a column, not for shouting. */ .t-badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-top: var(--sp-1); } /* The landing page's About block and document tabs. The aside reads as a sidebar where there is room and as a header card where there is not; the tab strip is the scopes idiom, already legible everywhere. */ /* minmax(0, 1fr), never a bare 1fr. A 1fr track carries min-width:auto, so it cannot shrink below its content's min-content width — and the feed contains a segmented control that must not wrap. The track grew to fit it, the document went wider than the phone, and every heading and the pager ran off the right edge. Capping the chips at 100% did nothing, because 100% was by then the width of the overgrown track: the floor has to be removed at the track, not patched at the child. */ /* The page opens on its two headings with the air a page title would have had above it; the first section of the side column starts at the top, and each later one sits a section's gap below what precedes it. */ .dash { display: grid; gap: var(--sp-5); grid-template-columns: minmax(0, 1fr); margin-top: var(--sp-5); } .dash-band + .dash { margin-top: 0; } .dash-side > h2 ~ h2 { margin-top: var(--sp-6); } .dash-side, .dash-feed { min-width: 0; } @media (min-width: 64rem) { /* Two columns, not three: "your recent work" was the feed filtered to one author, and a whole column to express one predicate is a column that is empty for most accounts. */ .dash { grid-template-columns: 20rem minmax(0, 1fr); align-items: start; } } /* The first visit's welcome, above its two columns. */ .dash-band { margin-bottom: var(--sp-6); } .aw-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2); align-items: start; } .aw-row .t-glyph { margin-top: var(--sp-1); } .dash-more { margin: var(--sp-3) 0 0; font-size: var(--fs-body); } /* The first visit: the same page, so the same rules, plus these. */ .fv-hello { max-width: 46rem; } .fv-hello h2 { font-size: var(--fs-lg); margin-bottom: var(--sp-2); } .fv-lede { margin: 0 0 var(--sp-2); max-width: 60ch; } .fv-lede .ic { vertical-align: -0.15em; } .fv-map { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-1) var(--sp-4); margin: var(--sp-4) 0 0; font-size: var(--fs-body); } .fv-map dt { color: var(--muted); } .fv-map .fv-map-h { font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.05em; padding-top: var(--sp-1); } .fv-map dd { margin: 0; } .fv-empty { border: 1px dashed var(--rule); border-radius: var(--r-2); padding: var(--sp-3) var(--sp-4); } .fv-empty p { margin: 0 0 var(--sp-2); } .fv-empty p:last-child { margin: 0; } .fv-empty pre { margin: 0 0 var(--sp-2); font-size: var(--fs-sm); overflow-x: auto; } .search-empty { max-width: 46rem; margin-top: var(--sp-2); } .search-empty kbd { font: 0.85em var(--font-mono); border: 1px solid var(--rule); border-radius: var(--r-1); padding: 0 0.3em; } .fv-empty .btn { display: inline-block; margin-top: var(--sp-1); } .fv-sig { font-size: var(--fs-sm); color: var(--muted); font-variant-numeric: tabular-nums; margin-left: var(--sp-1); } .fv-sig .ic { vertical-align: -0.15em; } .fv-second { margin-top: var(--sp-5); } @media (max-width: 40rem) { .fv-map { grid-template-columns: 1fr; gap: 0; } .fv-map dt { margin-top: var(--sp-2); } } .dash-act { margin: var(--sp-4) 0 0; } /* Each thing a signed-in account may do is its own line, not a clause in a run-on sentence of checkboxes. */ .part-form > label { display: flex; align-items: baseline; gap: var(--sp-2); margin: var(--sp-1) 0; } .dash-find { margin: 0 0 var(--sp-2); } .dash-find input { width: 100%; } /* The open-work count beside a repository: a link, because the number is only useful if it takes you to what it counts. */ a.dash-open { float: right; font-size: var(--fs-sm); color: var(--muted); text-decoration: none; border: 1px solid var(--rule); border-radius: var(--r-4); padding: 0 var(--sp-2); } a.dash-open:hover { color: var(--accent); border-color: var(--accent); } h2.t-view { font-size: var(--fs-base); margin: 0; font-weight: 600; } /* The wiki's structure panel: two short lists, side by side when there is room, under the page list where the reader has finished scanning. */ .w-struct { display: grid; gap: var(--sp-5); margin-top: var(--sp-6); padding-top: var(--sp-4); border-top: 1px solid var(--rule); } @media (min-width: 48rem) { .w-struct { grid-template-columns: 1fr 1fr; } } .w-struct h2 { font-size: var(--fs-md); margin: 0 0 var(--sp-2); color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } ul.w-links { list-style: none; margin: 0; padding: 0; } /* The count sits BESIDE its label, not flung to the far edge. With space-between it ended 496px from the name it belonged to, in a 595px row, which reads as two unrelated columns. */ ul.w-links > li { padding: var(--sp-1) 0; display: flex; gap: var(--sp-2); align-items: baseline; flex-wrap: wrap; } ul.w-links a { text-decoration: none; } ul.w-links a:hover { text-decoration: underline; } .w-n { font-size: var(--fs-sm); color: var(--muted); } .w-struct h2 a { color: inherit; text-decoration: none; } .w-struct h2 a:hover { color: var(--accent); } .w-struct-links { display: inline-flex; align-items: center; gap: var(--sp-3); margin-left: auto; font-size: var(--fs-md); } .w-struct-links a { text-decoration: none; color: var(--muted); } .w-struct-links a:hover { color: var(--accent); text-decoration: underline; } .w-lead { color: var(--muted); font-size: var(--fs-body); max-width: 62ch; margin: var(--sp-1) 0 var(--sp-4); } ul.w-links-full > li { padding: var(--sp-2) 0; border-bottom: 1px solid var(--rule); display: block; } .w-link-t { font-weight: 600; } /* Who reached for the name. A wanted page without its askers is a to-do item with no brief. */ .w-from { display: block; font-size: var(--fs-sm); color: var(--muted); margin-top: var(--sp-0); } /* Saving a report: a name box under the rail, quiet enough that it does not compete with the views themselves. */ .t-rail-save { display: flex; gap: var(--sp-1); margin-top: var(--sp-4); } .t-rail-save input { min-width: 0; flex: 1; font-size: var(--fs-md); } .t-rail-save button { font-size: var(--fs-md); } .t-rail-sec li { display: flex; align-items: center; } .t-rail-sec li > a { flex: 1; min-width: 0; } .t-rail-x button { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 0 var(--sp-1); line-height: 1; } .t-rail-x button:hover { color: var(--del); } .dash-depth { margin: var(--sp-2) 0 0; font-size: var(--fs-md); color: var(--muted); text-align: center; } /* A landing: the record, then what it would change, then the talking. Branch names are code because they are identifiers people retype. */ .l-meta { margin: -0.4rem 0 var(--sp-4); color: var(--muted); display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; } code.l-branch { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--r-1); padding: 0 var(--sp-1); } .l-desc { margin: var(--sp-2) 0 var(--sp-5); } .l-desc > :first-child { margin-top: 0; } .l-cis { margin-bottom: var(--sp-5); } /* A blocked proposal states the obstacle and whose move it is. Loud enough to stop a reader reaching for a control that cannot work. */ .l-block { border: 1px solid var(--del); border-radius: var(--r-2); padding: var(--sp-3) var(--sp-4); margin: 0 0 var(--sp-5); } .l-block strong { color: var(--del); } /* Not a failure, just a record that has been overtaken: stated in the page's own voice rather than in the red of something broken. */ .l-note { border: 1px solid var(--rule); border-radius: var(--r-2); padding: var(--sp-3) var(--sp-4); margin: 0 0 var(--sp-5); background: var(--surface); } .l-note p { margin: var(--sp-1) 0 0; color: var(--muted); } .l-paths { margin: var(--sp-2) 0; } ol.l-events { list-style: none; margin: 0 0 var(--sp-5); padding: 0; border-left: 2px solid var(--rule); } .l-ev { padding: var(--sp-1) 0 var(--sp-1) var(--sp-4); color: var(--muted); } .l-ev strong { color: var(--fg); } .l-ev-accepted { border-left: 2px solid var(--accent); margin-left: -2px; } .l-commits h2, .l-diff h2, .l-talk h2 { font-size: var(--fs-base); color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin: var(--sp-5) 0 var(--sp-2); } .l-why { margin: var(--sp-4) 0 var(--sp-2); color: var(--muted); } /* Collapsed by default: the summary is a control, so it reads as one. */ .l-changes > summary { cursor: pointer; list-style: none; display: flex; align-items: baseline; gap: var(--sp-2); padding: var(--sp-2) 0; } .l-changes > summary::-webkit-details-marker { display: none; } .l-changes > summary::before { content: "\25b8"; color: var(--muted); transition: transform 0.12s; } .l-changes[open] > summary::before { content: "\25be"; } .l-sum { font-size: var(--fs-base); color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } .l-n { color: var(--muted); font-size: var(--fs-body); } /* A disabled decision reads as disabled, not as a dimmer accent: the accent drains out, the outline goes dashed, and the reason beside it is what carries the colour. Half-opacity orange on a dark ground looked like a button that was merely tired. */ .f-reply-foot button[disabled] { opacity: 0.6; cursor: not-allowed; background: transparent; color: var(--muted); border: 1px dashed var(--muted); } /* The filter strip SCROLLS; it does not widen the page. nav.seg-box sets flex-wrap: nowrap and beats .dash-chips's wrap on specificity, so on a phone the four chips could neither wrap nor shrink and forced the document wider than the viewport — which is why the pager looked broken and every heading ran off the right edge. Nothing was wrong with the pager: it was being measured against a page 40px too wide. A segmented control must not wrap (its rounded ends would repeat mid row), so the strip is given its own scroll instead. */ .dash-chips { margin-bottom: var(--sp-2); max-width: 100%; overflow-x: auto; overscroll-behavior-x: contain; } /* Tighter than the general segmented control: six chips carrying an icon, a word and a count only fit the feed column at this padding, and the count only earns its place if the whole strip fits. */ .dash-chips a { display: inline-flex; align-items: center; gap: var(--sp-1); padding-left: var(--sp-2); padding-right: var(--sp-2); } .dash-chips .n { font-size: 0.8em; } .dash-chips .ic { width: 0.9rem; height: 0.9rem; flex: none; } .dash-chips .chip-s { display: none; } /* The strip degrades in one direction, shedding the least-load-bearing thing first. Full: icon, full label, count. Then the counts go — six-figure numbers are the widest thing here and the least useful at a glance. Then the icons, which only ever restated the label. Last the label shortens: "ticket changes" becomes "tickets". A word always survives, so no chip is ever a glyph you have to guess at, and the full wording stays in aria-label and title throughout. This sheds only from the CHIPS. The feed rows below keep their icon at every width — there the glyph is the only thing marking what kind of event a row is. */ /* Keyed to the COLUMN, not the viewport: the strip sits beside the repository sidebar, so on a 1200px desktop it still only gets ~820px and the full form wants 930. A viewport query sheds at the wrong moments in both directions. */ .dash-feed { container-type: inline-size; container-name: feed; } /* Thresholds are the measured width each form needs, not round numbers: counts+icons+words want 929, icons+words 775, words alone 655, and the short words 370. Each tier is dropped exactly when the column can no longer hold it. */ @container feed (max-width: 928px) { .dash-chips .n { display: none; } } @container feed (max-width: 774px) { .dash-chips .ic { display: none; } } /* Check-ins, landings and technote edits are one chip until the column is narrow, where the three fold into "code" to give the strip back its width. The same tier as the short words: that is when the strip stops fitting. */ .dash-chips .k-code { display: none; } @container feed (max-width: 654px) { .dash-chips .chip-w { display: none; } .dash-chips .chip-s { display: inline; } .dash-chips .k-part { display: none; } .dash-chips .k-code { display: inline-flex; } } .dash-awaiting { margin-bottom: var(--sp-4); } .dash-awaiting h3 { font-size: var(--fs-base); margin: 0 0 var(--sp-2); } /* A phone. Six short words and nothing else, at the tightest spacing that still gives each one a tap target. */ @container feed (max-width: 420px) { .dash-chips a { padding-left: var(--sp-1); padding-right: var(--sp-1); font-size: 0.92em; } } /* The repository selector is a filter above the list, not part of it: it needs air beneath it or the list reads as the select's dropdown. */ .dash-facet { display: flex; align-items: center; gap: var(--sp-2); margin: var(--sp-0) 0 var(--sp-4); color: var(--muted); } .dash-facet label { display: flex; align-items: center; gap: var(--sp-2); } .dash-chips .n { color: var(--muted); } .dash-chips .here .n { color: inherit; } .dash h2 { font-size: var(--fs-base); margin: 0 0 var(--sp-2); color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } .dash .tl-badge { margin-right: var(--sp-1); } /* The dashboard styles its own lists. It used to borrow ul.tickets, so when the tracker became a bordered card the dashboard grew three boxes it never asked for — a shared class is a shared fate. */ ul.dash-list { margin: var(--sp-1) 0 0; } ul.dash-list > li { padding: var(--sp-2) var(--sp-4); } /* Weight is a signal, and a page where every line is bold has spent it. A repository NAME is a label and keeps some — it is what you scan the sidebar for. A feed entry is a sentence; it gets none, and stays findable by being the link on its row. */ a.dash-title { font-weight: 500; color: var(--fg); text-decoration: none; } .dash-feed a.dash-title { font-weight: 400; } /* Two lines, then stop. One landing comment ran to fifteen lines on a phone and WAS the viewport — a feed entry is a pointer to the thing, and the thing is one tap away. Clamping is CSS-only, so with styles off the full text is still there rather than cut by the server. */ .dash-feed a.dash-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } a.dash-title:hover { color: var(--accent); text-decoration: underline; } .dash-meta { margin-top: var(--sp-0); font-size: var(--fs-md); color: var(--muted); } .dash-meta a { color: inherit; } .dash-meta a:hover { color: var(--accent); } /* GitHub's latest-commit bar: one line, the message eats the slack and truncates; the hash/date/count block never wraps. On a phone the message yields entirely and the bar keeps author + date, exactly the collapse GitHub makes. */ .tip-bar { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--surface); padding: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-2); font-size: var(--fs-body); } .tip-user { font-weight: 600; flex: none; } .tip-msg { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); text-decoration: none; } .tip-msg:hover { color: var(--accent); text-decoration: underline; } .tip-right { flex: none; margin-left: auto; color: var(--muted); display: inline-flex; align-items: center; gap: var(--sp-1); white-space: nowrap; } .tip-right a { color: var(--muted); text-decoration: none; display: inline-flex; align-items: center; gap: var(--sp-1); } .tip-right a:hover { color: var(--accent); } @media (max-width: 44rem) { .tip-bar .tip-msg, .tip-bar .tip-hash { display: none; } } /* On a phone the stats strip stops being a card and becomes GitHub's wrapping row of inline facts, closing the About flow above the branch bar. */ @media (max-width: 44rem) { .repo-stats { border: 0; background: none; padding: var(--sp-1) 0 var(--sp-2); border-bottom: 1px solid var(--rule); border-radius: 0; } } /* GitHub's About sidebar, borne on Gitea's literal frame: a real grid, not a float — a float squeezes the bars sharing its rows until their contents collide. Column 1 holds every bar, table and document; column 2 is the sidebar, spanning all rows. One column where there is no room, and the aside is FIRST in source order, so the phone reads description → facts → branch bar → files, exactly as GitHub and Forgejo order it. */ .repo-home { display: grid; grid-template-columns: minmax(0, 1fr); } .repo-home > * { grid-column: 1; min-width: 0; } .about-card { margin: var(--sp-2) 0 var(--sp-2); font-size: var(--fs-body); } @media (min-width: 64rem) { .repo-home.has-about { grid-template-columns: minmax(0, 1fr) 17rem; gap: 0 var(--sp-6); } .repo-home.has-about > .about-card { grid-column: 2; grid-row: 1 / span 40; margin: var(--sp-2) 0 0; } } .about-h { font-size: var(--fs-base); margin: 0 0 var(--sp-2); } .about-h a { color: var(--fg); text-decoration: none; } .about-h a:hover { text-decoration: underline; } .about-desc { margin: 0 0 var(--sp-2); color: var(--fg); } .topics { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: var(--sp-3) 0 var(--sp-3); } a.b-pill { color: inherit; text-decoration: none; } a.b-pill:hover { border-color: var(--fg); color: var(--fg); } .about-row { display: flex; align-items: center; gap: var(--sp-2); margin: 0; padding: var(--sp-1) 0; color: var(--muted); text-decoration: none; min-width: 0; } .about-row .ic { flex: none; } /* An about row WRAPS, so its icon belongs on the first line rather than centred over all of them. Baseline alignment gets close and misses by 1.70px, because an SVG has no baseline and the synthesised one stands the icon on the text's. Giving the icon a box exactly one line tall settles it without a magic number: the width still says how big the glyph is, and the SVG's own preserveAspectRatio centres it inside the taller box. On a genuinely two-line row that measures +0.29px, against -1.70px for baseline and -3.30px for a plain start. Scoped here rather than shared, for the reason the ticket rows teach: the SAME declaration measured +2.39px on li.t-row, where the row's own baseline alignment is already exact at -0.01px. There is no alignment that is right for every row. Behind @supports because without the lh unit the height falls back to 1em and a plain start is WORSE than what we have -- an unsupported browser should keep today's 1.70px, not inherit 3.30px. */ @supports (height: 1lh) { .about-row > .ic { align-self: start; height: 1lh; } } /* A wrapping title starts level with its icon rather than centred against the whole block, and its later lines stay in the text column. Notes and Announcement carry real titles; the icon rows above them are all one line, where this is indistinguishable. */ .about-row:has(strong) { align-items: baseline; } .about-row strong { min-width: 0; } .about-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } a.about-row:hover { color: var(--accent); } a.about-row:hover span { text-decoration: underline; } .about-link { color: var(--accent); font-weight: 600; } .about-div { border-top: 1px solid var(--rule); margin: var(--sp-4) 0; } .about-tag a { color: var(--fg); text-decoration: none; } .about-tag a:hover { text-decoration: underline; } .latest-pill { border: 1px solid var(--add); color: var(--add); border-radius: var(--r-4); padding: 0 var(--sp-2); font-size: var(--fs-xs); line-height: 1.5; } .about-sub { margin: 0 0 var(--sp-1) var(--sp-5); font-size: var(--fs-md); color: var(--muted); } .about-sub a { color: var(--accent); text-decoration: none; } .about-sub a:hover { text-decoration: underline; } .doc-tabs { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); margin: var(--sp-5) 0 0; border-bottom: 1px solid var(--rule); font-size: var(--fs-body); } nav.doc-tabs a { padding: var(--sp-1) var(--sp-0); border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; color: var(--muted); text-decoration: none; } nav.doc-tabs a.here { color: var(--fg); background: none; border: 0; border-bottom: 2px solid transparent; font-weight: 600; } nav.doc-tabs a { position: relative; } .doc-panel { margin-top: var(--sp-4); } /* The structured diff: gutters own the numbers and the +/- marker so selection copies clean code; the mark class is the intra-line "what actually changed" emphasis; syntax classes come from the chroma stylesheet already on the page (.dtab carries .chroma). */ .dtab { border-collapse: collapse; width: 100%; font: var(--fs-md)/1.45 var(--font-mono); } .dtab td { padding: 0 var(--sp-2) 0 0; vertical-align: top; } .dnum { user-select: none; text-align: right; color: var(--muted); min-width: 3ch; font-variant-numeric: tabular-nums; padding-left: var(--sp-2); } .dmark { user-select: none; width: 1ch; color: var(--muted); } tr.d-addition .dmark::before { content: "+"; } tr.d-deletion .dmark::before { content: "-"; } tr.d-addition, .dcell.d-addition { background: var(--add-row); } tr.d-deletion, .dcell.d-deletion { background: var(--del-row); } .dcode { white-space: pre-wrap; overflow-wrap: anywhere; width: 100%; } /* A phone gets one number column, the line's own (the new number, or the old one for a removed line), and code that scrolls sideways instead of wrapping: two gutters and a wrap left a 30-character line in four fragments. */ .dnum-one { display: none; } /* Line notes (linenotes.go). A line number that opens one is quiet until pointed at; a note sits under its line as a card in the page's own face, and on a phone it stays at the left edge while wide code scrolls under it. */ .dn-link { color: inherit; text-decoration: none; } .dn-link:hover, .dn-link:focus-visible { color: var(--accent); text-decoration: underline; } tr.d-notes > td { padding: var(--sp-2) var(--sp-3); background: var(--bg); border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); white-space: normal; font-family: var(--font-sans); } tr.d-notes > td > * { position: sticky; left: var(--sp-3); max-width: min(46rem, calc(100vw - 4 * var(--sp-3))); } .d-note { border: 1px solid var(--rule); border-radius: var(--r-2); padding: var(--sp-2) var(--sp-3); background: var(--surface); } .d-note + .d-note { margin-top: var(--sp-2); } .d-note-h { display: block; font-size: var(--fs-sm); color: var(--muted); margin-bottom: var(--sp-1); } .d-note-b { font-size: var(--fs-body); } .d-note-b p { margin: var(--sp-1) 0; } .d-note-form textarea { width: 100%; box-sizing: border-box; font-size: var(--fs-body); padding: var(--sp-2); border: 1px solid var(--rule); border-radius: var(--r-row); background: var(--bg); color: var(--fg); } .d-note-form textarea:focus { outline: none; border-color: var(--accent); } .d-note-foot { display: flex; justify-content: flex-end; align-items: center; gap: var(--sp-3); margin-top: var(--sp-2); font-size: var(--fs-sm); } .f-line code { font-size: var(--fs-sm); } @media (max-width: 40rem) { .dtab:not(.split) .dnum-old, .dtab:not(.split) .dnum-new { display: none; } .dtab:not(.split) .dnum-one { display: table-cell; } .filediff .tw { overflow-x: auto; } .dtab:not(.split) .dcode { white-space: pre; overflow-wrap: normal; } } tr.d-addition .dm, .dcell.d-addition .dm { background: var(--add-mark); border-radius: var(--r-1); } tr.d-deletion .dm, .dcell.d-deletion .dm { background: var(--del-mark); border-radius: var(--r-1); } /* Side-by-side: two half-width code cells; a side with no counterpart is a hatched void, fossil's idiom for "nothing here". */ .dtab.split .dcell { white-space: pre-wrap; overflow-wrap: anywhere; width: 50%; padding-right: var(--sp-2); border-left: 1px solid var(--rule); padding-left: var(--sp-2); } .dcell.d-void { background: repeating-linear-gradient(45deg, transparent, transparent 4px, var(--surface) 4px, var(--surface) 8px); } .hunkhead { color: var(--muted); font: var(--fs-sm)/1.4 var(--font-mono); padding: var(--sp-1) var(--sp-2); border-top: 1px solid var(--rule); background: var(--surface); } .filediff { margin: var(--sp-4) 0; border: 1px solid var(--rule); border-radius: var(--r-2); overflow: hidden; } /* Side-by-side halves the room each line gets, so the split view earns what GitHub gives it: the diff breaks out of the page column and takes the viewport, 2rem margins aside. 50% - 50vw walks from the centred column's edge to the viewport's. Only once the viewport is actually wider than the column — below that the breakout is a no-op that still moves pixels. */ @media (min-width: 80rem) { .filediff.wide { margin-left: calc(50% - 50vw + 2rem); margin-right: calc(50% - 50vw + 2rem); } } .filediff-head { margin: 0; padding: var(--sp-2) var(--sp-3); font-size: var(--fs-body); background: var(--surface); } .diffcount { font-weight: 600; margin: var(--sp-4) 0 var(--sp-1); } .diffmore { color: var(--muted); font-size: var(--fs-body); margin: var(--sp-4) 0; text-align: center; min-height: 1.5rem; } /* Segmented toggle (GitHub's "Unified | Split"): one bordered group, buttons joined, the current one filled. Overrides nav a's pill. */ nav.toggle { display: inline-flex; margin: var(--sp-2) 0 var(--sp-3); border: 1px solid var(--rule); border-radius: var(--r-row); overflow: hidden; font-size: var(--fs-body); } nav.toggle a { border: 0; border-radius: 0; padding: var(--sp-1) var(--sp-3); color: var(--fg); line-height: 1.25; } nav.toggle a + a { border-left: 1px solid var(--rule); } nav.toggle a:hover { background: var(--surface); } nav.toggle a.here { background: var(--code-bg); color: var(--fg); font-weight: 600; } /* The pager, shared by the people list and windowed diffs. GitHub's pagination shape: bordered buttons either side of a position readout; a disabled end keeps its place, muted, so nothing shifts between pages. nav a's pill rule is overridden per button. */ nav.pager { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); margin: var(--sp-3) 0 var(--sp-4); font-size: var(--fs-body); flex-wrap: wrap; } nav.pager a.pager-btn, nav.pager span.pager-btn { display: inline-block; padding: var(--sp-1) var(--sp-3); border-radius: var(--r-row); border: 1px solid var(--rule); text-decoration: none; color: var(--fg); line-height: 1.25; white-space: nowrap; } nav.pager a.pager-btn:hover { background: var(--surface); border-color: var(--muted); } nav.pager .pager-btn[aria-disabled="true"] { color: var(--muted); border-color: transparent; pointer-events: none; } nav.pager .pager-where { color: var(--muted); padding: 0 var(--sp-2); font-variant-numeric: tabular-nums; } nav.pager .pager-where b { color: var(--fg); font-weight: 600; } /* The message leads the page but is not a headline (no forge makes it one — its length is untrusted). wrap-anywhere: a 2000-character unbroken line is real data, not a layout veto. */ /* The header names the page (GitHub's "Commit abc123"); the message sits below it as content in a quiet box, subject bolded — unless the first line is too long to be a subject, in which case it is a paragraph and renders like one. wrap-anywhere throughout: a 2000-character unbroken line is real data, not a layout veto. */ .ci-h { font-size: var(--fs-xl); font-weight: 600; margin: 0 0 var(--sp-1); display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-2); } /* The full hash: its own line, wrapping anywhere, because 64 unbroken characters have no break opportunity of their own and would push a narrow page sideways. */ .ci-fullhash { margin: 0 0 var(--sp-3); font-size: var(--fs-sm); color: var(--muted); overflow-wrap: anywhere; } /* Both of these sit inside 1.4rem display type, so their icons are sized in REM rather than em — at 1em the glyph would inherit the heading and render half again too large. */ .ci-copy { background: none; border: 0; color: var(--muted); cursor: pointer; padding: var(--sp-0); line-height: 1; display: inline-flex; align-items: center; } .ci-copy .ic { width: 0.95rem; height: 0.95rem; vertical-align: 0; } .ci-copy:hover { color: var(--accent); } /* A 28x22 button is a fine SIGHT and a poor TARGET: a thumb wants 44px. The hit area is grown with a pseudo-element rather than with padding, so the button keeps its size next to the hash and only the reachable part changes — and only where the pointer is coarse. */ @media (pointer: coarse) { .ci-copy { position: relative; } .ci-copy::after { content: ""; position: absolute; top: 50%; left: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); } } .ci-copy.done { color: var(--add); } .ci-tags { display: inline-flex; gap: var(--sp-1); flex-wrap: wrap; } .ci-tag { display: inline-flex; align-items: center; gap: var(--sp-1); font-size: var(--fs-xs); font-weight: 500; line-height: 1.3; border: 1px solid var(--rule); border-radius: var(--r-4); padding: var(--sp-0) var(--sp-2); color: var(--muted); background: var(--surface); white-space: nowrap; } .ci-tag .ic { width: 0.85rem; height: 0.85rem; vertical-align: 0; } a.ci-tag { text-decoration: none; } a.ci-tag:hover { color: var(--accent); border-color: var(--accent); } /* Tag history. A log read forwards, so the STANDING rows must read as current and the overruled ones as past without a legend doing all the work: struck text and reduced contrast say "superseded" on sight. */ .ct-back { margin: 0 0 var(--sp-4); font-size: var(--fs-body); } .ct-h { font-size: var(--fs-lg); margin: var(--sp-5) 0 var(--sp-2); } .ct-flag { font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.06em; border: 1px solid var(--del); color: var(--del); border-radius: var(--r-4); padding: var(--sp-0) var(--sp-2); font-weight: 600; } .ct-was { margin: var(--sp-2) 0 0; font-size: var(--fs-md); color: var(--muted); } .ct-waslabel { text-transform: uppercase; letter-spacing: 0.07em; font-size: var(--fs-xs); font-weight: 600; margin-right: var(--sp-1); } table.ct { width: 100%; border-collapse: collapse; font-size: var(--fs-md); } table.ct th { text-align: left; font-weight: 600; color: var(--muted); font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.06em; padding: var(--sp-1) var(--sp-3) var(--sp-2) 0; border-bottom: 1px solid var(--rule); white-space: nowrap; } table.ct td { padding: var(--sp-2) var(--sp-3) var(--sp-2) 0; border-bottom: 1px solid var(--rule); vertical-align: top; } .ct-when { white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; font-size: var(--fs-sm); } .ct-src, .ct-sign { white-space: nowrap; } .ct-val { overflow-wrap: anywhere; } .ct-none { color: var(--muted); font-style: italic; } .ct-gone { color: var(--muted); } .ct-gone .ct-val, .ct-gone .ct-name code { text-decoration: line-through; } .sg { display: inline-block; min-width: 1.1rem; text-align: center; font-weight: 700; font-family: var(--font-mono); } .sg-add { color: var(--add); } .sg-del { color: var(--del); } .sg-prop { color: var(--accent); } table.mf { width: 100%; border-collapse: collapse; font-size: var(--fs-md); } table.mf td { padding: var(--sp-2) var(--sp-3) var(--sp-2) 0; border-bottom: 1px solid var(--rule); vertical-align: baseline; } .mf-l { width: 1.6rem; } .mf-l code { font-weight: 700; color: var(--accent); } .mf-t { font-family: var(--font-mono); font-size: var(--fs-sm); overflow-wrap: anywhere; } .mf-n { color: var(--muted); font-size: var(--fs-sm); white-space: normal; min-width: 12rem; } @media (max-width: 40rem) { table.mf, table.mf tbody, table.mf tr, table.mf td { display: block; width: auto; } table.mf tr { border-bottom: 1px solid var(--rule); padding: var(--sp-2) 0; } table.mf td { border: 0; padding: var(--sp-0) 0; } .mf-l { display: inline-block; margin-right: var(--sp-2); } .mf-t { display: inline; } } .mf-files { margin: var(--sp-4) 0 0; display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: baseline; } .mf-why { color: var(--muted); font-size: var(--fs-md); } .mf-note { color: var(--muted); font-size: var(--fs-sm); margin: var(--sp-4) 0 0; max-width: 62ch; } .dv-mode { margin: 0 0 var(--sp-2); font-size: var(--fs-body); } .ct-legend { font-size: var(--fs-sm); color: var(--muted); margin: var(--sp-3) 0 0; max-width: 62ch; } /* A person, as one target: the mark and the name move together, and the whole thing takes the page's link colour on hover — the same affordance the hash and the branch beside it already have. */ /* A byline inside a row that is ITSELF a link stays plain — the docs list and the wiki home card are whole-card anchors, and an anchor inside an anchor is invalid: the parser closes the outer one at the inner, which tore those rows in half. The row already goes somewhere; a second target inside it was never the right idea. INLINE, not inline-flex. A flex box is atomic: it cannot flow with the text around it, so as a byline it stopped wrapping mid-phrase and jumped to a line of its own — which is what it did to the docs list, where every row grew a second line. The mark is aligned by the baseline rule .idn already carries. */ a.who { color: inherit; text-decoration: none; border: 0; padding: 0; white-space: nowrap; } a.who:hover { color: var(--accent); } a.who:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .tl-filter { margin: 0 0 var(--sp-2); font-size: var(--fs-body); color: var(--muted); display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } /* Patch is an ACTION, not a third view, so it stands outside the segmented toggle and borrows the pager button's shape. */ .ci-patch { display: inline-flex; align-items: center; gap: var(--sp-1); margin: var(--sp-2) 0 var(--sp-3) var(--sp-2); padding: var(--sp-1) var(--sp-3); font-size: var(--fs-body); line-height: 1.25; border: 1px solid var(--rule); border-radius: var(--r-row); color: var(--fg); text-decoration: none; vertical-align: top; } .ci-patch .ic { width: 0.95rem; height: 0.95rem; vertical-align: 0; } .ci-patch:hover { background: var(--surface); border-color: var(--muted); } /* Counts read as a pair, so they hold their column: tabular numerals stop a list of files jittering line to line. */ .ci-stat { font-size: var(--fs-sm); font-weight: 400; font-variant-numeric: tabular-nums; white-space: nowrap; } .ci-add { color: var(--add); } .ci-del { color: var(--del); } .ci-box { margin: 0 0 var(--sp-4); } .ci-sub { font-weight: 600; margin: 0; overflow-wrap: anywhere; } .ci-sub.long { font-weight: 400; } .ci-msg { white-space: pre-wrap; overflow-wrap: anywhere; margin: var(--sp-2) 0 0; color: var(--muted); } /* The changed-files list wears the files page's clothes: icon + name as one label, no underline until hover, the change kind sitting where the listing puts its sizes. */ /* The changed-file TREE. One row per directory, its count on the row, the A/M/D tally beside it — so a 600-file check-in says where it changed before a reader scrolls anything. */ .ftree { margin: 0 0 var(--sp-3); } /* Wrapping and an ellipsis are DEFENSIVE, not measured. The tally sits at the row's right edge, so a compressed path long enough to fill a narrow row would push it out; wrapping only engages when the content genuinely does not fit, and a directory name is an identifier that must not break mid-segment. Headless Chrome cannot render a real phone viewport (it clamps the window and the layout viewport stays wide), so this pair is reasoned from the box model rather than seen — the device is the test. */ .ftree-d > summary { cursor: pointer; padding: var(--sp-1) var(--sp-0); display: flex; align-items: center; gap: var(--sp-2); border-radius: var(--r-row); flex-wrap: wrap; } /* A directory name is an identifier and must not break mid-segment; it scrolls its own row instead of reflowing the tally under it. */ .ftree-d > summary code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } .ftree-d > summary:hover { background: var(--surface); } .ftree-d { border-left: 1px solid var(--rule); margin-left: var(--sp-1); padding-left: var(--sp-2); } .ftree-n { color: var(--muted); font-size: var(--fs-sm); } .ftree-t { margin-left: auto; display: inline-flex; gap: var(--sp-2); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; } .ftree-t .d-add { color: var(--add); } .ftree-t .d-del { color: var(--del); } .ftree-t .d-mod { color: var(--muted); } .ftree-files { list-style: none; margin: var(--sp-0) 0 var(--sp-1); padding: 0; } /* Columns only where a group is long enough to need them; 18rem is about the width a path reads at without wrapping mid-name. */ .ftree-files.cols { columns: 18rem; column-gap: var(--sp-5); } .ftree-files li { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-0) var(--sp-0); break-inside: avoid; } .ftree-files li:hover { background: var(--surface); } .ftree-files a { text-decoration: none; display: inline-flex; align-items: center; gap: var(--sp-1); min-width: 0; } .ftree-files a:hover { text-decoration: underline; } .ftree-files .tl-badge { margin-left: auto; } /* In columns the badge follows the NAME instead of the column edge: pushed to the edge it left a hand's width of nothing between a filename and its own status, in every row of every column. */ .ftree-files.cols li { justify-content: flex-start; } .ftree-files.cols .tl-badge { margin-left: var(--sp-2); } /* The summary strip IS the filter: each chip says how many of that kind, and narrows to them when pressed. */ .ftree-f { margin: 0 0 var(--sp-2); display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; font-size: var(--fs-md); } .ftree-k { text-decoration: none; color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-0) var(--sp-2); font-variant-numeric: tabular-nums; } .ftree-k:hover { border-color: var(--muted); color: var(--fg); } .ftree-k strong { color: var(--fg); } .ftree-k.here { border-color: var(--fg); color: var(--fg); background: var(--surface); } .ftree-k.k-A strong { color: var(--add); } .ftree-k.k-D strong { color: var(--del); } .ftree-only { color: var(--muted); font-size: var(--fs-sm); } .ftree-clear { text-decoration: none; color: var(--muted); font-size: var(--fs-sm); } .ftree-clear:hover { text-decoration: underline; } .difffiles { margin: 0 0 var(--sp-3); } .difffiles li { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-1) var(--sp-4); line-height: 1.45; } .difffiles li:hover { background: var(--surface); } .difffiles a { text-decoration: none; display: inline-flex; align-items: center; gap: var(--sp-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; } .difffiles a:hover { text-decoration: underline; } .difffiles .tl-badge { margin-left: auto; } .b-pill { font-size: var(--fs-xs); padding: var(--sp-0) var(--sp-1); border-radius: var(--r-4); border: 1px solid var(--rule); color: var(--muted); white-space: nowrap; } .b-pill.b-pri { border-color: var(--accent); color: var(--accent); } .b-pill.b-warn { border-color: var(--warn); color: var(--warn); } /* A revoked or expired credential stays listed for the record and reads as past, not as one more to choose from. */ .tok-ended td { color: var(--muted); } .t-badges span { font-size: var(--fs-xs); padding: var(--sp-0) var(--sp-1); border-radius: var(--r-4); border: 1px solid var(--rule); color: var(--muted); white-space: nowrap; } .t-badges .b-pri { border-color: var(--accent); color: var(--accent); } /* A card looks the same on either board. .board-rows is the team's (RFC 0018), which lays cards out in one column rather than in lanes because its grouping axis is people and there is no bound on how many — but a card is a card, and the two surfaces being recognisably one idea is the whole reason the team board borrows this look rather than inventing another. */ .board .card, .board-rows .card { background: var(--bg); border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-2); margin-bottom: var(--sp-2); } .board .card .t-title, .board-rows .card .t-title { display: block; font-weight: 600; overflow-wrap: anywhere; } .board .card .t-meta, .board-rows .card .t-meta { margin-top: var(--sp-1); } /* The move control is deliberately always present, never revealed by script: it is how a touch or keyboard reader moves a card, and any drag layer is an addition on top of it. */ /* On a phone the open columns stack full-width and the folded ones become a row of chips above them. The earlier design kept a swiped row here; a page that scrolls sideways was judged worse than a board that reads as stacked lists, which it is on any phone-sized screen. */ @media (max-width: 40rem) { .board { gap: var(--sp-2); } .board-col { flex: 1 1 100%; } .board-col.folded { flex: 0 0 auto; order: -1; padding: var(--sp-1) var(--sp-2); } .board-col.folded h3 { writing-mode: horizontal-tb; transform: none; } } /* ---- wiki --------------------------------------------------------- Rails beside the page on wide screens, page alone on narrow ones. Dropped rather than stacked on a phone: a dozen sibling links and a contents above the text push the text itself below the fold, and both rails lead only where the page already leads. */ /* Two widths, not one. Running text caps at the measure because that is what reading wants; a code block, a table or a diagram gets the whole column, because wrapping those to prose width is what makes people scroll sideways. The column then sizes to the CONTENT rather than eating the viewport — at 1800px the article ended 391px short of the contents rail, which is not breathing room, it is a hole. */ .w-doc { --measure: 62ch; --column: 78ch; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-5); } /* The track list follows the rails that are actually THERE. A doc page has no sibling rail and a short page has no contents, and a grid that reserves a column for a rail nobody rendered puts the document itself in the 11.5rem gutter. */ @media (min-width: 64rem) { /* Left-aligned, not centred. Centring indented the block away from the page title, which sits outside the grid — on a doc page the heading started at 392px and its own first paragraph at 495px, which reads as two columns that missed each other. The leftover goes to the page edge, where every other page in the forge puts it. The contents track is reserved WHETHER OR NOT a page has contents. Sizing it to what each page happens to hold made the prose jump sideways every time a reader clicked from a page with headings to one without — the text should sit where it sat, and an empty track costs nothing but the space it was always going to occupy. The left rail still follows :has, because a doc page has no siblings to list and reserving a gutter for nothing is a different mistake. */ .w-doc { grid-template-columns: minmax(0, var(--column)) 12.5rem; } .w-doc:has(.w-rail) { grid-template-columns: 11.5rem minmax(0, var(--column)) 12.5rem; } /* A page with NEITHER rail centres instead. The rule above is left- aligned for one specific reason — the layout's h1 sits outside the grid, so centring the block left the heading and its own first paragraph starting a hundred pixels apart. A notes page draws its heading INSIDE the column (OwnH1), so nothing is left behind to miss, and the reserved-but-empty contents track that keeps prose from jumping sideways between doc pages has nothing to hold still for. Without this the column strands itself against the left edge under a wide empty margin, which is not how the rest of the forge lays a page out. */ .w-doc.w-solo { grid-template-columns: minmax(0, var(--column)); justify-content: center; } } .w-rail, .w-toc { display: none; } @media (min-width: 64rem) { .w-rail, .w-toc { display: block; } } .w-railhead { font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); font-weight: 600; margin: 0 0 var(--sp-2); } .w-rail ul, .w-toc ul { list-style: none; margin: 0; padding: 0; } .w-rail a { display: block; padding: var(--sp-1) var(--sp-2); border-radius: var(--r-row); text-decoration: none; color: var(--fg); font-size: var(--fs-md); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .w-rail a:hover { background: var(--surface); } .w-rail a.here { background: var(--surface); color: var(--fg); font-weight: 600; } /* No ring on these two. A box drawn around a wrapped three-line entry reads as a defect, and the rails are dense enough that colour alone carries the state. Focus still SHOWS — it borrows the accent the current entry uses — because a keyboard reader who cannot see where they are is worse off than one looking at an ugly outline. */ .w-rail a:focus-visible, .w-toc a:focus-visible { outline: none; } .w-rail a:focus-visible { background: var(--surface); color: var(--accent); } .w-toc a:focus-visible { color: var(--accent); border-left-color: var(--accent); } .w-railall { color: var(--muted) !important; } .w-crumb { font-size: var(--fs-sm); color: var(--muted); margin: 0 0 var(--sp-2); } .w-crumb a { text-decoration: none; } .w-by { font-size: var(--fs-sm); color: var(--muted); margin: 0 0 var(--sp-4); padding-bottom: var(--sp-2); border-bottom: 1px solid var(--rule); } /* THE measure. Running text at the full content width came out around 152 characters a line, roughly twice the comfortable maximum, which is the single thing most wrong with reading a long page here. */ .w-main article > * { max-width: var(--measure); } /* The exceptions: content whose lines are authored, not flowed. */ .w-main article > pre, .w-main article > table, .w-main article > .tw, .w-main article > figure, .w-main article > svg, .w-main article > .pikchr { max-width: none; } .w-main article h1:first-child { margin-top: 0; } .w-toc { position: sticky; top: 0.6rem; align-self: start; } .w-toc ul { border-left: 2px solid var(--rule); } /* These rails are nav elements, and the global nav-link rule gives every link a 1px box with a radius that the .here rule then COLOURS IN. Setting only border-left left the other three sides to show up on the current entry as a box drawn around a wrapped label. Reset the box, then draw the one edge this component actually wants. */ .w-toc a { display: block; padding: var(--sp-0) var(--sp-2); margin-left: -2px; border: 0; border-left: 2px solid transparent; border-radius: 0; background: none; text-decoration: none; color: var(--muted); font-size: var(--fs-sm); line-height: 1.35; } .w-toc a:hover { color: var(--fg); } .w-toc a.sub { padding-left: var(--sp-5); font-size: var(--fs-sm); } .w-toc a.here { color: var(--accent); border-left-color: var(--accent); background: none; font-weight: 600; } /* The index. */ .w-home { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3); border: 1px solid var(--rule); border-radius: var(--r-2); padding: var(--sp-3) var(--sp-4); margin: var(--sp-1) 0 var(--sp-4); background: var(--surface); text-decoration: none; color: var(--fg); } .w-home:hover { border-color: var(--accent); } .w-home-t { font-weight: 700; font-size: var(--fs-lg); } .w-home-m { color: var(--muted); font-size: var(--fs-sm); margin-left: auto; } .w-controls { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-4); } .w-find { display: flex; align-items: center; gap: var(--sp-2); border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--surface); padding: var(--sp-0) var(--sp-0) var(--sp-0) var(--sp-2); flex: 1 1 16rem; max-width: 22rem; } .w-find label { color: var(--muted); font-size: var(--fs-md); } .w-find input { flex: 1 1 auto; min-width: 0; border: 0; background: none; color: var(--fg); font: inherit; font-size: var(--fs-body); padding: var(--sp-1) 0; } .w-find input::placeholder { color: var(--muted); } .w-find:focus-within { border-color: var(--accent); } .w-pages { list-style: none; margin: 0; padding: 0; } @media (min-width: 52rem) { .w-pages { columns: 2; column-gap: var(--sp-6); } } .w-pages li { break-inside: avoid; border-bottom: 1px solid var(--rule); } .w-pages a { display: grid; grid-template-columns: 1fr auto auto; align-items: baseline; gap: var(--sp-3); padding: var(--sp-2) var(--sp-1); text-decoration: none; color: var(--fg); font-size: var(--fs-body); } .w-pages a:hover { background: var(--surface); } .w-pages a:hover .w-t { color: var(--accent); } .w-t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .w-when { color: var(--muted); font-size: var(--fs-sm); white-space: nowrap; } .w-revs { color: var(--muted); font-size: var(--fs-xs); background: var(--surface); border-radius: var(--r-4); padding: 0 var(--sp-2); min-width: 2.1rem; text-align: center; font-variant-numeric: tabular-nums; } .w-count { margin: var(--sp-4) 0 0; font-size: var(--fs-sm); color: var(--muted); } /* The source marker is deliberately the quietest thing in the row: a reader needs it only when they want to EDIT, and until then it is noise between them and the title. */ .w-kind { font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-4); padding: 0 var(--sp-1); white-space: nowrap; opacity: 0.75; } .w-assoc { margin: var(--sp-4) 0 0; font-size: var(--fs-sm); color: var(--muted); } /* The About sits on the surface tint rather than in a bordered card: it is context for what follows, not a peer of it. */ /* The context panel. A rail of hashes down the left with the message beside it, and the check-in you are on lifted out of the run — the one row that must be findable without reading. */ .ci-ctx { margin: var(--sp-4) 0; } .ci-ctx h2 { font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); font-weight: 600; margin: 0 0 var(--sp-2); } .ci-ctx ol { list-style: none; margin: 0; padding: 0; } .ci-ctx li { display: grid; grid-template-columns: 14px auto auto minmax(0, 1fr); align-items: center; gap: var(--sp-2); padding: 0; } .ci-ctx li.here { background: var(--surface); } .ci-node { width: 14px; height: 34px; display: block; overflow: visible; } .ci-ctx-h { text-decoration: none; } .ci-ctx li.here .ci-ctx-h { font-weight: 600; } .ci-ctx-m { color: var(--muted); font-size: var(--fs-sm); white-space: nowrap; } .ci-ctx-c { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-md); } /* On a phone the row stacks: hash, then the message, then who and when. The drawn rail goes with it — an SVG node cannot stretch to a stacked row without distorting its circle, so the rule becomes a border on the list, which is the same line by other means. */ @media (max-width: 44rem) { .ci-ctx ol { border-left: 2px solid var(--rule); } .ci-node { display: none; } .ci-ctx li { grid-template-columns: minmax(0, 1fr); gap: var(--sp-0); padding: var(--sp-2) var(--sp-3); border-left: 2px solid transparent; margin-left: -2px; } .ci-ctx li.here { border-left-color: var(--accent); } .ci-ctx-c { white-space: normal; } } .a-note { background: var(--surface); border-radius: var(--r-2); padding: var(--sp-3) var(--sp-4) var(--sp-2); margin: var(--sp-4) 0; } /* Closed, it is a one-line control rather than a banner with nothing in it: the padding that frames prose looks like a mistake around a summary on its own. */ .a-note:has(.a-note-d:not([open])) { padding: var(--sp-2) var(--sp-4); } .a-note-b > :first-child { margin-top: 0; } .a-note-b > :last-child { margin-bottom: 0; } /* Prose keeps a readable measure; BLOCK content takes the panel. The note is as wide as the page, so capping the whole body at 68ch left a terminal transcript clipped mid-word inside a box with room to spare — the width was there and nothing was allowed to use it. This is the same split the doc pages already make between an article and the figures inside it. */ .a-note-b > * { max-width: 68ch; } .a-note-b > pre, .a-note-b > table, .a-note-b > .tw, .a-note-b > figure, .a-note-b > svg, .a-note-b > .pikchr, .a-note-b > blockquote { max-width: none; } /* A note carries arbitrary prose, including a command line longer than a phone. Its own scroll container, so the PAGE never moves sideways — the same treatment rendered tables already get. */ .a-note-b pre { overflow-x: auto; } .a-note-b code { overflow-wrap: anywhere; } .a-note-d > summary { cursor: pointer; font-size: var(--fs-sm); color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; list-style: none; } .a-note-d > summary::-webkit-details-marker { display: none; } .a-note-d > summary::before { content: "\25b8"; display: inline-block; margin-right: var(--sp-2); transition: transform 0.12s; } .a-note-d[open] > summary::before { transform: rotate(90deg); } .a-note-d > summary:hover { color: var(--fg); } .a-note-d[open] > summary { margin-bottom: var(--sp-2); } @media (prefers-reduced-motion: reduce) { .a-note-d > summary::before { transition: none; } } .a-note-f { margin: var(--sp-2) 0 0; font-size: var(--fs-sm); color: var(--muted); } .a-note-f a { color: var(--muted); text-decoration: none; } .a-note-f a:hover { color: var(--accent); text-decoration: underline; } /* The tracker header is ONE line: which lives are listed on the left, how they are drawn and the one action on the right. Stacked, the four controls read as four unrelated decisions; in a row they read as the axes of a single view, which is what they are. */ .t-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); margin: var(--sp-1) 0 var(--sp-3); } .t-tools { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin-left: auto; } /* The filter bar is ONE line. The segmented control is the page's navigation and cannot shrink; the action beside it can, so below the width where both fit it keeps its icon and drops its words. Letting the row wrap instead pushed the action onto a line of its own, which reads as a second toolbar. */ .act-icon { display: inline-flex; align-items: center; gap: var(--sp-1); } .act-icon .ic { flex: none; } @media (max-width: 30rem) { /* The row wraps, but the state filter is always FIRST and whole: it is how a reader says what they are looking at. Where a page carries more tool groups than fit beside it (the tracker has three and a button), those move to the next line instead. A page whose tools do fit — the landings list, one icon — still keeps one line. */ .t-head { flex-wrap: wrap; gap: var(--sp-2) var(--sp-2); } .t-head > nav.seg-box:first-child { flex: none; } .act-icon .act-label { display: none; } .act-icon { padding-left: var(--sp-2); padding-right: var(--sp-2); } /* nowrap alone would push the action off the side rather than fit it: the control is a hair too wide at 390px once the icon is beside it. The choices give up the padding, not the words — shortening them would cost the reader more than the whitespace. */ .t-head nav.seg-box a { padding-left: var(--sp-2); padding-right: var(--sp-2); } } /* A segmented control, not a row of loose pills: the choices along one axis share a box and butt against each other, so the group reads as "pick one of these three" before a single word is read. Loose pills read as three unrelated links, which is what they were. */ /* The dividers are GAPS, not borders. A border-right per item is the single-row idiom and it breaks the moment the strip wraps: the item ending a row keeps a divider that lands against the container edge, the last item overall has none, and nothing rules between the rows. Measured at 390px this strip is three rows, so wrapping is the normal case, not the exception. Letting the container's colour show through a 1px gap draws every divider the layout actually needs — vertical between neighbours, horizontal between rows — with no rule knowing where a row ends. */ /* A segmented control is ONE control, so it never breaks mid-way: with wrap, a squeezed bar dropped its last choice onto a line of its own ("99 All" below "30 Open | 69 Closed"), which reads as a stray button rather than as part of the set. It keeps its line; the tools BESIDE it are what give way. */ nav.seg-box { display: inline-flex; flex-wrap: nowrap; align-items: stretch; gap: var(--sp-0); background: var(--rule); border: 1px solid var(--rule); border-radius: var(--r-2); overflow: hidden; } nav.seg-box a { border: 0; border-radius: 0; margin: 0; padding: var(--sp-1) var(--sp-3); background: var(--bg); } /* These tabs CENTRE their contents. The generic nav-link rule above aligns on the baseline, which is right for a link whose label can wrap and wrong here, where a tab is one line by construction. It matters because an SVG has no baseline: the browser synthesises one from the bottom margin edge, so a baseline row stands the icon ON the text baseline and it rides high. Measured at exactly -2.00px on the ticket, landing and sqlite state tabs -- icon centre against the adjacent text's own glyph box -- and 0.00 with this line. Stated on the tab rather than on .ic on purpose. The icon cannot know whether its row is one line or four: align-self on .ic fixed these tabs and then centred the ticket-list status glyph over a four-line row, 27px below the title it belongs to. Only the host knows which it is, so the host is what says it. */ nav.seg-box a { align-items: center; } /* Selected reads as the ACCENT, not a grey wash — the same language the repobar, explore, doc and fleet tabs already speak with their ink underline. A filled --surface pill was wrong twice: the token is one value for both themes on purpose (it tints whatever ground it lands on), which makes it heavy on the light paper, and hover used the very same value, so a hovered tab and the selected one were identical. Now --surface means hover and the accent means selected. The rule is an inset shadow rather than a border so nothing shifts by 2px when a tab becomes current. */ nav.seg-box a { color: var(--muted); white-space: nowrap; flex: none; transition: background-color 120ms ease, color 120ms ease; } /* Hover is a whisper — a five-percent wash of the text colour — so the pointer's tab never competes with the selected one for the eye. */ nav.seg-box a:hover { background: color-mix(in oklab, var(--fg) 5%, transparent); color: var(--fg); } nav.seg-box a.here { background: var(--code-bg); color: var(--fg); font-weight: 600; } nav.seg-box a.here:hover { background: var(--code-bg); } /* The report row: six native selects that read as one control strip. A set axis is outlined in accent so "why am I only seeing four tickets" is answerable by looking rather than by reading the URL. */ .t-filters { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-4); } /* The query box, sized so a real report fits without scrolling inside it: "is:closed subsystem:web crash" is the shape people type. */ /* The query box and the six selects are one strip: one height, one radius, padding on the spacing scale. They were 32 and 24 px tall. */ .t-search { display: flex; align-items: center; gap: var(--sp-1); box-sizing: border-box; min-height: 2.25rem; border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--surface); padding: 0 var(--sp-2) 0 var(--sp-3); margin: 0 0 var(--sp-2); max-width: 34rem; } .t-search-label { color: var(--muted); font-size: var(--fs-md); } .t-search input { flex: 1 1 auto; min-width: 0; border: 0; background: none; color: var(--fg); font: inherit; font-size: var(--fs-body); padding: var(--sp-1) 0; } .t-search input::placeholder { color: var(--muted); } .t-search:focus-within { border-color: var(--accent); } .t-filter { display: inline-flex; align-items: center; gap: var(--sp-1); box-sizing: border-box; min-height: 2.25rem; border: 1px solid var(--rule); border-radius: var(--r-2); padding: 0 var(--sp-3); background: var(--surface); font-size: var(--fs-md); color: var(--muted); } .t-filter > span { text-transform: lowercase; } .t-filter select { border: 0; background: none; color: var(--fg); font: inherit; font-size: var(--fs-md); padding: 0; max-width: 11rem; } .t-filter.on { border-color: var(--accent); color: var(--accent); } .t-filter.on select { color: var(--accent); font-weight: 600; } .t-clear { font-size: var(--fs-md); } /* The dense scan. Rows are for COMPARING here, not reading, so the type scale drops and the columns stay narrow enough that an eye can run down one without tracking sideways. */ table.t-table { width: 100%; border-collapse: collapse; font-size: var(--fs-body); } table.t-table th { text-align: left; font-weight: 600; white-space: nowrap; border-bottom: 1px solid var(--rule); } table.t-table th a { text-decoration: none; color: var(--muted); display: inline-block; padding: var(--sp-2) var(--sp-2); } table.t-table th a:hover { color: var(--fg); } table.t-table td { padding: var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--code-bg); white-space: nowrap; } table.t-table tr:hover td { background: var(--surface); } table.t-table .ord { color: var(--accent); } table.t-table td.c-title { white-space: normal; min-width: 18rem; } table.t-table td.c-title a { color: var(--fg); text-decoration: none; font-weight: 600; } table.t-table td.c-title a:hover { color: var(--accent); text-decoration: underline; } /* The id is a link because fossil readers reach for it, but the title beside it already goes to the same place: underlining both draws two lines per row and neither means anything the other does not. */ table.t-table td.c-id a { text-decoration: none; color: var(--muted); } table.t-table td.c-id a:hover { color: var(--accent); text-decoration: underline; } table.t-table td.c-id { white-space: nowrap; } /* Rail beside listing on wide screens, listing alone on narrow ones. The rail is dropped rather than stacked on a phone: stacked, a dozen browse links push the tickets themselves below the fold, and the dropdowns above already reach every value it offers. */ .t-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-4); } @media (min-width: 64rem) { .t-body { grid-template-columns: 12rem minmax(0, 1fr); } } .t-rail { display: none; } @media (min-width: 64rem) { .t-rail { display: block; } } .t-rail-sec + .t-rail-sec { margin-top: var(--sp-4); } .t-rail h2 { font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 var(--sp-1); font-weight: 600; } .t-rail ul { list-style: none; margin: 0; padding: 0; } .t-rail a { display: flex; align-items: baseline; gap: var(--sp-2); padding: var(--sp-1) var(--sp-2); border: 0; border-radius: var(--r-row); text-decoration: none; color: var(--fg); font-size: var(--fs-md); } .t-rail a:hover { background: var(--surface); border-color: transparent; } .t-rail a.here { background: var(--surface); border-color: transparent; color: var(--fg); font-weight: 600; } .t-rail .t-rail-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .t-rail .n { color: var(--muted); font-size: var(--fs-sm); } /* The numbered pager. Page numbers sit close together as one run so the eye reads them as a scale; the two step controls stand apart from it, and the position line sits apart again because it is a statement rather than a control. */ .t-pager { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: var(--sp-1); margin: var(--sp-4) 0 var(--sp-1); font-size: var(--fs-md); } .t-pager a { text-decoration: none; padding: var(--sp-1) var(--sp-2); border: 1px solid transparent; border-radius: var(--r-row); color: var(--fg); } .t-pager a:hover { background: var(--surface); border-color: var(--rule); } .t-pager > span { color: var(--muted); padding: var(--sp-1) var(--sp-2); } .t-page-step { margin: 0 var(--sp-2); } a.t-page-step { border-color: var(--rule); } /* One shape for a page number, selected or not, and one way of centring it. The pager is a nav, and a global "nav a { display: inline-flex }" therefore applies to every page link. text-align does nothing to a flex container's items, so the digit sat at the flex start — hard left — while the CURRENT page, a span, stayed display:block and centred properly. Measured live: the anchors had 10.6px to the left of the glyph and 33.9px to the right. The boxes differed too — 0.5rem of padding and no border on the span against 0.6rem plus a 1px border on the anchors, 48px versus 53.2px for the same digit — so the highlight also changed size as the pointer moved. Both are settled here, for both elements. */ .t-page-n { min-width: 2rem; text-align: center; font-variant-numeric: tabular-nums; } .t-pager .t-page-n { display: flex; align-items: center; justify-content: center; padding: var(--sp-1) var(--sp-2); border: 1px solid transparent; } /* Where you are is a statement, not a link — it goes nowhere, so it is not shaped like something that would. */ .t-page-n.here { background: var(--accent); color: var(--bg); border-radius: var(--r-row); font-weight: 600; } .t-page-gap { padding: var(--sp-1) var(--sp-1); } .t-pager .t-pager-at { font-variant-numeric: tabular-nums; margin-left: var(--sp-3); } /* Narrow: the count drops to its own line and the controls keep theirs. The row already wraps, but justify-content centres each wrapped line independently, so whatever spilled — usually "older" — landed centred under the numbers beside the count, reading as if it belonged to them. The count is a statement ABOUT the list, not a control in it, so it is the part that leaves. */ @media (max-width: 34rem) { .t-pager .t-pager-at { flex-basis: 100%; margin-left: 0; text-align: center; } /* And the controls are trimmed to fit one row rather than spilling a lone "older" onto its own. Measured at 390px: the row wanted 398px of a 358px container, and most of that was the number cells — 2rem of min-width plus padding on each. */ .t-page-step { margin: 0 var(--sp-0); } .t-pager a { padding: var(--sp-1) var(--sp-1); } /* Same selector as the rule above, or it loses on specificity and the numbers keep their wide padding while everything else shrinks. */ .t-pager .t-page-n { padding: var(--sp-1) var(--sp-1); min-width: 1.5rem; } } /* The local tier's marks: the chip that leads a queue row, and the icon on a local ticket's title. */ .chip.c-local { border-color: var(--accent); color: var(--accent); } .t-local { color: var(--accent); border-color: var(--accent); } .t-local .ic { width: 0.8em; height: 0.8em; vertical-align: 0.05em; } /* The state under a ticket's title: a filled pill, so open and closed differ in shape and word as well as hue, from the tokens both themes already contrast-check (the listing's own state colours). */ .t-state { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: var(--sp-2) 0 var(--sp-4); } .t-pill { display: inline-flex; align-items: center; gap: var(--sp-1); padding: var(--sp-1) var(--sp-3); border-radius: var(--r-4); font-size: var(--fs-sm); font-weight: 600; line-height: 1.2; color: var(--bg); } .t-pill .ic { width: 1em; height: 1em; } .t-pill-open { background: var(--add); } .t-pill-review { background: var(--accent); } .t-pill-closed { background: var(--muted); } /* A ticket named in prose (markup.TicketDescriber): its number, then its state as a small pill in the same colours as the ticket's own page, so a list of references says which are done at a glance. */ a.tref { font-variant-numeric: tabular-nums; white-space: nowrap; } a.ref-nw, .t-num, .msg-num { white-space: nowrap; } /* The state is the icon in front of the number, in the list's own colours. */ a.tref .ref-ic, a.ref .ref-ic, .msg-num .ref-ic { margin-right: 0.2em; } .tref-local { display: inline-block; padding: 0 var(--sp-2); border-radius: var(--r-4); font-size: var(--fs-xs); font-weight: 600; line-height: 1.5; vertical-align: 0.08em; white-space: nowrap; } .tref-local { border: 1px solid var(--accent); color: var(--accent); } /* Trunk's CI failed: a dot on the Timeline tab, in the colour the failure badges already use. */ .tab-dot { display: inline-block; width: 0.5em; height: 0.5em; margin-left: var(--sp-1); border-radius: var(--r-4); background: var(--del); vertical-align: 0.15em; } .tip-ci { text-decoration: none; } /* Promote and Discard: each a small summary that opens one line saying what it will do and the button that does it — a confirmation that needs no script and works the same on a phone. */ .t-tier-acts { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-2); } .t-confirm > summary { list-style: none; display: inline-flex; align-items: center; padding: var(--sp-0) var(--sp-2); border: 1px solid var(--rule); border-radius: var(--r-row); font-size: var(--fs-sm); color: var(--fg); cursor: pointer; } .t-confirm > summary::-webkit-details-marker { display: none; } .t-confirm[open] > summary { border-color: var(--accent); color: var(--accent); } .t-confirm[open] { flex-basis: 100%; } .t-confirm p { margin: var(--sp-2) 0; font-size: var(--fs-sm); color: var(--muted); } /* The honeypot is for robots only: off-canvas, unfocusable. */ .hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } /* A row is a GRID — glyph gutter, the ticket, conversation count pinned right — not a paragraph. Inline spans with no box are just more words, which is how the type and subsystem chips ran into the title and into each other ("Code_Defectci"). */ /* THE LIST COMPONENT. One CARD with hairlines inside it, the shape every forge converged on: a bordered block says "this is the list" and its last row is visibly the last, where a stack of underlined paragraphs trails off into the footer. It was the ticket listing's shape; every row list in the app now wears it, so a list is recognisable as a list wherever you meet one. The card owns the border, the radius and the hairlines. Rows own their own layout and vertical rhythm — they differ, and should — but take their horizontal padding from here so no row's text touches the card edge. Not for lists that are not rows: a stack of separate cards (.ops-reqs) and the inline link lists (.w-links, .l-paths, .tkt-refs) are other shapes on purpose. */ .list-card { list-style: none; padding: 0; border: 1px solid var(--rule); border-radius: var(--r-2); overflow: hidden; } .list-card > li { border-bottom: 1px solid var(--rule); } .list-card > li:last-child { border-bottom: 0; } ul.tickets { margin: var(--sp-1) 0 0; } li.t-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: baseline; column-gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); } li.t-row:hover { background: var(--surface); } /* An SVG's baseline is its BOTTOM edge, so a round glyph baseline-aligned against text floats above the title's optical centre (measured: 2.4px at the 16/24 body scale). Nudged, not re-aligned, so a title that wraps still keeps its glyph on the first line. */ .t-glyph { width: 1.05em; height: 1.05em; position: relative; top: 0.15em; } /* State by hue, spending only tokens both themes already contrast-check: a fifth colour for one glyph would owe light and dark a new pair. */ .g-open { color: var(--add); } .g-review { color: var(--accent); } .g-done { color: var(--muted); } .g-rejected { color: var(--del); } .t-main { min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-2); } a.t-title { font-weight: 600; color: var(--fg); text-decoration: none; } a.t-title:hover { color: var(--accent); text-decoration: underline; } .t-sub { flex-basis: 100%; font-size: var(--fs-md); color: var(--muted); } /* A chip earns its box only when it DISTINGUISHES the row (see ticketRows): type and subsystem read as labels, severity and priority speak only when they are shouting. */ .chip { font-size: var(--fs-xs); line-height: 1.6; padding: 0 var(--sp-2); border-radius: var(--r-4); border: 1px solid var(--rule); background: var(--surface); color: var(--muted); white-space: nowrap; /* Centred on the flex line rather than sat on the title's baseline: a chip's box is taller than its text, so baseline alignment hangs it low. align-self needs no magic number to say "middle of the line". */ align-self: center; } .c-type { color: var(--fg); } /* The tracker a row came from, on the cross-repository listing only. Quieter than a chip and a link in its own right: it is provenance first, and a way to narrow to that tracker second. */ a.t-repo { font-size: var(--fs-xs); color: var(--muted); text-decoration: none; align-self: center; white-space: nowrap; } a.t-repo:hover { color: var(--accent); text-decoration: underline; } .c-hot { color: var(--del); border-color: var(--del); font-weight: 600; } /* The row's trailing cell: the conversation count and the state mark read as one group at the end of the row, and stay ONE grid child so neither can displace the other onto a line of its own. */ .t-tail { display: flex; align-items: baseline; gap: var(--sp-2); } /* Referenced-by rows read like the tracker's own list: a state glyph in the gutter, the name as the click target, the branch pair after. */ .tkt-refs { list-style: none; padding: 0; margin: 0 0 var(--sp-4); } .tkt-refs li { display: flex; align-items: baseline; gap: var(--sp-2); padding: var(--sp-1) 0; border-bottom: 1px solid var(--rule); } .tkt-refs li:last-child { border-bottom: 0; } .tkt-refs .ic { flex: none; } /* A verdict a reader can follow. The badge kept its own colours; the link only makes it clickable, because "fail" with nowhere to go is the state this page was in. */ a.ci-link { text-decoration: none; border: 0; } a.ci-link:hover .tl-badge { border-color: var(--fg); } /* The exceptions surface. Rows are collapsed to their signature line and open onto the trace, because the list is for scanning and the trace is for one row at a time. */ /* An exception row on a phone: the summary line WRAPS, so the signature, surface badge and message share the width that exists instead of forcing the page sideways. The message may be a whole zit stderr line — anywhere-wrap or it decides the layout. */ /* An issue row, Sentry's shape: a severity mark, then what broke in the page's own voice, then where and how often underneath, then the count on the right. The card and its hairlines come from .list-card, so this only draws what is particular to an issue. */ /* Loud on purpose. This is the one thing on the page that means the forge is not doing its job. */ .dep-down { border: 1px solid var(--danger); border-left-width: 3px; border-radius: var(--r-2); padding: var(--sp-2) var(--sp-4); margin: 0 0 var(--sp-4); background: color-mix(in srgb, var(--danger) 8%, transparent); } .dep-down p { margin: var(--sp-0) 0; } .dep-since { color: var(--muted); font-size: 0.9em; } /* A running step must not look like a queued one. The glyph turns, so "something is happening" is legible without reading a word — and it stops turning for anyone who asked the system for less motion. */ .runstep .d-ic.run { color: var(--accent); animation: ci-spin 1.4s linear infinite; } @keyframes ci-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .runstep .d-ic.run { animation: none; } } .runstep .d-ic.wait { opacity: 0.55; } /* Stalled is not running and not failed: nothing is wrong with the code, nothing is happening either. */ .tl-badge.ci-stalled { color: var(--warn); border-color: var(--warn); } .tl-badge.ci-stale { color: var(--warn); border-color: var(--warn); } .d-ic.stale { color: var(--warn); fill: var(--warn); } .l-stale { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em; } .l-stale form { margin: 0; } .exc-list { margin: var(--sp-1) 0 0; } .exc-item { padding: 0; } details.exc { padding: 0; } .exc summary { cursor: pointer; display: grid; list-style: none; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); } .exc summary::-webkit-details-marker { display: none; } .exc summary:hover { background: var(--surface); } .exc-lvl { width: 0.5rem; height: 0.5rem; border-radius: 50%; margin-top: var(--sp-2); flex: none; } /* Two levels, because there are two: something threw and nobody caught it, or something returned an error a page had to give up on. */ .exc-fatal { background: var(--danger); } .exc-error { background: var(--accent); } .exc-head { min-width: 0; } .exc-title { display: block; font-weight: 600; overflow-wrap: anywhere; } .exc-meta { display: block; margin-top: var(--sp-0); color: var(--muted); font-size: var(--fs-md); overflow-wrap: anywhere; } .exc-count { color: var(--muted); font-variant-numeric: tabular-nums; background: var(--code-bg); border-radius: var(--r-4); padding: 0 var(--sp-2); font-size: var(--fs-sm); } .exc .d-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-2); min-width: 0; } .exc .d-line strong { overflow-wrap: anywhere; } .exc .d-sub { display: block; margin-top: var(--sp-0); } .exc summary { cursor: pointer; } .exc-sig { color: var(--muted); font-size: 0.85em; } .exc-repo { font-size: 0.85em; } .exc-body { padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--rule); } /* What it costs, on the summary line: a reader who does not know what a lane is still learns what is not happening. */ .exc-why { display: block; margin-top: var(--sp-0); color: var(--fg); opacity: 0.85; } .exc-cost, .exc-fix { margin: var(--sp-1) 0; } /* The specifics as data, so an operator or an agent reads them without parsing a sentence. Two columns on anything but a phone. */ .exc-fields { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-0) var(--sp-3); margin: var(--sp-2) 0; padding: var(--sp-2) var(--sp-2); background: var(--code-bg); border-radius: var(--r-row); } .exc-fields dt { color: var(--muted); } .exc-fields dd { margin: 0; overflow-wrap: anywhere; } /* The shape beside the number: how often lately, against how often ever. A burst that ended and a fire that is still burning are the same count and different pictures. */ .exc-build { margin-left: var(--sp-2); } .exc-num { display: flex; align-items: center; gap: var(--sp-2); } .exc-spark { width: 4.5rem; height: 1.1rem; flex: none; fill: var(--muted); } .exc-rate { color: var(--fg); } .exc-msg { margin: 0 0 var(--sp-2); overflow-wrap: anywhere; } .exc-trace { max-height: 26rem; overflow: auto; font-size: var(--fs-sm); background: var(--code-bg); padding: var(--sp-2) var(--sp-3); border-radius: var(--r-row); white-space: pre; } /* A stack trace on a phone. The frames stay unwrapped — a wrapped Go trace is unreadable — so the panel scrolls in both axes instead, and gives back the side padding the lines need. Salvaged from the abandoned fleet-tabs-1, whose navigation went the other way but whose reading of this page was right. */ @media (max-width: 40rem) { .exc-body { padding: var(--sp-2) var(--sp-1); } .exc-trace { font-size: var(--fs-xs); max-height: 18rem; } } .t-conv { display: inline-flex; align-items: center; gap: var(--sp-1); font-size: var(--fs-md); color: var(--muted); text-decoration: none; } .t-conv:hover { color: var(--fg); } .t-none { color: var(--muted); } /* Open vs resolved by weight, not hue: the palette is four variables and a fifth for one badge would owe both themes a contrast check. */ .t-status { font-size: var(--fs-xs); padding: var(--sp-0) var(--sp-2); border-radius: var(--r-4); border: 1px solid var(--rule); } .st-open { color: var(--fg); border-color: var(--fg); font-weight: 600; } .st-done { color: var(--muted); } main pre { overflow-wrap: normal; } /* A quotation had NO rule at all, so it rendered as prose the browser happened to indent — indistinguishable from a paragraph someone had tabbed. Fossil marks a quote with a rule down its side and a quieter voice; the bar carries the author's hue where there is one, so quoting a person in a thread looks like that person. */ main blockquote { margin: var(--sp-4) 0; padding: var(--sp-1) 0 var(--sp-1) var(--sp-4); border-left: 3px solid oklch(var(--idn-l) var(--uc, var(--idn-c0)) var(--uh, 0) / 0.55); color: var(--muted); } main blockquote > :first-child { margin-top: 0; } main blockquote > :last-child { margin-bottom: 0; } main blockquote blockquote { margin-left: var(--sp-1); } /* RENDERED MARKDOWN. Every surface that prints markup.Render output — a doc or wiki page, a ticket body and its comments, a forum post, a landing event, the repository README — and nothing else, so the app's own tables, headings and code (.dt, .ops-t, .pref-matrix, table.tl, chrome headings) keep their own rules. Each rule below repeats the same :is() list. */ :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) { --md-note: var(--syn-attr); --md-tip: var(--ok); --md-important: var(--syn-num); --md-warning: var(--warn); --md-caution: var(--danger); } /* Headings: one distinct step per level, in em so it follows the surface's own text size. The markdown renderer demotes # to h2, so the h1 step only ever meets raw HTML. Space above outweighs space below, so a heading belongs to what follows it. */ :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) :is(h1, h2, h3, h4, h5, h6) { font-weight: 600; letter-spacing: normal; line-height: 1.3; margin: var(--sp-5) 0 var(--sp-2); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) h1 { font-size: 1.7em; font-weight: 700; margin-top: var(--sp-6); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) h2 { font-size: 1.4em; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) h3 { font-size: 1.17em; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) h4 { font-size: 1.07em; margin-top: var(--sp-4); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) :is(h5, h6) { font-size: 1em; margin-top: var(--sp-4); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) h6 { color: var(--muted); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) > :is(h1, h2, h3, h4, h5, h6):first-child { margin-top: 0; } /* Inline code is a chip that still wraps; kbd is a key. */ :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) :not(pre) > code { background: var(--code-bg); padding: 0.1em 0.35em; border-radius: var(--r-1); overflow-wrap: anywhere; -webkit-box-decoration-break: clone; box-decoration-break: clone; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) kbd { font-size: var(--fs-sm); padding: 0.05em 0.4em; border: 1px solid var(--rule); border-bottom-width: 2px; border-radius: var(--r-1); background: var(--bg); white-space: nowrap; } /* One rule, flush left, the full measure; the footnotes separator is the same element. */ :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) hr { border: 0; border-top: 1px solid var(--rule); height: 0; margin: var(--sp-5) 0; } /* Task items carry their own checkbox, so the bullet goes. */ :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) ul.md-tasks { list-style: none; padding-left: 0; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) :is(li.checked, li.unchecked, li.indeterminate) { list-style: none; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) li > input[type="checkbox"] { margin: 0 var(--sp-2) 0 0; vertical-align: -0.1em; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) li.checked { color: var(--muted); } /* Tables: a framed, striped block that hugs its content and scrolls inside its own box when wider than the column. */ :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table { width: fit-content; max-width: 100%; margin: var(--sp-4) 0 var(--sp-5); border: 1px solid var(--rule); border-radius: var(--r-row); border-collapse: separate; border-spacing: 0; font-variant-numeric: tabular-nums; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table th { font-size: var(--fs-sm); font-weight: 600; color: var(--fg); white-space: nowrap; vertical-align: bottom; background: color-mix(in srgb, var(--rule) 38%, transparent); border-bottom: 1px solid var(--rule); padding: var(--sp-2) var(--sp-3); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table td { padding: var(--sp-2) var(--sp-3); border: 0; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--rule) 16%, transparent); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table tr:first-child > :first-child { border-top-left-radius: calc(var(--r-row) - 1px); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table tr:first-child > :last-child { border-top-right-radius: calc(var(--r-row) - 1px); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table tr:last-child > :first-child { border-bottom-left-radius: calc(var(--r-row) - 1px); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table tr:last-child > :last-child { border-bottom-right-radius: calc(var(--r-row) - 1px); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table .md-short { white-space: nowrap; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table .md-num { text-align: right; } /* The GFM alignment survives the sanitizer as an align attribute; the global th, td left rule would otherwise beat it. */ :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table :is(th, td)[align="left"] { text-align: left; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table :is(th, td)[align="center"] { text-align: center; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table :is(th, td)[align="right"] { text-align: right; } @media (max-width: 640px) { :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) table .md-short { white-space: normal; } } /* GitHub alerts: a left bar and a faint wash in the kind's colour, its title in the colour. The colours are existing palette tokens, so both themes hold. */ :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) .md-alert { --k: var(--muted); margin: var(--sp-4) 0; padding: var(--sp-2) var(--sp-4); border-left: 4px solid var(--k); border-radius: 0 var(--r-1) var(--r-1) 0; background: color-mix(in srgb, var(--k) 8%, transparent); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) .md-alert > * { margin: 0 0 var(--sp-2); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) .md-alert > :last-child { margin-bottom: 0; } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) .md-alert-title { margin: 0 0 var(--sp-1); font-weight: 600; color: var(--k); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) .md-alert-note { --k: var(--md-note); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) .md-alert-tip { --k: var(--md-tip); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) .md-alert-important { --k: var(--md-important); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) .md-alert-warning { --k: var(--md-warning); } :is(.w-main article, .tkt-body, .tkt-comment, .f-post-b, .l-ev-body) .md-alert-caution { --k: var(--md-caution); } /* An @mention carries the same mark as the person's byline, so someone named in prose is recognisably the same person named anywhere else. */ a.uref .idn { width: 1em; height: 1em; vertical-align: -0.15em; margin-right: 0.15em; } /* Templates wrap their tables in .tw, which scrolls. Tables that arrive from RENDERED CONTENT — the API doc, a wiki page, a ticket body — have no wrapper to put them in, and a wide one pushed the whole page sideways (the /docs rules column overflowed a phone by 208px). Making such a table its own scroll container needs no wrapper and covers every source of them, including markup quarry did not generate. */ main :not(.tw) > table { display: block; overflow-x: auto; max-width: 100%; } .tw > table { display: table; } main .dt-wrap > table { display: table; } /* Except when it is stacking on a phone — see .dt-stack above, which has to outrank this to become a block. */ /* The highlight is a palette token like everything else. It was a pair of literals with the dark one behind an UNGUARDED media query, so a reader who chose light on a dark-mode device got the dark highlight under light text: a dark block with dark text on it. Tokens follow the three-state rule for free, which is the point of having them. */ mark { background: var(--mark); color: var(--fg); } /* Attachments (RFC 0016 §Reading). One section shape on four pages, and a smaller copy of the same cards inside a forum post. Every colour is a palette token, so both themes hold without a second set of rules. */ .attach { margin: var(--sp-5) 0 0; } .attach-h { display: flex; align-items: baseline; gap: var(--sp-2); } .attach-h h3 { margin: 0 0 var(--sp-2); } .attach-h .tools { margin-left: auto; font-size: var(--fs-sm); } .attach-h .n { color: var(--muted); font-weight: 400; } .attach-grid { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); } .attach-compact .attach-grid { gap: var(--sp-2); grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); } .attach-card { border: 1px solid var(--rule); border-radius: var(--r-row); overflow: hidden; background: var(--surface); } .attach-card:hover, .attach-card:focus-within { border-color: var(--muted); } /* One aspect for every kind, so a grid of mixed files is a grid and not a ragged pile. The preview is the anchor: the whole tile is the link. */ /* The whole tile is a link, so it has to be told not to LOOK like one: without this the code preview arrives underlined and in the accent colour, which reads as eight links rather than as a sample of a file. */ .attach-prev { position: relative; display: block; aspect-ratio: 4 / 3; max-width: 100%; overflow: hidden; background: var(--code-bg); border-bottom: 1px solid var(--rule); color: var(--fg); text-decoration: none; } .attach-prev :where(span, pre, code, div) { text-decoration: none; } .attach-prev img { width: 100%; height: 100%; object-fit: cover; display: block; } /* A picture the markup placed in a body (an attach: reference, a phone screenshot) never runs past its column. */ .f-post-b img, .wiki-body img, .tk-body img, .md img { max-width: 100%; height: auto; } /* A diagram (markup.Figure) scales down to its column, however narrow: the page never scrolls sideways for it and neither does its frame. Its small labels shrink with it, so tapping opens it alone at full size. */ .pikchr-fig { margin: var(--sp-4) 0; max-width: 100%; container-type: inline-size; } .pikchr-view { max-width: 100%; overflow-x: auto; overscroll-behavior-x: contain; background: linear-gradient(to right, var(--bg) 40%, transparent) left / 32px 100% no-repeat local, linear-gradient(to left, var(--bg) 40%, transparent) right / 32px 100% no-repeat local, linear-gradient(to right, color-mix(in srgb, var(--fg) 22%, transparent), transparent) left / 14px 100% no-repeat scroll, linear-gradient(to left, color-mix(in srgb, var(--fg) 22%, transparent), transparent) right / 14px 100% no-repeat scroll; } .pikchr-fig .pikchr-open { display: block; width: max-content; max-width: 100%; color: inherit; cursor: zoom-in; } .pikchr-view svg { display: block; width: var(--pk-w, auto); max-width: 100%; height: auto; } .pikchr-close { display: none; } .pikchr-fig:target { position: fixed; inset: 0; z-index: 60; margin: 0; overflow: auto; background: var(--bg); padding: var(--sp-6) var(--sp-4); } .pikchr-fig:target .pikchr-view { overflow: visible; background: none; } .pikchr-fig:target .pikchr-open { max-width: none; cursor: auto; margin: auto; } .pikchr-fig:target .pikchr-view svg { max-width: none; width: max(var(--pk-w, 100%), 100%); min-width: 0; } .pikchr-fig:target .pikchr-close { display: block; position: fixed; top: var(--sp-3); right: var(--sp-3); padding: var(--sp-1) var(--sp-3); border: 1px solid var(--rule); border-radius: var(--r-2); background: var(--bg); color: var(--fg); font-size: var(--fs-xl); line-height: 1; text-decoration: none; } /* The diagram's source, a click away. Two radio buttons choose what the frame shows, so it switches with scripts off; they are hidden from sight but not from the keyboard, and their labels in the bar are the control. Copy needs a script and is hidden without one. */ .pikchr-v { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } .pikchr-src { display: none; margin: 0; max-width: 100%; overflow-x: auto; padding: var(--sp-3); background: var(--code-block); border: 1px solid var(--rule); border-radius: var(--r-2); font-size: var(--fs-sm); } .pikchr-vs:checked ~ .pikchr-view { display: none; } .pikchr-vs:checked ~ .pikchr-src { display: block; } .pikchr-bar { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: nowrap; min-width: 0; margin-top: var(--sp-2); padding-top: var(--sp-2); border-top: 1px solid var(--rule); font-size: var(--fs-sm); color: var(--muted); } .pikchr-kind { font-family: var(--font-mono); } .pikchr-seg { display: inline-flex; flex: none; border: 1px solid var(--rule); border-radius: var(--r-2); overflow: hidden; } .pikchr-seg label { padding: var(--sp-0) var(--sp-2); cursor: pointer; } .pikchr-vd:checked ~ .pikchr-bar label[for$="-d"], .pikchr-vs:checked ~ .pikchr-bar label[for$="-s"] { background: var(--surface); color: var(--fg); font-weight: 500; } .pikchr-vd:focus-visible ~ .pikchr-bar label[for$="-d"], .pikchr-vs:focus-visible ~ .pikchr-bar label[for$="-s"] { outline: 2px solid var(--accent); outline-offset: -2px; } .pikchr-gap { flex: 1; } .pikchr-act { display: inline-flex; align-items: center; gap: var(--sp-1); flex: none; white-space: nowrap; padding: var(--sp-0) var(--sp-2); border: 1px solid transparent; border-radius: var(--r-2); background: none; color: var(--fg); font: inherit; cursor: pointer; text-decoration: none; } .pikchr-act:hover { border-color: var(--rule); background: var(--surface); } .pikchr-act .ic { width: 0.9rem; height: 0.9rem; } .pikchr-studio { color: var(--accent); } .pikchr-copy.done { color: var(--add); } .pikchr-ic-done, .pikchr-copy-done, .pikchr-copy.done .pikchr-ic-copy, .pikchr-copy.done .pikchr-copy-idle { display: none; } .pikchr-copy.done .pikchr-ic-done, .pikchr-copy.done .pikchr-copy-done { display: inline; } @media (scripting: none) { .pikchr-copy { display: none; } } /* The bar answers to the figure's own width, wherever the figure sits: one line at every width. The label drops first, then Copy and the studio link keep only their icons (and their accessible names). */ @container (max-width: 36rem) { .pikchr-kind, .pikchr-long { display: none; } } @container (max-width: 24rem) { .pikchr-copy .pikchr-lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } .pikchr-bar { gap: var(--sp-1); } } @container (max-width: 17rem) { .pikchr-studio .pikchr-lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } } .attach-card.attach-inline { grid-column: 1 / -1; } .attach-loading .attach-prev { display: grid; place-items: center; } .attach-spin { width: 1.4rem; height: 1.4rem; border-radius: 50%; border: 2px solid var(--rule); border-top-color: var(--accent); animation: attach-spin 0.9s linear infinite; } @keyframes attach-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .attach-spin { animation: none; border-top-color: var(--rule); } } .attach-look { padding: 0; border: 0; background: none; cursor: zoom-in; line-height: 0; } .attach-card.attach-inline .attach-info { padding: var(--sp-1) var(--sp-2); } /* The badge names what the bytes ARE — the sniffed format, or a line count. Top-left, out of the way of the picture's subject. */ .attach-kind { position: absolute; top: 0.3rem; left: 0.3rem; font: var(--fs-xs)/1.4 var(--font-mono); padding: var(--sp-0) var(--sp-1); border-radius: var(--r-1); background: var(--bg); color: var(--muted); border: 1px solid var(--rule); } /* A text preview fades out rather than cutting a line in half: the eight lines are a sample, and a hard edge reads as a broken render. */ /* The top padding clears the badge: the badge sits over the preview, and without room for it the first line of the file is behind it. */ .attach-code { font-size: var(--fs-xs); line-height: 1.35; padding: var(--sp-5) var(--sp-2) 0; overflow: hidden; height: 100%; -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 100%); mask-image: linear-gradient(to bottom, #000 55%, transparent 100%); } .attach-code pre { margin: 0; background: none; padding: 0; overflow: hidden; } .attach-ext { position: absolute; inset: 0; display: grid; place-items: center; font: 600 var(--fs-lg)/1 var(--font-mono); color: var(--muted); letter-spacing: 0.05em; } /* A media tile is a dark frame with a play mark — no poster frame exists without decoding the file, which the forge does not do. */ .attach-card[data-kind="media"] .attach-prev { background: #1d2021; } .attach-play { position: absolute; inset: 0; display: grid; place-items: center; } .attach-play::before { content: ""; width: 0; height: 0; border-left: 18px solid #f0ece4; border-top: 12px solid transparent; border-bottom: 12px solid transparent; opacity: 0.85; } /* Name and download share one row; the sub line spans both columns underneath, in the mono face so sizes line up down the grid. */ .attach-info { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0 var(--sp-2); padding: var(--sp-1) var(--sp-2) var(--sp-2); align-items: center; } .attach-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-sm); } .attach-missing { opacity: .6; cursor: default; } .attach-dl { color: var(--muted); display: inline-flex; } .attach-dl:hover { color: var(--accent); } .attach-sub { grid-column: 1 / -1; color: var(--muted); font: var(--fs-xs)/1.4 var(--font-mono); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .attach-sub .idn { width: 0.9em; height: 0.9em; vertical-align: -0.1em; } /* Phase 3: a held upload is a link and never a picture, so its tile is the placeholder with the reason over it; the buttons a holder of the letters sees sit under the sub line, small and quiet. */ .attach-held { cursor: pointer; } .attach-card[data-held="true"] .attach-prev { background: repeating-linear-gradient( -45deg, var(--code-bg), var(--code-bg) 6px, var(--surface) 6px, var(--surface) 12px); } .attach-act { grid-column: 1 / -1; display: flex; gap: var(--sp-2); margin-top: var(--sp-1); } .attach-inline-form { display: inline; } .attach-act button { font-size: var(--fs-xs); padding: var(--sp-0) var(--sp-2); } .attach-form { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: var(--sp-3) 0 0; font-size: var(--fs-md); } .attach-compact .attach-form { margin-top: var(--sp-2); font-size: var(--fs-sm); } .attach-pick input[type="file"] { max-width: 16rem; } .attach-progress { width: 10rem; } .attach-error { color: var(--danger); } .attach-limit { color: var(--muted); } .attach-unbound { margin: var(--sp-3) 0 0; color: var(--muted); font-size: var(--fs-md); } /* A drop target while a file is over it (attach.js): the section, or the composer a paste lands in. A dashed rule in the sheet's own colour, offset so it frames rather than crowds — quiet, and gone the moment the file leaves. */ [data-drop="over"] { outline: 2px dashed var(--muted); outline-offset: 4px; border-radius: var(--r-2); } /* The composer's pending list: what was pasted while writing — a thumbnail for a picture, the name and size for anything — each with a way to take it back before the post. A row the bucket refused at submit is marked so the error line's name has a face. */ .attach-composer { padding: var(--sp-2) var(--sp-2); border-top: 1px solid var(--rule); font-size: var(--fs-sm); color: var(--muted); } /* :not([hidden]) — a bare display rule outranks the attribute, and the empty bar showed under every composer before any upload began. */ .attach-composer .attach-progress:not([hidden]) { display: block; margin: var(--sp-1) 0; } .attach-choose { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin: var(--sp-1) 0 0; } .attach-choose .attach-pick { display: inline-flex; align-items: center; gap: var(--sp-1); cursor: pointer; border: 1px solid var(--rule); border-radius: var(--r-row); padding: var(--sp-1) var(--sp-2); font-size: var(--fs-sm); color: var(--fg); } .attach-choose .attach-pick:hover { border-color: var(--accent); color: var(--accent); } .attach-choose .attach-pick input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } .attach-choose .attach-pick .ic { width: 0.85rem; height: 0.85rem; } .attach-choose .attach-hint { color: var(--muted); } .attach-pending { list-style: none; margin: 0; padding: 0; } .attach-pending:empty { display: none; } .attach-pending li { display: flex; align-items: center; gap: var(--sp-2); margin: var(--sp-0) 0; color: var(--fg); } .attach-pending .attach-size { color: var(--muted); } .attach-pending button { font-size: var(--fs-xs); padding: var(--sp-0) var(--sp-2); } .attach-pending .attach-thumb { width: 4.5rem; height: 4.5rem; object-fit: cover; border-radius: var(--r-row); border: 1px solid var(--rule); } .attach-pending li[data-state="failed"] .attach-name { color: var(--danger); } .attach-held-list li { margin: var(--sp-1) 0; } /* The lightbox. Presentation only: with no script the same images are plain links to the inline view, which is what this dialog shows. */ .attach-lightbox { border: none; padding: 0; max-width: 94vw; max-height: 94vh; min-width: min(360px, 92vw); background: var(--bg); color: var(--fg); border-radius: var(--r-2); } .attach-lightbox::backdrop { background: #000000c4; } /* A tiny image must not collapse the dialog to its own bar: the frame keeps a floor and centres whatever lands in it. */ .attach-lightbox figure { margin: 0; display: grid; place-items: center; min-height: 140px; background: var(--code-bg); } .attach-lightbox img { display: block; max-width: 94vw; max-height: 80vh; object-fit: contain; } .attach-lb-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-2); border-top: 1px solid var(--rule); } .attach-lb-meta { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-sm); } .attach-lb-sub { color: var(--muted); font: var(--fs-xs)/1.4 var(--font-mono); } .attach-lb-bar button { background: none; border: 1px solid transparent; color: var(--fg); cursor: pointer; font-size: var(--fs-lg); line-height: 1; padding: var(--sp-0) var(--sp-2); border-radius: var(--r-1); } .attach-lb-bar button:hover { border-color: var(--rule); } .attach-lightbox img { transition: opacity 120ms ease; } @media (prefers-reduced-motion: reduce) { .attach-lightbox img { transition: none; } } /* The text viewer: a whole attachment, numbered and highlighted. The gutter classes are chroma's own; only the frame is ours. */ .atx-h { font-size: var(--fs-lg); word-break: break-all; } .atx { border: 1px solid var(--rule); border-radius: var(--r-row); overflow-x: auto; } .atx .chroma { margin: 0; } .atx pre { margin: 0; } .atx .lnt { color: var(--muted); user-select: none; }`