/* kosms — feed stylesheet.
   Shared by feed_template.html (the design prototype) and feed.html (real
   records). Anything added here must work in both: the prototype proves the
   design, the data page proves the implementation, and they must never drift
   into two different designs. */

:root{
  --bg:#FFFFFF; --card:#FFFFFF; --border:#E8E6E1; --border-2:#CFC9BC;
  --text:#0A0A0A; --text-2:#4A4E52; --meta:#6F7C86; --dom:#8A95A0; --cmt:#5A6C82;
  --accent:#12428A; --accent-bg:#E4ECF7; --down:#C9302C;
  --code-bg:#F4F2EE; --callout-bg:#E4ECF7; --callout-fg:#0C2F63;
  --thead:#F6F5F2; --pos:#2E7D32; --neg:#B3261E;
  --field:#F1F0EB; --on-accent:#FFFFFF;
  --shadow:0 1px 2px rgba(20,18,14,.05),0 2px 8px rgba(20,18,14,.06);

  --clamp-max: 50rem;
  --fade-height: 2.4em;
  --fade-stop: 78%;

  --mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
  --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,"Helvetica Neue",Arial,sans-serif;
}
/* Dark mode is built on GitHub Dark's ground rules: the page is dark but not
   black, the card sits a clear step LIGHTER than the page with a visible border
   doing the separation, and text is genuinely bright. The old #000/#0A0A0C pair
   left cards barely distinguishable from the void and metadata near-invisible.
   The accent is GitHub's dark-mode purple, which lands inside the spec's own
   85–92% lightness band. */
[data-theme="dark"]{
  --bg:#0D1117; --card:#161B22; --border:#30363D; --border-2:#3D444D;
  --text:#E6EDF3; --text-2:#AFB8C2; --meta:#8D96A0; --dom:#6E7681; --cmt:#8B949E;
  --accent:#D2A8FF; --accent-bg:#2A2140; --down:#F85149;
  --code-bg:#1C2128; --callout-bg:#2A2140; --callout-fg:#D2A8FF;
  --thead:#1C2128; --pos:#3FB950; --neg:#F85149;
  --field:#21262D; --on-accent:#1C1030;
  --shadow:0 1px 2px rgba(0,0,0,.45),0 2px 10px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
/* 100%, not 16px. A hardcoded root size silently overrides the reader's own
   font-size preference — every rem in the design then scales from a number they
   did not choose. Ctrl-zoom worked either way; this is the other half of it. */
html{font-size:100%}
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased;line-height:1.4}
a{color:inherit}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
svg.i{width:1.125rem;height:1.125rem;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;display:block;flex-shrink:0}

.wrap{max-width:42.5rem;margin:0 auto;padding:0 .875rem}

/* The bar scrolls away with the page. It is a place to start from, not a thing
   to look at: once you are reading, the feed should own the whole window. */
.top{background:var(--bg);border-bottom:.5px solid var(--border)}
/* The bar sits on the feed's own column, so the mark lines up with the left edge
   of every card and the auth buttons with the right. The hairline still runs the
   full width — it is the edge of the bar, not of the content. */
.bar{max-width:42.5rem;margin:0 auto;padding:0 .875rem;display:flex;align-items:center;gap:.75rem;height:3.5rem}
.brand{display:flex;align-items:center;gap:.5rem;text-decoration:none;flex-shrink:0}
.mark{width:1.75rem;height:1.75rem;border-radius:50%;display:block;flex-shrink:0}
.logo{font-weight:700;letter-spacing:-.03em;font-size:1.2rem;color:var(--text)}

/* Search owns the middle and takes whatever the ends do not need. */
.srch{position:relative;flex:1;min-width:0;display:flex;align-items:center}
.srch .s-ic{position:absolute;left:.875rem;width:1.0625rem;height:1.0625rem;color:var(--meta);pointer-events:none}
#q{width:100%;height:2.5rem;padding:0 2.5rem 0 2.6rem;border-radius:999px;border:1px solid transparent;
  background:var(--field);color:var(--text);font:inherit;font-size:.875rem;outline:none;
  transition:background .14s,border-color .14s,box-shadow .14s}
#q::placeholder{color:var(--meta)}
#q:focus{background:var(--card);border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
.s-x{position:absolute;right:.75rem;display:flex;color:var(--meta)}
/* A class that sets display outranks the [hidden] attribute's UA rule. */
.s-x[hidden],.s-drop[hidden]{display:none}
.s-x svg.i{width:1rem;height:1rem}
.s-x:hover{color:var(--text)}
.s-drop{position:absolute;top:calc(100% + .375rem);left:0;right:0;z-index:6;
  background:var(--card);border:.5px solid var(--border);border-radius:.75rem;box-shadow:var(--shadow);
  padding:.25rem;max-height:20rem;overflow:auto}
.s-row{display:block;width:100%;padding:.5rem .625rem;border-radius:.5rem;text-align:left}
.s-row[aria-selected="true"],.s-row:hover{background:var(--accent-bg)}
.s-g{font-family:var(--mono);font-size:.8125rem;color:var(--accent);font-weight:500}
.s-none{padding:.625rem;font-size:.8125rem;color:var(--meta)}

