CI / Typecheck, test, build (pull_request) Successful in 11s
A comment that was a picture rendered as either a link or, for a Giphy, the literal text ``. On r/aww that is most of the thread. Reddit writes an inline image as a token rather than an address, in three shapes: `` for a Giphy, `` for a subreddit emote, and `` for an image uploaded straight to the comment. The useful part is that all three tokens are keys in that same comment's own `media_metadata`, so this is one lookup and not three special cases. Nothing in the adapter has to know what Giphy is. The fourth shape is someone pasting the address of a picture, which on Reddit is how most images in comments actually arrive -- 117 of them against 21 Giphys in the sample I scanned. Those are shown as pictures too, decided by the file extension. A link that is not to an image stays a link. Animated ones take `s.gif` over `s.mp4` even though the MP4 is several times smaller: a GIF moves on its own in an `<img>`, and an MP4 would need a player element with autoplay, loop and muted set, for something the size of a postage stamp. Everything goes through the `/m/` proxy, like all other media. Without that a comment thread would have the reader's browser fetch dozens of files straight from Reddit, which is the one thing this whole app exists to avoid. Placing the image is the renderer's job, not the Markdown parser's, because the proxy is a render-time concern and markdown.ts knows nothing about it -- so it takes an optional `ImageRenderer` and, without one, an image stays a link exactly as before. The parser also learned `![...]` proper: the link rule was matching from the `[` and stranding the `!` as text. Verified on r/aww/comments/171dxph, which carries one Giphy and 77 pasted images: 35 render on the first page, all 35 load, all 35 through the proxy, no upstream address reaches the page, no token is left unresolved, and nothing overflows the column or scrolls the page sideways. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
578 lines
15 KiB
CSS
578 lines
15 KiB
CSS
:root {
|
||
--bg: #f6f6f7;
|
||
--card: #ffffff;
|
||
--ink: #16181c;
|
||
--ink-dim: #5b6470;
|
||
--line: #e2e4e8;
|
||
--accent-ink: #ffffff;
|
||
--radius: 14px;
|
||
color-scheme: light dark;
|
||
}
|
||
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #0d0e11;
|
||
--card: #16181d;
|
||
--ink: #e9ebee;
|
||
--ink-dim: #949cab;
|
||
--line: #262a31;
|
||
}
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
padding: max(12px, env(safe-area-inset-top)) 12px calc(24px + env(safe-area-inset-bottom));
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||
-webkit-text-size-adjust: 100%;
|
||
}
|
||
|
||
main { max-width: 680px; margin: 0 auto; }
|
||
|
||
.post {
|
||
background: var(--card);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ---------- header ---------- */
|
||
|
||
.post__head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.badge { display: inline-flex; align-items: center; gap: 8px; flex: none; }
|
||
|
||
.badge__mark {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 8px;
|
||
background: var(--accent);
|
||
color: var(--accent-ink);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
|
||
.badge__label { font-size: 13px; font-weight: 600; letter-spacing: 0.01em; }
|
||
|
||
.who { display: flex; align-items: center; gap: 8px; min-width: 0; margin-left: auto; }
|
||
.who__avatar { width: 28px; height: 28px; border-radius: 50%; flex: none; }
|
||
.who__names { display: flex; flex-direction: column; min-width: 0; text-align: right; }
|
||
.who__name { font-size: 13px; font-weight: 600; }
|
||
.who__name, .who__handle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.who__handle { font-size: 12px; color: var(--ink-dim); }
|
||
|
||
/* ---------- text ---------- */
|
||
|
||
.text {
|
||
padding: 14px;
|
||
font-size: 16px;
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.text a { color: inherit; text-decoration-color: var(--ink-dim); text-underline-offset: 2px; }
|
||
|
||
/* ---------- media ---------- */
|
||
|
||
/* Card-coloured, not black: whatever space the media does not fill should
|
||
read as part of the page rather than as a border around it. */
|
||
.media { border-block: 1px solid var(--line); background: var(--card); }
|
||
.post__head + .media, .text + .media { border-top: 1px solid var(--line); }
|
||
|
||
.media__bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
background: var(--card);
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.media__count { font-size: 12px; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
|
||
|
||
.media__views { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
|
||
|
||
.media__view {
|
||
appearance: none;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--ink-dim);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
padding: 5px 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.media__view[aria-pressed="true"] { background: var(--ink); color: var(--card); }
|
||
|
||
/* Swipe is the default, and needs no JavaScript to work. */
|
||
.media__rail {
|
||
display: flex;
|
||
overflow-x: auto;
|
||
scroll-snap-type: x mandatory;
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
.media__rail::-webkit-scrollbar { display: none; }
|
||
|
||
.item {
|
||
flex: 0 0 100%;
|
||
scroll-snap-align: center;
|
||
scroll-snap-stop: always;
|
||
margin: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
position: relative;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Sized by its own proportions rather than stretched to the column: a
|
||
portrait video filling the width is mostly empty space either side of it.
|
||
The cap keeps any single item within the window. */
|
||
.item img, .item video {
|
||
display: block;
|
||
width: auto;
|
||
height: auto;
|
||
max-width: 100%;
|
||
max-height: 70vh;
|
||
max-height: 70dvh;
|
||
object-fit: contain;
|
||
background: transparent;
|
||
}
|
||
|
||
.item__note {
|
||
position: absolute;
|
||
inset-inline: 0;
|
||
bottom: 0;
|
||
padding: 4px 8px;
|
||
font-size: 11px;
|
||
color: #cfd4dc;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
}
|
||
|
||
.media[data-view="grid"] .media__rail {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||
gap: 2px;
|
||
overflow: visible;
|
||
}
|
||
|
||
.media[data-view="grid"] .item {
|
||
aspect-ratio: 1 / 1;
|
||
cursor: zoom-in;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/*
|
||
* Filled absolutely rather than with height: 100%. The cell's height comes
|
||
* from its aspect-ratio, which a percentage height will not resolve against,
|
||
* and `aspect-ratio: auto` means "use the image's own" rather than "ignore
|
||
* ratios" -- between them a portrait thumbnail ends up twice the height of
|
||
* its cell.
|
||
*/
|
||
.media[data-view="grid"] .item img,
|
||
.media[data-view="grid"] .item video {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
max-width: none;
|
||
max-height: none;
|
||
min-height: 0;
|
||
object-fit: cover;
|
||
aspect-ratio: auto !important;
|
||
}
|
||
.media[data-view="grid"] .item__note { display: none; }
|
||
|
||
/* ---------- footer ---------- */
|
||
|
||
.post__foot { padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
||
.post__foot time { font-size: 12px; color: var(--ink-dim); }
|
||
|
||
.original { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
|
||
|
||
.copy, .original__open {
|
||
appearance: none;
|
||
font: inherit;
|
||
font-size: 14px;
|
||
padding: 8px 14px;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
border: 1px solid var(--line);
|
||
}
|
||
|
||
.copy { background: var(--ink); color: var(--card); border-color: var(--ink); }
|
||
.copy[data-copied] { background: #1a7f4b; border-color: #1a7f4b; }
|
||
.original__open { background: transparent; color: var(--ink); }
|
||
|
||
.original__url { flex-basis: 100%; margin: 0; }
|
||
.original__url code {
|
||
font-size: 12px;
|
||
color: var(--ink-dim);
|
||
overflow-wrap: anywhere;
|
||
user-select: all;
|
||
}
|
||
|
||
/* ---------- error and index ---------- */
|
||
|
||
.error__heading { font-size: 18px; margin: 14px; }
|
||
.error__detail { margin: 0 14px 14px; color: var(--ink-dim); font-size: 14px; }
|
||
.post--error .post__head:empty { display: none; }
|
||
|
||
.post--index { padding: 18px; }
|
||
.index__title { margin: 0; font-size: 20px; }
|
||
.index__lede { color: var(--ink-dim); font-size: 14px; }
|
||
.index__table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.index__table th { text-align: left; color: var(--ink-dim); font-weight: 600; padding: 6px 8px 6px 0; }
|
||
.index__table td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; }
|
||
.index__table code { overflow-wrap: anywhere; }
|
||
|
||
/* ---------- verification puzzle ---------- */
|
||
|
||
.challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); }
|
||
|
||
.challenge__stage {
|
||
position: relative;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 8px;
|
||
background: #000;
|
||
border-block: 1px solid var(--line);
|
||
min-height: 160px;
|
||
}
|
||
|
||
.challenge__stage img {
|
||
max-width: 100%;
|
||
/* The puzzle is a drag target, so the browser must not claim the gesture
|
||
for scrolling or image-dragging. */
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-drag: none;
|
||
cursor: grab;
|
||
}
|
||
|
||
.challenge__stage img:active { cursor: grabbing; }
|
||
|
||
.challenge__status {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
margin: 0;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
color: #e9ebee;
|
||
background: rgba(0, 0, 0, 0.65);
|
||
}
|
||
|
||
/* ---------- threads ---------- */
|
||
|
||
.segment + .segment { border-top: 1px solid var(--line); }
|
||
|
||
/* A chain reads as one column of posts, so the media in each needs its own
|
||
top edge only when text sits above it. */
|
||
.segment .text + .media { border-top: 1px solid var(--line); }
|
||
|
||
.post[data-segments] .segment { position: relative; }
|
||
|
||
.segment__mark {
|
||
margin: 0;
|
||
padding: 8px 14px 0;
|
||
font-size: 11px;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-dim);
|
||
}
|
||
|
||
.post[data-segments] .segment[data-anchor="true"] {
|
||
/* The link may point at the middle of a thread, so say which one it was
|
||
without hiding the rest. */
|
||
background: color-mix(in srgb, var(--accent, #888) 7%, transparent);
|
||
}
|
||
|
||
/* ---------- fitting the window ---------- */
|
||
|
||
/*
|
||
* On a large screen a single post should be readable without scrolling to
|
||
* find it -- a video especially, which you can full-screen if you want it
|
||
* bigger.
|
||
*
|
||
* The cap is the window less the chrome around the media: header, caption and
|
||
* footer come to roughly 270-350px on the posts this serves. Deriving it
|
||
* instead of subtracting a constant is the obvious thing to try and does not
|
||
* work: the card is sized by its contents, so a percentage height inside it
|
||
* resolves to auto and clamps nothing.
|
||
*
|
||
* A thread is a column of several posts and cannot fit whatever we do, so it
|
||
* keeps the looser cap above and scrolls.
|
||
*/
|
||
@media (min-width: 700px) and (min-height: 560px) {
|
||
.post:not([data-segments]) .item img,
|
||
.post:not([data-segments]) .item video {
|
||
max-height: calc(100vh - 350px);
|
||
max-height: calc(100dvh - 350px);
|
||
/* Below that the media would be too small to be worth showing; better to
|
||
let a very long caption scroll. */
|
||
min-height: 220px;
|
||
}
|
||
}
|
||
|
||
/* ---------- a post with a headline ---------- */
|
||
|
||
/* Reddit is the one platform where the post has a title and the body is
|
||
optional, so the headline has to carry on its own. */
|
||
.text__title {
|
||
margin: 0 0 8px;
|
||
font-size: 19px;
|
||
line-height: 1.3;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.text__title:only-child { margin-bottom: 0; }
|
||
|
||
/* ---------- comments ---------- */
|
||
|
||
.thread {
|
||
margin-top: 12px;
|
||
background: var(--card);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
.thread__head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.thread__title { margin: 0; font-size: 15px; font-weight: 650; }
|
||
.thread__count { font-size: 12px; color: var(--ink-dim); }
|
||
|
||
.thread__toggle {
|
||
appearance: none;
|
||
margin-left: auto;
|
||
border: 1px solid var(--line);
|
||
background: transparent;
|
||
color: var(--ink-dim);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
padding: 4px 12px;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.thread__list { padding: 6px 14px 14px; }
|
||
|
||
.c { margin-top: 10px; }
|
||
|
||
/* The whole byline is the hit target for folding, so it wants to look like
|
||
one thing rather than a row of separate labels. */
|
||
.c__head {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 4px 8px;
|
||
font-size: 12px;
|
||
color: var(--ink-dim);
|
||
cursor: pointer;
|
||
list-style: none;
|
||
padding: 2px 0;
|
||
user-select: none;
|
||
}
|
||
|
||
.c__head::-webkit-details-marker { display: none; }
|
||
|
||
.c__head::before {
|
||
content: "–";
|
||
display: inline-grid;
|
||
place-items: center;
|
||
width: 15px;
|
||
height: 15px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
line-height: 1;
|
||
color: var(--ink-dim);
|
||
flex: none;
|
||
}
|
||
|
||
.c:not([open]) > .c__head::before { content: "+"; }
|
||
|
||
.c__author { font-weight: 600; color: var(--ink); }
|
||
.c__hidden { font-variant-numeric: tabular-nums; }
|
||
.c[open] > .c__head .c__hidden { display: none; }
|
||
|
||
.c__mark {
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.03em;
|
||
color: var(--accent-ink);
|
||
background: var(--ink-dim);
|
||
}
|
||
|
||
.c__mark--op { background: #ff4500; }
|
||
.c__mark--mod, .c__mark--admin { background: #1a7f4b; }
|
||
|
||
.c__body { font-size: 15px; overflow-wrap: anywhere; }
|
||
.c__body > :first-child { margin-top: 4px; }
|
||
.c__body > :last-child { margin-bottom: 0; }
|
||
.c__body p { margin: 0 0 8px; }
|
||
.c__body a { color: inherit; text-decoration-color: var(--ink-dim); text-underline-offset: 2px; }
|
||
.c__body ul, .c__body ol { margin: 0 0 8px; padding-left: 20px; }
|
||
|
||
.c__body blockquote {
|
||
margin: 0 0 8px;
|
||
padding-left: 10px;
|
||
border-left: 3px solid var(--line);
|
||
color: var(--ink-dim);
|
||
}
|
||
|
||
.c__body pre {
|
||
margin: 0 0 8px;
|
||
padding: 8px 10px;
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.c__body code { font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||
.c__body pre code { font-size: 12px; }
|
||
|
||
/* The rule down the left is what tells you where a subtree ends, which is
|
||
the thing a wall of replies otherwise makes you count indents for. */
|
||
.c__replies {
|
||
margin: 6px 0 0 7px;
|
||
padding-left: 11px;
|
||
border-left: 1px solid var(--line);
|
||
}
|
||
|
||
.c__more { margin: 8px 0 0; font-size: 12px; color: var(--ink-dim); }
|
||
|
||
.thread__more {
|
||
margin: 0;
|
||
padding: 12px 14px;
|
||
border-top: 1px solid var(--line);
|
||
font-size: 12px;
|
||
color: var(--ink-dim);
|
||
}
|
||
|
||
@media (max-width: 520px) {
|
||
.c__replies { margin-left: 4px; padding-left: 7px; }
|
||
}
|
||
|
||
/* ---------- a quoted post ---------- */
|
||
|
||
/*
|
||
* A post inside a post. Bordered and inset rather than merely indented,
|
||
* because the whole job of this block is to be unmistakably someone else's
|
||
* — an indent alone reads as a continuation of the words above it.
|
||
*/
|
||
.quote {
|
||
margin: 0 14px 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.text + .quote, .media + .quote { margin-top: 14px; }
|
||
|
||
.quote__who {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 2px 6px;
|
||
padding: 10px 12px 0;
|
||
font-size: 13px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.quote__avatar {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
flex: none;
|
||
align-self: center;
|
||
}
|
||
|
||
.quote__name { font-weight: 600; }
|
||
.quote__handle, .quote__who time { color: var(--ink-dim); font-size: 12px; }
|
||
.quote__text { padding: 6px 12px 10px; font-size: 15px; overflow-wrap: anywhere; }
|
||
.quote__text a { color: inherit; text-decoration-color: var(--ink-dim); text-underline-offset: 2px; }
|
||
|
||
/* Its own media sits flush in the block, the way the post's own does in the
|
||
card, so the two read as the same kind of thing at different sizes. */
|
||
.quote .media { border-bottom: 0; }
|
||
.quote__text + .media, .quote__who + .media { border-top: 1px solid var(--line); }
|
||
|
||
.quote__open {
|
||
display: block;
|
||
padding: 9px 12px;
|
||
border-top: 1px solid var(--line);
|
||
font-size: 12px;
|
||
color: var(--ink-dim);
|
||
text-decoration: none;
|
||
}
|
||
|
||
/*
|
||
* Deliberately smaller than the post's own media, and after the window-fitting
|
||
* rule above so it wins on source order at equal specificity. A quote is
|
||
* context for the post, and context that fills the screen has stopped being
|
||
* context.
|
||
*/
|
||
.post .quote .item img,
|
||
.post .quote .item video {
|
||
max-height: 40vh;
|
||
max-height: 40dvh;
|
||
min-height: 0;
|
||
}
|
||
|
||
/*
|
||
* A post that quotes another has two things to show, so its own media gives
|
||
* up the window-filling cap above. Left at full height the quote starts below
|
||
* the fold, which is the same as not having it — the whole reason it is here
|
||
* is that neither half reads without the other.
|
||
*/
|
||
.segment[data-quoted] .item img,
|
||
.segment[data-quoted] .item video {
|
||
max-height: 45vh;
|
||
max-height: 45dvh;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* A picture someone put in a comment. Capped hard: it is a remark inside a
|
||
conversation, not the thing the page is about. */
|
||
.c__img {
|
||
display: block;
|
||
max-width: min(100%, 420px);
|
||
max-height: 40vh;
|
||
max-height: 40dvh;
|
||
width: auto;
|
||
height: auto;
|
||
margin: 8px 0;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--ink) 4%, transparent);
|
||
}
|
||
|
||
/* A lone image is the whole comment more often than not, so it should not
|
||
carry a paragraph's worth of space above it as well as its own. */
|
||
.c__body > p:first-child > .c__img:first-child { margin-top: 2px; }
|