Files
antisocial/public/app.css
T
thatguygriffandClaude Opus 5 6325f0ff32
CI / Typecheck, test, build (pull_request) Successful in 9s
Add Reddit with its comment threads, and show quoted posts whole
Two changes. They share the `Segment` model, which is why they arrive
together.

## Reddit

A new adapter under /reddit, plus the thread beneath the post -- on Reddit the
conversation is usually the reason the link was shared, so a viewer that showed
only the post would be showing the wrong half.

The `.json` twin of a post URL is the post and the whole first page of comments
in one response, far better than anything the page gives up, so it is the only
layer that normally runs. Reddit refuses it to a browser it has never seen and
answers with a JavaScript challenge, which any ordinary navigation solves by
itself; the adapter navigates once and retries, and the cookie left behind
serves every later post. Below that, `shreddit-comment` elements are read from
the rendered page -- flat, each carrying its own `depth`, so `treeFromDepths`
rebuilds the nesting.

`Post.comments` is a tree rather than a flat list with depths, because folding
a comment has to take everything under it along and nesting is what makes that
free. Each comment renders as a `<details open>`, so collapsing works with the
stylesheet off and from the keyboard, and a collapsed one says how many replies
it is hiding. "Collapse all" is the only part that needs the script, so it
ships hidden and appears once the script has run. What sits behind a "load
more" is not fetched -- that is a second page and often a third -- but it is
counted and said out loud rather than quietly dropped.

Four things the payloads got wrong on the first try, each now with a fixture:
`fallback_url` is the video track alone whenever `has_audio` is true, so a post
with sound has to use `hls_url` and only a silent one gets the proxied MP4;
`scrubber_media_url` looks like a poster and is a second MP4 for the timeline
thumbnails, while the still is in `preview.images`; a gallery's pictures live
in `media_metadata` keyed and unordered, with their order only in
`gallery_data`; and `replies` is the string "" rather than an object when there
are none. Comment bodies are Markdown, rendered by a new render/markdown.ts
that escapes first and then puts back only the constructs we chose to support
-- never Reddit's own `body_html`, which would mean trusting markup a stranger
caused to be generated.

An app share link (/r/<sub>/s/<code>) is a plain 301, so one request told not
to follow it is enough. The permalink it resolves to is what the copy button
hands back, since an opaque share code is a tracking parameter by another name.

## Quoted posts

Both X and Bluesky lifted the quoted post's media out and showed it as the
quoter's own, dropping the quoted words and the quoted author entirely. A quote
of a photo post therefore rendered as somebody else's picture under the wrong
name with nothing to say so, and a quote that had a picture of its own dropped
the quoted one instead -- the two could never both appear. Half the quote posts
people share are someone answering a stranger and the other half are someone
continuing a thought from an earlier post; neither reads with only one side of
it on the page.

`Segment.quoted` now carries the whole thing -- author, words, pictures, time
and a link to it -- and renders as a post inside the post. Neither payload
carries a usable address for it: X has no permalink and it is rebuilt from the
handle and `id_str`, Bluesky has an `at://` URI nobody can open and it is
rebuilt from the handle and the record key. On Bluesky the record sits at
`embed.record` for a plain quote and at `embed.record.record` when the quoting
post has media of its own, and a quote can also point at a feed, a list or a
post since deleted, which arrive in the same slot under a different `$type` --
only `app.bsky.embed.record#viewRecord` is taken.

Two things about X's text, both visible on any post and not only a quote. It
arrives pre-escaped, so an ampersand someone typed was reaching the page as the
literal `&amp;`; it is decoded in the adapter, where the encoding comes from,
leaving the escape-on-the-way-out rule alone. And every link is a `t.co`, which
tells the reader nothing and routes them through X's click tracker to find out
-- `entities.urls` carries the real address alongside, so it is put back. The
shortlink X staples onto the end of a quote post is dropped rather than
expanded, since the post it points at is already on the page;
`display_text_range` is where that boundary is and it keeps a link the author
put there deliberately. Its indices are UTF-16 units into the escaped text, so
the slice happens before decoding and before expanding, and splitting to
codepoints first overshoots past an emoji -- checked against a post carrying
one.

A quote is context, and context that fills the screen has stopped being
context, so a segment carrying one gives up the window-filling cap on its own
media.

## Along the way

setupMedia only ever wired `document.querySelector('.media')`, the first rail
on the page. That was already wrong for a Bluesky or Threads chain with media
in more than one post, and a quoted carousel would have hit it too. It now runs
per rail.

## Verified

108 tests, typecheck and build clean. Resolved end to end against the live
platforms: Reddit self, gallery, video, link and share-link posts; X quotes
with no media, with media on the quoted side, and with media on both; Bluesky
quotes in both embed shapes, including a ten-post chain where every post quotes
a different account and all nine quotes come back under the right name.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
2026-08-27 11:45:40 -03:00

559 lines
14 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
: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;
}