.auth{display:flex;align-items:center;gap:.5rem;flex-shrink:0}
.tg{display:flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:999px;color:var(--meta)}
.tg:hover{color:var(--text);background:var(--field)}
.tg svg.i{width:1.125rem;height:1.125rem}
.btn-q,.btn-a{height:2.25rem;padding:0 1rem;border-radius:999px;font-size:.875rem;font-weight:500;white-space:nowrap}
.btn-q{color:var(--text)}
.btn-q:hover{background:var(--field)}
/* One filled control in the bar, in the one accent the design already has. */
.btn-a{background:var(--accent);color:var(--on-accent);padding:0 1.125rem}
.btn-a:hover{opacity:.9}
.pfp{display:flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:999px;
  background:var(--field);border:.5px solid var(--border-2);color:var(--meta)}
.pfp:hover{color:var(--text)}
.acct[hidden],.acct-menu[hidden],.auth .btn-q[hidden],.auth .btn-a[hidden]{display:none}

/* ——— Which half of the bar is drawn, before any script has run ———————
   The server sends the signed-out markup and /auth_status cannot answer until
   after the page is up, so a signed-in reader used to see "Log in" and then
   watch it become their avatar. data-auth is set from the last known session
   in <head>, ahead of first paint, and these rules override the `hidden`
   attributes the markup arrives with.

   They only ever run ahead of the answer. signedIn() sets the attribute and
   the `hidden` flags together once /auth_status replies, and from then on the
   two agree — so a wrong guess costs one correction, not a permanent lie.
   With no attribute at all (a first visit, or storage unavailable) nothing
   here applies: the markup's own signed-out state stands, which is the same
   behaviour as before. */
html[data-auth="in"] .acct[hidden]{display:flex}
html[data-auth="in"] .auth .btn-q,
html[data-auth="in"] .auth .btn-a{display:none}
html[data-auth="out"] .acct{display:none}
.acct{position:relative;display:flex}
/* The menu is the search dropdown's surface — card, hairline, same radius and
   shadow — because the bar should have one kind of panel, not two. It hangs
   from the avatar's right edge so it never runs off the window. */
.acct-menu{position:absolute;top:calc(100% + .375rem);right:0;z-index:12;min-width:11rem;
  background:var(--card);border:.5px solid var(--border);border-radius:.75rem;box-shadow:var(--shadow);padding:.25rem}
.acct-who{padding:.5rem .625rem .4375rem;border-bottom:.5px solid var(--border);margin-bottom:.25rem}
/* The username in the mono voice every /u/ and /g/ form uses elsewhere. */
.acct-u{font-family:var(--mono);font-size:.8125rem;color:var(--text);font-weight:500}
.acct-row{display:flex;align-items:center;gap:.625rem;width:100%;padding:.5rem .625rem;border-radius:.5rem;
  text-align:left;font-size:.875rem;color:var(--text)}
.acct-row:hover{background:var(--field)}
.acct-row svg.i{width:1rem;height:1rem;color:var(--meta)}
/* Log out is red — the same downvote red, the design's one sanctioned
   exception to a single accent per surface. It is the only leaving action
   in the product, and it should not be mistaken for Profile on the way past. */
