CI / Typecheck, test, build (pull_request) Successful in 28s
The StopTheMadness rules are indiscriminate, which is the point, but they catch the link on the way back out as well: in the browser they are installed in, "Open on <platform>" redirects straight back here. The one button meant to reach the app is the one that cannot. Handing the address to a different browser is the way past it, and the only way to do that from a page is that browser's own URL scheme. `/` gets a picker for which one; the choice lives in that browser's localStorage, because which browsers are installed is a fact about the device and the phone's answer is not the Mac's. The scheme table is its own module so a test can pin it -- every browser spells it differently, and Edge differs between macOS and iOS. Firefox has no scheme on macOS, so it is only offered on iOS, and an unknown or schemeless choice keeps the plain link rather than producing a dead one. The markup still carries the plain https address and the script swaps it afterwards, so nothing changes without JavaScript. Once a browser is chosen the button says which, since a scheme for a browser that is not installed opens nothing and the tap would otherwise be silent. Closes #7 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KF5YF3iZVKbezwALap8LYd
602 lines
16 KiB
CSS
602 lines
16 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; }
|
||
|
||
.picker {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px 10px;
|
||
margin-top: 18px;
|
||
padding-top: 14px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
/* The control ships hidden and is revealed by the script, and `display:
|
||
flex` above would otherwise beat the browser's own rule for [hidden]. */
|
||
.picker[hidden] { display: none; }
|
||
.picker__label { font-size: 13px; color: var(--ink-dim); }
|
||
.picker__select {
|
||
font: inherit;
|
||
font-size: 14px;
|
||
padding: 7px 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--line);
|
||
background: var(--card);
|
||
color: var(--ink);
|
||
}
|
||
.picker__note { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--ink-dim); }
|
||
|
||
/* ---------- 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; }
|