CI / Typecheck, test, build (pull_request) Successful in 46s
People write in chains on both, and a link into one arrives pointing at a single post out of several. Showing only that post loses the thing that was being said. Other people's replies are a different matter: they are a conversation rather than the thing that was shared, and on a busy post there are hundreds of them. A Post is now a list of Segments instead of one body. Most platforms produce exactly one and say so through oneSegment(); the two that thread produce the whole chain, with isAnchor marking the post that was actually linked, which need not be the first. Bluesky walks parent upward and the author's own replies downward, stopping at the first post by anyone else. That needs depth and parentHeight on getPostThread, which drags the entire reply tree along -- a few hundred KB on a popular post -- because there is no way to ask the API for one author's branch. Threads is harder to read. The page ships the linked post, the author's follow-ups, other people's replies and a pile of unrelated recommendations, all as flat thread_items containers with no nesting to go on. What separates a follow-up from a stranger's reply is that a follow-up is the author replying to themselves; a reply from someone else carries the same reply_to_author with a different name on it. The first post of a chain replies to nothing at all, so it is reachable only by walking backwards from the post that answers it -- a test caught that, when linking the second post of a thread returned just the one post. Fixtures for both are real captures. The Bluesky one keeps two of every level's outside replies rather than pruning them away, because a filter is only worth testing against the thing it is supposed to exclude. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
275 lines
6.9 KiB
CSS
275 lines
6.9 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 ---------- */
|
|
|
|
.media { border-block: 1px solid var(--line); background: #000; }
|
|
.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;
|
|
}
|
|
|
|
.item img, .item video {
|
|
display: block;
|
|
width: 100%;
|
|
max-height: 78vh;
|
|
object-fit: contain;
|
|
background: #000;
|
|
}
|
|
|
|
.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 !important; cursor: zoom-in; }
|
|
.media[data-view="grid"] .item img,
|
|
.media[data-view="grid"] .item video { height: 100%; max-height: none; object-fit: cover; }
|
|
.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);
|
|
}
|