.acct-row.danger,.acct-row.danger svg.i{color:var(--down)}
.acct-row.danger:hover{background:var(--accent-bg)}
[data-theme="dark"] .acct-row.danger:hover{background:#2A1517}

/* ——— Auth modal ———
   One dialog, two panes; only one accent-filled control per pane, same as the
   bar. Fields take the search field's treatment — --field at rest, card-coloured
   with an accent ring on focus — but rectangular: a pill is a place you type a
   fragment, a form field holds a whole answer. The scrim is translucent, unlike
   the image viewer's opaque backdrop: the viewer replaces the page, a form
   interrupts it, and keeping the feed dimly present says you are coming back. */
.aum{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:1rem;
  background:rgba(8,8,10,.6)}
.aum[hidden]{display:none}
.aud{background:var(--card);border:.5px solid var(--border);border-radius:.75rem;box-shadow:var(--shadow);
  width:min(22.5rem,100%);padding:1.5rem 1.5rem 1.375rem;position:relative}
.aud .au-x{position:absolute;top:.875rem;right:.875rem;display:flex;color:var(--meta);padding:.375rem;margin:-.375rem}
.aud .au-x:hover{color:var(--text)}
.aud h2{margin:0 0 1.125rem;font-size:1.09rem;font-weight:500;letter-spacing:-.016em}
.aud label{display:block;margin:0 0 .875rem}
.aud label span{display:block;margin-bottom:.3125rem;font-family:var(--mono);font-size:.75rem;letter-spacing:.02em;color:var(--meta)}
.aud input{width:100%;height:2.5rem;padding:0 .75rem;border-radius:.625rem;border:1px solid transparent;
  background:var(--field);color:var(--text);font:inherit;font-size:.875rem;outline:none;
  transition:background .14s,border-color .14s,box-shadow .14s}
.aud input:focus{background:var(--card);border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
.au-go{width:100%;height:2.5rem;border-radius:999px;background:var(--accent);color:var(--on-accent);
  font-size:.875rem;font-weight:500;margin-top:.25rem}
.au-go[disabled]{opacity:.55;cursor:default}
.au-go:hover{opacity:.9}
/* The server's reason, in the downvote red every error in the product uses.
   The one place a message is worth showing: a login that failed has a cause
   the reader can do something about. */
.au-err{margin:0 0 .875rem;padding:.5rem .75rem;border-radius:.5rem;
  background:var(--accent-bg);color:var(--down);font-size:.8125rem;line-height:1.4}
.au-err[hidden]{display:none}
[data-theme="dark"] .au-err{background:#2A1517}
.au-alt{margin:.875rem 0 0;font-size:.8125rem;color:var(--meta);text-align:center}
.au-alt button{color:var(--accent);font-weight:500;padding:.375rem .25rem;margin:-.375rem -.25rem}
.au-pane[hidden]{display:none}

/* Mobile: one door. The bar keeps only Log in; sign-up is reached from inside
   the login dialog. Two auth buttons on a phone bar spend width the search
   field needs, and the dialog already carries the cross-link. */
@media (hover:none) and (pointer:coarse){
  .auth .btn-q{display:none}
  /* Below 16px, iOS (and some Android WebViews) zoom the whole page in on
     focus — it assumes text that small is a mistake and "fixes" it for you.
     16px is the floor that turns the behaviour off, so search and the auth
     fields get it only where it is needed: a mouse never triggers this, so
     desktop keeps the smaller size the type scale actually wants. */
  #q,.aud input{font-size:1rem}
}

/* Below ~34rem the three pieces cannot share one line without starving the
   search field. Rather than shrink it to a stub, the bar wraps: identity and
   auth keep the top line, search takes a full line of its own. Still centred
   on the feed's column, still scrolling away with it. */
@media (max-width:34rem){
  .bar{flex-wrap:wrap;height:auto;padding-top:.5rem;padding-bottom:.5rem;row-gap:.5rem}
  .auth{margin-left:auto}
  .srch{order:3;flex-basis:100%}
}

/* ——— Feed states ———————————————————————————————————————————————————
   Loading, empty and error are the only things the feed can be other than
   posts. None of them is a card: a card is a post, and dressing a message as
   one invites a click that goes nowhere. Centred, generous, one icon, one
   line — and never an explanation of what went wrong internally. */
.state{display:flex;flex-direction:column;align-items:center;gap:1.125rem;
  padding:5rem 1.5rem;text-align:center}
.state svg{width:3.25rem;height:3.25rem;fill:none;stroke:currentColor;stroke-width:1.5;
  stroke-linecap:round;stroke-linejoin:round;display:block;color:var(--border-2)}
.state p{margin:0;font-size:1rem;font-weight:500;color:var(--meta)}

/* An error is the one place the feed raises its voice: downvote red, the
   design's one sanctioned second colour, and the only type in the feed set
   larger than a post title. */
.state.err svg{color:var(--down)}
.state.err p{color:var(--down);font-size:1.35rem;letter-spacing:-.02em;
  line-height:1.35;max-width:26rem}

/* The galaxy turning is the one piece of motion in the product. It earns the
   exception: something is genuinely in flight and nothing still can say so. */
.state svg.spin{stroke:none;width:2.75rem;height:2.75rem;animation:kosms-spin 1.5s linear infinite}
@keyframes kosms-spin{to{transform:rotate(1turn)}}
@media (prefers-reduced-motion:reduce){.state svg.spin{animation:none}}

/* Fetching the next page, and the end of the feed: both sit under the last
   card, so both are quieter than a first load. Neither is news. */
.state.more,.state.end{padding:2.75rem 1.5rem;gap:.875rem}
.state.end p{font-size:.875rem;font-weight:400;color:var(--dom)}

/* The next page arriving is a plain spinner — the convention everywhere for
   "more is on its way", and at the foot of a feed you are still reading it
   should be furniture, not a performance. The galaxy is for the first load,
   where the whole page is waiting and there is room for it. */
.state .dot{width:1.5rem;height:1.5rem;border-radius:50%;
  border:2px solid var(--border);border-top-color:var(--meta);
  animation:kosms-spin .7s linear infinite}
@media (prefers-reduced-motion:reduce){.state .dot{animation:none}}

.feed{display:flex;flex-direction:column;gap:.375rem;padding-top:.75rem;padding-bottom:3rem}

.card{display:flex;background:var(--card);border:.5px solid var(--border);border-radius:.75rem;box-shadow:var(--shadow);overflow:hidden}
.rail{width:2.875rem;flex-shrink:0;display:flex;flex-direction:column;align-items:center;gap:.25rem;padding:.9375rem 0}
.v{color:var(--border-2);display:flex;transition:color .12s}
.v.up:hover,.v.up.on{color:var(--accent)}
.v.dn:hover,.v.dn.on{color:var(--down)}
.sv{font-size:.78rem;font-weight:600;color:var(--text)}
.sv.up{color:var(--accent)} .sv.dn{color:var(--down)}
.bd{flex:1;min-width:0;padding:.9375rem 1.0625rem .8125rem 0}
.meta{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;margin:0 0 .625rem;font-family:var(--mono);font-size:.75rem;letter-spacing:.02em;color:var(--meta)}
.gl{color:var(--accent);background:var(--accent-bg);text-decoration:none;font-weight:500;font-size:.75rem;padding:.125rem .5625rem;border-radius:999px}
.tl{margin:0 0 .6875rem;font-size:1.09rem;font-weight:500;line-height:1.42;letter-spacing:-.016em;color:var(--text)}
.tl.last{margin-bottom:0}
.dm{display:inline-block;margin-left:.625rem;font-family:var(--mono);font-size:.8125rem;font-weight:400;letter-spacing:0;color:var(--dom);white-space:nowrap}
.bt{margin:0;font-size:.906rem;line-height:1.64;color:var(--text-2)}
/* In the prototype the author, the title and the comment count are plain
   text; in the real feed they are links. They must not LOOK like links: the
   design's rule is one accent-coloured element per card, and that is the
   group pill. */
.meta a:not(.gl),.tl a{color:inherit}
.meta a,.tl a,.cmt{text-decoration:none}
.cmt{display:flex;align-items:center;gap:.4375rem;margin:.625rem 0 0;font-size:.78rem;color:var(--cmt)}
.cmt svg.i{width:.9375rem;height:.9375rem}

/* ——— Mobile / tablet card footer ———
   On touch devices the rail is gone: the same score and arrows move into the
   footer, leading from the card's left edge, with the comment count after them.
   The switch is keyed to the DEVICE, not the viewport width — hover:none plus
   pointer:coarse is a phone or tablet. A width breakpoint was wrong twice over:
   desktop zoom inflates rem and made the rail vanish mid-zoom, and a narrowed
   desktop window is still a desktop with a mouse. A desktop viewport always
   has the rail, at any zoom; a touch screen never does. On desktop .mv stays
   display:none and .ft is an unstyled wrapper around the comment count. */
.mv{display:none}
.mvb{display:flex;align-items:center;justify-content:center;width:1.75rem;height:2.75rem;color:var(--meta);transition:color .12s}
.mvb.up:hover,.mvb.up.on{color:var(--accent)}
.mvb.dn:hover,.mvb.dn.on{color:var(--down)}
.oar{width:1.0625rem;height:1.0625rem;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linejoin:round;display:block}
/* Voting fills the silhouette — the glyph transforms rather than recolours,
   because on touch there is no hover and the press is the only feedback. */
.mvb.up.on .oar{fill:var(--accent)}
.mvb.dn.on .oar{fill:var(--down)}
@media (hover:none) and (pointer:coarse){
  .rail{display:none}
  .bd{padding-left:1.0625rem}
  .ft{display:flex;align-items:center;gap:1rem;margin-top:.625rem}
  .ft .cmt{margin:0}
  .mv{display:flex;align-items:center}
  /* 44px-tall targets bought with invisible padding; the row stays one text line. */
  .mvb{margin:-.75rem 0}
  .mv .sv{margin:0 .125rem;min-width:0;text-align:center}
}

/* ——— No `vh` in a media cap ————————————————————————————————————————————
   Browser zoom keeps rem constant in CSS px and SHRINKS the viewport, so a
   `min(rem, vh)` cap flips to its vh term the moment you zoom in — and the
   picture then shrinks while every word around it grows. On screen it looks
   like the media is the one thing that does not zoom.

   Measured on the real feed at 100 / 150 / 200%, column and title constant at
   680px and 17.44px throughout:

       gallery          440 -> 320 -> 240    (-45%)
       portrait image   576 -> 384 -> 288    (-50%)
       long-body clamp  680 -> 453 -> 340    (-50%)
       link thumbnail   200 -> 200 -> 160
       plain image      588 -> 588 -> 617    no vh term
       video, YouTube   constant             no vh term

   Every cap here is therefore rem only, which is the decision the portrait
   video player already reached on its own and for the same reason. The cost is
   that a tall picture can now run past the bottom of the window at high zoom
   and you scroll to the rest of it — the cheaper failure, and the same one the
   player accepts.
   ——————————————————————————————————————————————————————————————————————— */

/* Media sizing: one rule, three bands on source width. Below ~300px upscale to
   a 300px floor; between the floor and the column render at natural size,
   centred; above the column scale down to fill it. Never upscale past the
   floor, never distort, never crop to change shape.

   --nat is the source's own width. The prototype knows it from the SVG's
   viewBox and hardcodes 30rem; the data page measures it after the <img>
   decodes and sets the variable inline. Same rule, two ways of learning the
   number. */
.md{display:block;width:100%;height:auto;border-radius:.5rem}
.md-thumb{height:12.5rem}
.md-small{width:min(100%,var(--nat,30rem));margin:0 auto}
.md-port{width:auto;height:40rem;max-width:100%;margin:0 auto}
.md-tall{max-height:56rem;overflow:hidden;border-radius:.5rem}
.md-tall svg{display:block;width:100%;height:auto}

.lf{position:relative;max-height:var(--clamp-max);overflow:hidden}
.lf .fade{
  position:absolute;left:0;right:0;bottom:0;
  height:var(--fade-height);
  background:linear-gradient(to bottom,rgba(0,0,0,0),var(--card) var(--fade-stop));
  pointer-events:none;
  /* Drawn only when the body actually runs past the clamp — see clamp.js. A
     gradient over the last line of a post that fits is not a fade, it is a
     bug the reader can see. */
  display:none;
}
.lf.clamped .fade{display:block}
.rt{font-size:.906rem;line-height:1.64;color:var(--text-2)}
.rt p{margin:0 0 .875rem}
.rt h3{margin:1.125rem 0 .5rem;font-size:1rem;font-weight:500;color:var(--text)}
.rt h4{margin:1rem 0 .375rem;font-size:.875rem;font-weight:500;color:var(--text)}
.rt ul,.rt ol{margin:0 0 .875rem;padding-left:1.25rem}
.rt li{margin-bottom:.375rem}
.rt .co{margin:0 0 1rem;padding:.75rem .875rem;background:var(--callout-bg);border-radius:.5rem;font-size:.875rem;color:var(--callout-fg)}
.rt .co p{margin:0}
.rt table{width:100%;border-collapse:collapse;table-layout:fixed;font-size:.875rem;margin:0 0 1rem}
.rt th{text-align:left;font-weight:500;color:var(--text);padding:.5rem .625rem;background:var(--thead);border-bottom:.5px solid var(--border-2)}
.rt td{padding:.5rem .625rem;border-bottom:.5px solid var(--border)}
.rt tr:last-child td{border-bottom:0}
.rt .r{text-align:right}
.rt blockquote{margin:0 0 1rem;padding:.125rem 0 .125rem .875rem;border-left:2px solid var(--border-2);color:var(--text-2);font-style:italic}
.rt code{font-family:var(--mono);font-size:.8125rem;background:var(--code-bg);padding:.0625rem .3125rem;border-radius:.25rem}
.rt pre{margin:0 0 .875rem;padding:.75rem .875rem;background:var(--code-bg);border-radius:.5rem;font-family:var(--mono);font-size:.8125rem;line-height:1.55;overflow-x:auto;color:var(--text)}
.rt pre code{background:none;padding:0}
.rt a{color:var(--accent);text-decoration:none}
.k{color:#7F77DD}.s{color:#1D9E75}.n{color:#D85A30}.c{color:#888780}
/* Syntax brightens with the theme — the light-card hues sink on #161B22. */
[data-theme="dark"] .k{color:#D2A8FF}
[data-theme="dark"] .s{color:#7EE787}
[data-theme="dark"] .n{color:#FFA657}
[data-theme="dark"] .c{color:#8B949E}

.gal{position:relative;height:27.5rem;border-radius:.5rem;overflow:hidden;touch-action:pan-y pinch-zoom}
.gs{position:absolute;inset:0;display:none;align-items:center;justify-content:center}
.gs.on{display:flex}
.gs svg{display:block;max-width:100%;max-height:100%;border-radius:.5rem}
.ga{position:absolute;top:50%;transform:translateY(-50%);width:2.25rem;height:2.25rem;border-radius:999px;background:var(--card);border:.5px solid var(--border-2);display:flex;align-items:center;justify-content:center;color:var(--text)}
.ga.l{left:.75rem}.ga.r{right:.75rem}
.gd{position:absolute;left:0;right:0;bottom:.5rem;display:flex;justify-content:center;gap:.375rem}
.gd span{width:.375rem;height:.375rem;border-radius:50%;background:var(--border-2)}
.gd span.on{background:var(--text)}

.vpw{display:flex;justify-content:center}
.vp{position:relative;border-radius:.5rem;overflow:hidden;cursor:pointer;background:#000;container-type:inline-size}
.vp>svg.poster{display:block;width:100%;height:auto}
/* Portrait: the box IS the video's aspect ratio, so there is nothing around it.
   Width is in rem and capped by the column — exactly like the wide player, which
   is why it grows with the page under zoom instead of shrinking away from it.
   Height always falls out of the ratio, so the video can run past the bottom of
   the viewport; that is fine, scrolling reaches the controls. No vh term: a
   viewport cap here shrinks the WIDTH too, and the controls are laid out in rem. */
.vp.port{--ar:390/844;
  aspect-ratio:var(--ar);
  width:min(100%, 17.875rem);
  height:auto;background:transparent}
.vp.port>svg.poster{width:100%;height:100%}
.vp:fullscreen{border-radius:0;display:flex;align-items:center;justify-content:center;background:#000}
.vp:fullscreen>svg.poster{width:100%;height:100%;max-width:100%;max-height:100%}
.vp.port:fullscreen{width:100%;height:100%;aspect-ratio:auto}
/* pointer-events stay on even while hidden. Toggling them with visibility left
   the browser's hover state stale: the move that revealed the bar was hit-tested
   while it was still untouchable, so the control under your stationary pointer
   was not hovered and the volume panel would not open until you jiggled the
   mouse. The script ignores presses on a hidden bar instead. */
.ctl{position:absolute;left:0;right:0;bottom:0;height:9.375rem;opacity:0;pointer-events:auto;
  /* No transform. A lift on the way in slides every control out from under a
     stationary pointer, so a flick onto the speaker lands on nothing and the
     panel will not open until you move again. Controls hold still; only the
     opacity moves. Out: long and eased at both ends, with a beat of delay
     before it starts, so they drift away instead of blinking off. */
  transition:opacity .62s cubic-bezier(.4,0,.3,1) .12s;
  background:linear-gradient(to top,rgba(0,0,0,.42) 0%,rgba(0,0,0,.2) 40%,rgba(0,0,0,.06) 70%,rgba(0,0,0,0) 100%)}
/* In: slower than a snap but front-loaded, so it answers the mouse immediately
   and then settles. Asymmetry is the point — arriving is a response, leaving is not. */
.ctl.on{opacity:1;transition:opacity .34s cubic-bezier(.22,.7,.3,1)}
.low{position:absolute;left:0;right:0;bottom:0;padding-bottom:.5625rem}
.trk{height:.25rem;background:rgba(255,255,255,.34);position:relative;cursor:pointer;margin-bottom:.4375rem;transition:height .12s}
.vp:hover .trk{height:.3125rem}
.buf,.pl{position:absolute;left:0;top:0;bottom:0}
.buf{background:rgba(255,255,255,.58)} .pl{background:#E24B4A}
.kn{position:absolute;top:50%;transform:translate(-50%,-50%);width:.8125rem;height:.8125rem;border-radius:999px;background:#E24B4A}
.row{display:flex;align-items:center;gap:1.0625rem;padding:0 .875rem}
.ic{display:flex;align-items:center;justify-content:center;color:#fff;transition:opacity .12s}
.ic:hover{opacity:.8}
.ic svg.i{width:1.375rem;height:1.375rem;color:#fff}
.tm{font-size:.8125rem;color:#fff;font-variant-numeric:tabular-nums;white-space:nowrap}
.vol{position:relative;display:flex;align-items:center}
.vpan{position:absolute;bottom:100%;left:50%;transform:translate(-50%,0);
  /* The hit area is the panel, not the 4px track inside it. A 4px-wide target you
     have to travel to is not a target. And it starts at the top edge of the
     speaker — bottom:100%, not a fixed offset — so there is no dead gap on the
     way up where hover lapses and the panel vanishes before you reach it. */
  width:2.25rem;padding:.375rem 0 .5rem;
  display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;
  transition:opacity .4s cubic-bezier(.4,0,.3,1)}
.vol:hover .vpan,.vol.drag .vpan{opacity:1;pointer-events:auto;transition:opacity .2s cubic-bezier(.22,.7,.3,1)}
/* Keyboard focus opens it; a mouse click must not. :focus-within survived the
   click that muted, so the panel stayed pinned open until you clicked elsewhere.
   Kept in its own rule: one unsupported selector invalidates a whole selector
   list, and :has failing must not take the hover rule down with it. */
.vol:has(:focus-visible) .vpan{opacity:1;pointer-events:auto;transition:opacity .2s cubic-bezier(.22,.7,.3,1)}
/* Once a level is set the panel has said what it had to say. It stays shut until
   the pointer leaves the speaker and comes back — but never mid-drag. */
.vol.shut:not(.drag) .vpan{opacity:0;pointer-events:none}
.ctl:not(.on) .vpan{opacity:0;pointer-events:none}
.vt{display:block;width:.25rem;height:4.375rem;background:rgba(255,255,255,.34);border-radius:999px;position:relative;cursor:pointer}
.vf{position:absolute;left:0;right:0;bottom:0;background:#fff;border-radius:999px}
.vk{position:absolute;left:50%;transform:translate(-50%,50%);width:.75rem;height:.75rem;border-radius:999px;background:#fff}
@container (max-width:22rem){
  .row{gap:.8125rem;padding:0 .6875rem}
  .ic svg.i{width:1.25rem;height:1.25rem}
  .tm{font-size:.75rem}
}
/* Deep zoom: the portrait player is sized by 72vh, so its width keeps falling
   while the controls stay in rem. Shed the least load-bearing parts rather
   than let the row overflow and clip the clock. */
@container (max-width:15rem){
  .row{gap:.5625rem;padding:0 .5rem}
  .ic svg.i{width:1.125rem;height:1.125rem}
  .tm{font-size:.6875rem}
  .tot,.st{display:none}
  .vpan{width:2rem;padding:.3125rem 0 .4375rem}
  .vt{height:3.5rem}
}
@container (max-width:10rem){
  .row{gap:.4375rem;padding:0 .375rem}
  .tm{display:none}
}
/* Image viewer. The image gets the whole viewport; the chrome floats over it
   and carries its own shadow rather than sitting on a bar of its own. */
/* Opaque, not a veil. At 96% the feed's own gallery arrows still showed through
   behind the viewer's, and two sets of arrows is a question the reader should
   not have to answer. Dark in both themes: it is a surface for pictures. */
.lb{position:fixed;inset:0;z-index:40;background:#08080A}
.lb[hidden]{display:none}
/* margin:auto on the child, not centring on the flex container: a centred
   overflowing child is unreachable at the top and left once you scroll. */
/* touch-action:none hands every touch on the stage to kosms.js — swipe,
   pull-to-close, pan and pinch — so the browser neither scrolls nor zooms the
   page under the viewer. A wheel still scrolls a zoomed picture natively. */
.lb-stage{position:absolute;inset:0;display:flex;overflow:auto;overscroll-behavior:contain;touch-action:none}
.lb-img{margin:auto;line-height:0}
.lb-img svg,.lb-img img{display:block;-webkit-user-drag:none;user-select:none;-webkit-user-select:none}
/* No magnifier: a click no longer zooms. Zoomed, the picture is grabbed to pan. */
.lb.zoomed .lb-stage{cursor:grab}
.lb.zoomed .lb-stage:active{cursor:grabbing}
/* Pulling the picture away to close: the chrome steps aside so the gesture
   reads as the picture leaving, not the controls. */
.lb.pulling .lb-x,.lb.pulling .lb-nav,.lb.pulling .lb-dots{opacity:0;transition:opacity .15s}
.lb-x,.lb-nav{position:absolute;z-index:2;display:flex;align-items:center;justify-content:center;color:#fff;
  filter:drop-shadow(0 1px 2px rgba(0,0,0,.55)) drop-shadow(0 0 10px rgba(0,0,0,.4));transition:opacity .12s}
.lb-x:hover,.lb-nav:hover{opacity:.72}
.lb-x{top:.5rem;right:.5rem;width:2.75rem;height:2.75rem}
.lb-x svg.i{width:1.5rem;height:1.5rem;color:#fff}
.lb-nav{top:50%;transform:translateY(-50%);width:3rem;height:3rem}
.lb-nav svg.i{width:1.75rem;height:1.75rem;color:#fff}
.lb-nav.l{left:.25rem}.lb-nav.r{right:.25rem}
.lb-dots{position:absolute;left:0;right:0;bottom:.875rem;z-index:2;display:flex;justify-content:center;gap:.375rem}
.lb-dots span{width:.375rem;height:.375rem;border-radius:50%;background:rgba(255,255,255,.4);
  filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}
.lb-dots span.on{background:#fff}
.lb.one .lb-nav,.lb.one .lb-dots{display:none}
/* The only affordance in the feed, and it covers the whole clickable box rather
   than just the artwork inside it. A cursor is not a hover effect: nothing moves.
   The same `pointer` the video player uses — one cursor for everything in the
   feed you can click, so the feed answers with one voice. No magnifier
   anywhere: the viewer zooms by pinch and Ctrl+wheel, not by a click. */
.md.can,.md-tall.can,.gs.can,.md-tall.can svg,.gs.can svg{cursor:pointer}

@media (prefers-reduced-motion:reduce){*{transition:none!important}}


/* ——— Real media ———————————————————————————————————————————————————————
   Rules the data page needs and the prototype does not: its pictures are
   inline <svg> with a viewBox, the real feed's are <img>, <video> and a
   YouTube embed, which arrive with no dimensions and get measured after they
   decode. Only the YouTube facade is a new design decision.
   ——————————————————————————————————————————————————————————————————————— */

/* The card is `overflow:hidden`, so a picture that has not decoded yet has no
   height and the card collapses to a title. Reserving a band keeps the feed
   from jumping as images land; the band is released the moment the sizing
   pass runs, because from then on the real ratio is known. */
.md-wait{min-height:12rem;background:var(--field);border-radius:.5rem}
/* A source that 404s (an expired signed preview URL, a deleted image) leaves
   the post with what it still has — a title — rather than a broken glyph. */
.md-gone{display:none}

/* Tall visualisations crop from the top at full width rather than shrinking:
   a chart scaled to fit is a chart you cannot read. The prototype does this
   with a wrapper and overflow:hidden because an <svg> has no object-fit; an
   <img> does it itself, so there is no wrapper in the real markup. */
img.md-tall{
  width:100%;
  height:56rem;
  max-height:none;
  object-fit:cover;
  object-position:top center;
}

/* Link thumbnail: the same centre crop the prototype gets from
   preserveAspectRatio="xMidYMid slice". */
img.md-thumb{object-fit:cover;object-position:center}

/* Gallery slides are raster here, so they cannot be sized by a viewBox. Each
   one fits the stage by the same rules, inside the same fixed-height box. */
.gs img{display:block;max-width:100%;max-height:100%;border-radius:.5rem;object-fit:contain}

/* A step waiting on a large slide keeps the current picture up and, past
   120ms, says it is working. White with a shadow, like the player's controls,
   because it sits on a photograph of any colour. */
.gal.wait::after{content:"";position:absolute;left:50%;top:50%;width:1.75rem;height:1.75rem;
  margin:-.875rem 0 0 -.875rem;border-radius:50%;border:2px solid rgba(255,255,255,.35);
  border-top-color:#fff;filter:drop-shadow(0 0 2px rgba(0,0,0,.6));
  animation:kosms-spin .7s linear infinite;pointer-events:none}
@media (prefers-reduced-motion:reduce){.gal.wait::after{animation:none}}

/* The real player wraps a <video>. The poster is the element's own poster
   attribute, so there is no separate <svg class="poster"> to lay out — and
   with preload="none" the element has no intrinsic size until it is attached,
   so the box must carry the ratio itself or the card collapses to a line.
   .ar is the wide player (fills the column); .port is the phone one, whose
   width cap already lives in kosms.css. Never both: they disagree on width. */
/* Black, not transparent. `.vp.port` deliberately has a transparent
   background so the prototype's drawn portrait poster has nothing boxed
   around it — but a real <video> that has not painted a frame yet is
   transparent too, so the card's own white showed straight through it. That
   is the white rectangle with a control bar floating at the bottom of it.
   Set on the video rather than unsetting it on .vp.port, so the prototype
   keeps the treatment the design asked for. */
.vp>video{display:block;width:100%;height:auto;background:#000}
.vp.ar{width:100%;aspect-ratio:var(--ar)}
.vp.ar>video{height:100%;object-fit:contain}
.vp.port>video{width:100%;height:100%;object-fit:cover}
.vp:fullscreen>video{width:100%;height:100%;max-width:100%;max-height:100%;object-fit:contain}
/* No MSE, no native HLS: the poster stays and the controls stop pretending. */
.vp-unplayable .ctl{opacity:1}

/* A linked thumbnail is still a thumbnail: the anchor has to take the block
   so the image keeps its band. */
.bd a.mdl{display:block}

/* ——— YouTube ———
   A YouTube post is a YouTube player, not ours: the card carries the standard
   embed and nothing else. An earlier version put a poster and a play glyph in
   front of it to delay the iframe, but loading="lazy" on the iframe already
   does that, so the facade was a picture and a click handler bought for
   nothing. 16:9 is the box every modern YouTube video wants. */
.yt{position:relative;display:block;width:100%;aspect-ratio:16/9;
  border-radius:.5rem;overflow:hidden;background:#000}
.yt>iframe{display:block;width:100%;height:100%;border:0}

/* The out-link form, used when the page has no origin YouTube will accept —
   see the note in feed-data.js. hqdefault is 4:3 with the video letterboxed
   inside it, so it is cropped rather than shown with its bars. */
.yt>img{display:block;width:100%;height:100%;object-fit:cover}
.yt-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
  display:flex;align-items:center;justify-content:center;width:4rem;height:4rem;color:#fff;
  filter:drop-shadow(0 1px 2px rgba(0,0,0,.55)) drop-shadow(0 0 10px rgba(0,0,0,.4));
  transition:opacity .12s}
.yt-play svg.i{width:3rem;height:3rem;color:#fff}
.yt:hover .yt-play{opacity:.82}

/* Reddit markdown brings horizontal rules, which the synthetic posts never
   had. A hairline, in the border colour the cards already use. */
.rt hr{border:0;border-top:.5px solid var(--border);margin:1.25rem 0}
.rt img{max-width:100%;height:auto;border-radius:.5rem}
/* Reddit headings arrive as h1/h2 and are demoted to h3/h4 on render, so a
   post body can never out-weigh the post's own title. h5/h6 can still reach
   here through nested markdown. */
.rt h5,.rt h6{margin:1rem 0 .375rem;font-size:.875rem;font-weight:500;color:var(--text)}

/* ——— Mobile: media takes the screen ———————————————————————————————————
   On a phone the feed's column IS the screen, so every pixel the card spends
   on insets is a pixel the media does not get. Measured at 375px: media ran
   312px wide with 32px dead on each side, and the portrait player 286px with
   45px — a sixth of the screen given over to margin, on the one device with
   none to spare. Here the media spans the glass and the text stays inset.

   Two negative margins, because there are two insets to undo: .875rem of
   column padding on the card, then 1.0625rem of body padding on the media.
   The card keeps its border and rounding and stops a 6px gutter short of the
   bezel — the corners are what separate one post from the next.

   Keyed to touch AND width. Touch alone would do this to a tablet, whose
   column is centred in a much wider window and should stay a column. The
   width term is the point at which the column stops being narrower than the
   screen, which is the only condition under which "full width" means
   anything. The rem-in-a-media-query hazard that made the vote rail vanish
   mid-zoom does not reach here: desktop zoom cannot satisfy hover:none. */
@media (hover:none) and (pointer:coarse) and (max-width:42.5rem){
  /* Hairline gutter: the card runs to 6px (.375rem) short of the glass and
     keeps its border, shadow and rounding, so posts stay separate objects.
     .875rem of column padding minus .375rem of gutter = -.5rem. The media
     below still reaches the card's own edges; the card's overflow:hidden
     clips it to the rounding. Media width on a 390px phone: 378px (97%). */
  .feed>.card{margin-left:-.5rem;margin-right:-.5rem;border-radius:.875rem}

  /* Every kind of media, out to the card's edges. Nothing here sets height:
     width is the input and the ratio decides the rest.

     The list covers both pages' markup, which is not the same markup. The
     prototype wraps its tall <svg> in a bare .md-tall div and puts .vp
     straight in the body; the real feed has img.md and wraps the player in
     .vpw. Missing either spelling leaves that page's media inset while the
     card around it runs full width, which reads as a mistake rather than a
     difference. */
  .bd>.md,.bd>.mdl,.bd>.gal,.bd>.vpw,.bd>.vp,.bd>.yt,.bd>.md-wait,.bd>.md-tall{
    margin-left:-1.0625rem;margin-right:-1.0625rem;
    width:calc(100% + 2.125rem);max-width:none;border-radius:0}

  /* Full width, natural ratio, no exceptions — including the two bands that
     used to be sized by height. .md-port was the one that actually deformed
     its image: height:40rem with width:auto, so when max-width clamped the
     width to the column the height did not follow and every portrait photo
     was squashed. Measured 0.866 of its true ratio, a 13% squash. Giving
     width the lead and letting height fall out of it is the whole fix. */
  .bd>img.md{height:auto;max-height:none;object-fit:contain}
  .bd>img.md-small{width:calc(100% + 2.125rem);margin-left:-1.0625rem;margin-right:-1.0625rem}

  /* .md-tall still crops, and deliberately: at h/w >= 2.6 a full-height
     render is several screens of one post. It crops from the top, as before,
     just across the full width now. Cropping is not distortion — the pixels
     that survive are the shape they were drawn. */
  .bd>img.md-tall{height:56rem;object-fit:cover;object-position:top center}

  /* The gallery's fixed 27.5rem stage was a letterbox by construction: one
     height for every ratio, with the slide contained inside it. The stage now
     takes its ratio from the first slide (--gal-ar, set in kosms.js from that
     slide's own dimensions), so the image you open on fills it exactly.
     Later slides of a different shape still letterbox inside that box —
     unavoidable, since one stage cannot be two ratios, and sizing to the
     first is sizing to the one you actually see. */
  .gal{height:auto;aspect-ratio:var(--gal-ar,16/9)}

  /* The portrait player's rem width cap is what held it to 286px. On a phone
     the video and the screen are the same shape, which is the one case where
     a portrait video has nothing to prove: it simply fills. */
  .vp.port{width:100%}

  /* A link thumbnail keeps its band and its centre crop — it is a decoration
     on a link, not the post's media, and reddit's thumbnails are small enough
     that a full-width natural render would just be a blurry one. It gets the
     width, not the ratio. */
  .bd>.mdl>img.md-thumb{width:100%;height:12.5rem}
}

/* Fullscreen video: every touch is the player's, so a vertical swipe can
   close it. Two rules, not one list — a browser that does not know one of the
   pseudo-classes would throw the whole list away. */
.vp:fullscreen{touch-action:none}
.vp:-webkit-full-screen{touch-action:none}