Files
gerbeur/src/App.css
2026-10-10 09:14:55 +00:00

7852 lines
176 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.
/* ── Markdown prose ── */
.md p {
margin: 0 0 0.85em;
}
.md p:last-child {
margin-bottom: 0;
}
.md a {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.md a:hover {
text-decoration: none;
}
/* A chat message's @everyone: a mention, in the accent, but no link. */
.md .md-everyone {
color: var(--color-accent);
font-style: normal;
font-weight: 700;
}
.md strong {
font-weight: 700;
}
.md em {
font-style: italic;
}
.md code {
font-family: var(--font-mono, monospace);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-xs);
padding: 0.1em 0.35em;
font-size: 0.88em;
}
.md pre {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 0.75rem 1rem;
overflow-x: auto;
scrollbar-width: thin;
margin: 0.6em 0;
}
.md pre code {
background: none;
border: none;
padding: 0;
}
.md ul,
.md ol {
padding-left: 1.5em;
margin: 0.4em 0;
}
.md li {
margin: 0.15em 0;
}
.md blockquote {
border-left: 3px solid var(--color-border);
margin: 0.5em 0;
padding: 0.2em 0.75em;
opacity: 0.75;
}
.md h1,
.md h2,
.md h3,
.md h4,
.md h5,
.md h6 {
margin: 0.6em 0 0.2em;
font-weight: 700;
line-height: 1.25;
}
/* Headings inside a comment or a chat message are emphasis within prose, not
page titles: the browser's 2em h1 dwarfed everything around it. */
.md h1 {
font-size: 1.3em;
}
.md h2 {
font-size: 1.18em;
}
.md h3 {
font-size: 1.06em;
}
.md h4,
.md h5,
.md h6 {
font-size: 1em;
}
.md > :first-child {
margin-top: 0;
}
.md hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 0.85em 0;
}
.md del {
opacity: 0.7;
}
/* GFM task lists: the checkbox stands in for the bullet. */
.md .contains-task-list {
list-style: none;
padding-left: 0.2em;
}
.md .task-list-item input {
margin: 0 0.4em 0 0;
vertical-align: -0.1em;
}
.md-table-wrap {
max-width: 100%;
overflow-x: auto;
scrollbar-width: thin;
margin: 0.6em 0;
}
.md table {
border-collapse: collapse;
font-size: 0.95em;
}
.md th,
.md td {
border: 1px solid var(--color-border);
padding: 0.3em 0.6em;
text-align: left;
}
.md th {
font-weight: 700;
background: var(--color-bg);
}
.md img {
max-width: 100%;
height: auto;
display: block;
}
/* Compact / card mode: strip vertical spacing */
.md--inline p,
.md--inline ul,
.md--inline ol,
.md--inline pre,
.md--inline blockquote {
margin: 0;
}
.md--inline li {
margin: 0;
}
.md--inline h1,
.md--inline h2,
.md--inline h3,
.md--inline h4,
.md--inline h5,
.md--inline h6 {
margin: 0;
font-size: inherit;
}
.md--inline hr,
.md--inline .md-table-wrap {
display: none;
}
.md--inline ul,
.md--inline ol {
padding-left: 1.2em;
}
/* ── Panels ──
Large surface blocks that have no border of their own. Themes that want
them outlined set --panel-ring (an inset shadow, so blocks that sit flush
against each other keep their spacing). */
.dump-post-header,
.dump-rich-content,
.form-page-header,
.form-panel,
.playlist-detail-header,
.comment-node-inner {
box-shadow: var(--panel-ring), var(--card-shadow);
}
/* ── Dump detail page ── */
.dump-detail {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dump-post-header {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
margin-bottom: 3px;
background: var(--color-surface);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
.dump-header-block {
display: flex;
align-items: center;
gap: 1rem;
}
.dump-header-left {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.dump-header-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.dump-title {
margin: 0;
font-size: var(--text-2xl);
font-weight: 700;
line-height: 1.25;
word-break: break-word;
}
.dump-title-view {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: var(--radius-md);
max-width: 100%;
/* Padding gives the hover box breathing room (incl. right of the pencil);
the matching negative margin keeps the title's visual position unchanged. */
padding: 0.1rem 0.45rem;
margin: -0.1rem -0.45rem;
}
.dump-title-view--editable {
cursor: pointer;
}
.dump-title-view--editable:hover {
background: var(--color-surface);
}
.dump-title-edit-btn {
font-size: var(--text-sm);
line-height: 1;
flex-shrink: 0;
color: var(--color-text-muted);
opacity: 0;
transition: opacity var(--dur-fast);
pointer-events: none;
}
.dump-title-view--editable:hover .dump-title-edit-btn,
.dump-title-view--editable:focus-visible .dump-title-edit-btn {
opacity: 1;
}
.dump-title-editor {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
/* Shared chrome for inline-edit inputs (the editors that live outside `.form`).
Each rule below adds only its own size / weight / width deltas. */
.dump-title-input,
.profile-email-input,
.username-field-input {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
outline: none;
transition: border-color var(--dur-slow);
}
.dump-title-input:focus,
.profile-email-input:focus,
.username-field-input:focus {
border-color: var(--color-accent);
}
/* A name and its "Rename" button, the hint and errors below. */
.username-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.username-field-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.username-field-input {
flex: 1 1 10rem;
min-width: 0;
padding: 0.4rem 0.6rem;
font-size: var(--text-base);
}
.dump-title-input {
flex: 1;
min-width: 12rem;
padding: 0.4rem 0.6rem;
font-size: var(--text-xl);
font-weight: 700;
}
/* Save and Cancel wrap together, under the input on a phone. */
.dump-title-actions {
display: flex;
gap: 0.5rem;
}
.dump-title-error {
width: 100%;
margin: 0;
font-size: var(--text-sm);
color: var(--color-danger);
}
/* On the narrowest viewports the fixed-width action column squeezes the title
into a sliver. Stack the block: title takes the full width on top, the
vote/playlist actions sit in a row beneath it. */
@media (max-width: 512px) {
.dump-header-block {
flex-direction: column;
align-items: stretch;
}
.dump-header-left {
flex-direction: row;
align-items: center;
order: 2;
}
.dump-header-right {
order: 1;
}
}
.dump-comment {
font-size: var(--text-base);
line-height: 1.72;
opacity: 0.85;
border-left: 3px solid var(--color-accent);
margin: 0;
padding: 0.5rem 1rem;
word-break: break-word;
}
.dump-rich-content {
padding: 1.25rem;
background: var(--color-surface);
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
}
.dump-url-link {
display: inline-block;
word-break: break-all;
padding: 1rem;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
width: 100%;
box-sizing: border-box;
}
.dump-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.dump-actions a {
color: var(--color-text);
text-decoration: none;
font-size: var(--text-md);
opacity: 0.7;
transition: opacity var(--dur-base), color var(--dur-base);
}
.dump-actions a:hover {
opacity: 1;
color: var(--color-accent);
}
.dump-actions a:last-child {
margin-left: auto;
}
/* ── Forms ── */
/* Unified form system: `.form` container + `.form-field` / `.form-label` /
`.form-hint` building blocks, shared by every form. */
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-label {
font-size: var(--text-sm);
font-weight: 600;
opacity: 0.6;
}
/* Label paired with an inline action (e.g. a "reset to default" button). */
.form-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.form-label-action {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
opacity: 0.6;
}
.form-label-action:hover:not(:disabled) {
opacity: 1;
}
.form-label-action:disabled {
opacity: 0.3;
cursor: default;
}
.form input,
.form textarea {
width: 100%;
box-sizing: border-box;
padding: 0.65rem 1rem;
border-radius: var(--radius-lg);
border: var(--border-width) solid var(--color-border);
background-color: var(--color-surface-sunken);
color: var(--color-text);
font-size: var(--text-base);
font-family: var(--font-body);
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
line-height: 1.5;
transition: border-color var(--dur-slow), box-shadow var(--dur-slow),
background-color var(--dur-slow);
outline: none;
}
/* A submit/secondary button placed as a direct child of `.form` (the auth
forms, which have no action row) spans the full width like the inputs.
Buttons inside `.form-actions` keep their intrinsic width. */
.form > .btn-primary,
.form > .btn-secondary,
.form > .btn-danger {
width: 100%;
}
.form textarea {
resize: vertical;
}
.form input:hover,
.form textarea:hover {
border-color: color-mix(
in srgb,
var(--color-accent) 45%,
var(--color-border) 55%
);
}
.form input:focus,
.form textarea:focus {
border-color: var(--color-accent);
background-color: var(--input-focus-bg);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 18%, transparent);
}
.form input:disabled,
.form textarea:disabled {
opacity: 0.6;
}
.form-hint {
font-size: var(--text-xs);
opacity: 0.7;
margin-top: 0.1rem;
}
.form-hint--error {
color: var(--color-error, #c0392b);
opacity: 1;
}
.form-success {
color: var(--color-success, green);
margin: 0;
}
/* A slug after the path it ends, as the page's address reads. The prefix
wraps above the input on a narrow screen rather than squeezing it. */
.slug-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.5rem;
}
.slug-field-prefix {
font-size: var(--text-sm);
opacity: 0.6;
overflow-wrap: anywhere;
}
.slug-field input {
flex: 1 1 12rem;
width: auto;
min-width: 0;
}
.dump-create-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dump-create-title-row input {
flex: 1;
}
.dump-create-title-edit-btn {
background: none;
border: none;
cursor: pointer;
font-size: var(--text-base);
line-height: 1;
color: var(--color-muted);
border-radius: var(--radius-sm);
padding: 0.4rem;
transition: color var(--dur-base);
}
.dump-create-title-edit-btn:hover {
color: var(--color-text);
}
.dump-create-title {
margin: 0;
font-size: var(--text-2xl);
font-weight: 700;
}
/* ── Create-dump modal ── */
/* A file dropped anywhere in the modal is accepted, so the whole body lights up. */
.dump-create--drag {
outline: 2px dashed var(--color-accent);
outline-offset: 6px;
border-radius: var(--radius-xl);
}
.dump-create-draft,
.dump-create-confirm {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.9rem;
padding: 0.55rem 0.75rem;
border-radius: var(--radius-lg);
font-size: var(--text-sm);
}
.dump-create-draft {
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-text-secondary);
}
.dump-create-draft-discard {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
font-weight: 600;
color: var(--color-link);
text-decoration: underline;
}
.dump-create-confirm {
background: var(--color-danger-bg);
border: 1px solid var(--color-danger);
}
.dump-create-confirm p {
margin: 0;
flex: 1 1 14rem;
}
.dump-create-confirm-actions {
display: flex;
gap: 0.5rem;
}
/* Not reached, or reached with nothing worth showing. */
.preview-note {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
/* ── "Already dumped" hint ── */
.dump-duplicate {
display: flex;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.dump-duplicate-icon {
font-size: var(--text-base);
line-height: 1.3;
opacity: 0.8;
}
.dump-duplicate-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.dump-duplicate-lead {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
}
.dump-duplicate-link {
font-size: var(--text-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dump-duplicate-more,
.dump-duplicate-hint {
margin: 0;
font-size: var(--text-xs);
color: var(--color-text-secondary);
}
/* ── Step indicator ── */
.dump-steps {
display: flex;
align-items: center;
gap: 0.5rem;
list-style: none;
margin: 0 0 1.1rem;
padding: 0;
}
.dump-step {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
/* Connector between steps — drawn on the gap, not on the labels. */
.dump-step + .dump-step::before {
content: "";
width: 1.1rem;
height: 1px;
background: var(--color-border-subtle);
flex-shrink: 0;
}
.dump-step-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.35rem;
height: 1.35rem;
flex-shrink: 0;
border-radius: var(--radius-full);
border: 1.5px solid var(--color-border-subtle);
font-size: var(--text-2xs);
font-weight: 700;
line-height: 1;
}
.dump-step-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dump-step--current {
color: var(--color-text);
font-weight: 600;
}
.dump-step--current .dump-step-marker {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-on-accent);
}
.dump-step--done {
color: var(--color-text-secondary);
}
.dump-step--done .dump-step-marker {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* Only the current step keeps its label once the modal gets narrow. */
@media (max-width: 30rem) {
.dump-step:not(.dump-step--current) .dump-step-label {
display: none;
}
}
/* ── Panels ── */
.dump-panel {
display: flex;
flex-direction: column;
gap: 1rem; /* matches .form, so panels and plain forms space identically */
animation: dump-panel-in 0.18s ease-out;
}
.dump-panel--back {
animation-name: dump-panel-in-back;
}
@keyframes dump-panel-in {
from {
opacity: 0;
transform: translateX(12px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes dump-panel-in-back {
from {
opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.dump-panel {
animation: none;
}
}
/* One line naming the thing the second panel's choices apply to. */
.dump-panel-recap {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Mode toggle — segmented control ── */
/* Too wide for its container, it wraps onto more rows, each filled. */
.visibility-toggle {
display: flex;
flex-wrap: wrap;
max-width: 100%;
background: var(--color-bg);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 3px;
gap: 2px;
}
.visibility-toggle button {
flex: 1;
padding: 0.3rem 0.9rem;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--color-text);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 600;
transition: background var(--dur-base), color var(--dur-base);
white-space: nowrap;
}
/* Many options make two even rows, whatever the width, so their fit doesn't
hang on the style's font or the language; Segmented sets the columns. */
.visibility-toggle--many {
display: grid;
grid-template-columns: repeat(var(--segmented-columns), minmax(0, 1fr));
}
.visibility-toggle button.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.visibility-toggle button:not(.active):hover {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
/* ── FileDropZone ── */
.fdz {
border: 2px dashed var(--color-border-subtle);
border-radius: var(--radius-xl);
background: var(--color-surface);
cursor: pointer;
transition: border-color var(--dur-base), background var(--dur-base);
outline: none;
}
.fdz:hover:not(.fdz--disabled):not(.fdz--filled),
.fdz:focus-visible {
border-color: var(--color-accent);
}
.fdz--drag {
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface));
}
.fdz--disabled {
cursor: not-allowed;
opacity: 0.55;
}
.fdz--filled {
cursor: default;
border-style: solid;
}
.fdz__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 2rem 1.5rem;
user-select: none;
}
.fdz__upload-icon {
width: 2rem;
height: 2rem;
opacity: 0.4;
margin-bottom: 0.2rem;
}
.fdz__hint {
margin: 0;
font-size: var(--text-md);
font-weight: 500;
}
.fdz__browse {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.fdz__browse-link {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.fdz__limit {
margin: 0.25rem 0 0;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.fdz__file {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
}
.fdz__file-icon {
font-size: var(--text-2xl);
flex-shrink: 0;
}
.fdz__file-meta {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
flex: 1;
}
.fdz__file-name {
font-size: var(--text-md);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fdz__file-size {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.fdz__clear {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: var(--text-xs);
padding: 0.3rem 0.5rem;
border-radius: var(--radius-sm);
transition: color var(--dur-fast), background var(--dur-fast);
line-height: 1;
}
.fdz__clear:hover {
color: var(--color-text);
background: var(--color-border-subtle);
}
/* ── Upload progress (second panel, while the file is being sent) ── */
.dump-upload-progress {
height: 4px;
border-radius: var(--radius-full);
background: var(--color-border-subtle);
overflow: hidden;
}
.dump-upload-progress-fill {
height: 100%;
width: 0;
border-radius: var(--radius-full);
background: var(--color-accent);
transition: width var(--dur-slow) ease-out;
}
/* Length unknown, or the body is sent and the server is still thinking. */
.dump-upload-progress--indeterminate .dump-upload-progress-fill {
width: 40%;
animation: dump-upload-slide 1.1s ease-in-out infinite;
}
@keyframes dump-upload-slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(250%);
}
}
@media (prefers-reduced-motion: reduce) {
.dump-upload-progress--indeterminate .dump-upload-progress-fill {
width: 100%;
animation: none;
opacity: 0.5;
}
}
/* ── Local file / URL preview (DumpCreate) ── */
.local-preview-image {
width: 100%;
max-height: 320px;
object-fit: contain;
border-radius: var(--radius-xl);
background: var(--color-bg);
display: block;
}
.preview-loading {
margin: 0;
font-size: var(--text-sm);
opacity: 0.5;
font-style: italic;
}
/* File previews (Dump page) */
.file-preview-image {
max-width: 100%;
border-radius: var(--radius-lg);
display: block;
}
.file-preview-play-btn {
position: relative;
width: 100%;
padding: 0;
border: none;
background: #000;
cursor: pointer;
display: block;
border-radius: var(--radius-lg);
overflow: hidden;
}
.file-preview-video-thumb {
width: 100%;
max-height: 480px;
object-fit: contain;
display: block;
}
.file-preview-play-btn .rich-content-play-overlay {
font-size: var(--text-display);
}
.audio-file-preview {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: color-mix(
in srgb,
var(--color-accent) 8%,
var(--color-surface) 92%
);
border: var(--border-width) solid var(--color-border);
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
}
/* When active, brighten the bars still to play: the outer path only, since the
played copy sits in the nested svg. */
.audio-file-preview--active .scrubber-peaks > path {
fill: color-mix(in srgb, var(--color-accent) 45%, var(--color-border) 55%);
}
/* The cover plays the file, as a card's thumbnail does, and wears the same
badge (see "Two targets, one card"). */
.audio-file-preview .rich-content-thumbnail-btn {
width: 96px;
height: 96px;
border-radius: var(--radius-md);
}
.audio-file-preview .rich-content-play-overlay {
width: 34px;
height: 34px;
font-size: var(--text-sm);
}
/* ── Video player ── */
.video-player {
width: 100%;
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
overflow: hidden;
border: var(--border-width) solid var(--color-border);
position: relative;
}
.video-player-video {
width: 100%;
max-height: 480px;
object-fit: contain;
display: block;
cursor: pointer;
}
.video-player:not(.video-player--controls-visible) .video-player-video {
cursor: none;
}
.video-player .video-player-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-radius: 0;
border: none;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.38));
color: var(--color-on-accent);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.video-player--controls-visible .video-player-controls {
opacity: 1;
pointer-events: auto;
}
.video-player-controls .audio-player-time {
color: var(--color-on-accent);
opacity: 0.85;
}
.video-player-controls .audio-player-vol-btn {
color: var(--color-on-accent);
}
.video-player-controls .audio-player-track {
background: rgba(255, 255, 255, 0.25);
}
.video-player-controls .audio-player-btn {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(4px);
}
.video-player-controls .audio-player-btn:hover {
background: rgba(255, 255, 255, 0.35);
}
/* ── Custom audio player ── */
.audio-player {
container-type: inline-size;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.6rem;
padding: 0.75rem 1rem;
background: color-mix(
in srgb,
var(--color-accent) 8%,
var(--color-surface) 92%
);
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
border: var(--border-width) solid var(--color-border);
width: 100%;
box-sizing: border-box;
min-width: 0;
overflow: hidden;
}
.audio-player-btn {
flex-shrink: 0;
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
border: none;
background: var(--color-accent);
color: var(--color-on-accent);
cursor: pointer;
display: grid;
place-items: center;
padding: 0.45rem;
transition: background var(--dur-base), transform var(--dur-fast);
}
.audio-player-btn:hover {
background: var(--color-accent-hover);
transform: scale(1.08);
}
.audio-player-btn svg {
width: 100%;
height: 100%;
display: block;
}
.audio-player-time {
font-size: var(--text-xs);
font-variant-numeric: tabular-nums;
opacity: 0.6;
flex-shrink: 0;
}
.audio-player-track {
position: relative;
flex: 1;
min-width: 48px;
height: 4px;
border-radius: var(--radius-xs);
background: color-mix(
in srgb,
var(--color-accent) 20%,
var(--color-border) 80%
);
}
/* ── Scrubber: the track MediaPlayer seeks on, a waveform or a plain bar ── */
.scrubber {
position: relative;
flex: 1;
min-width: 48px;
height: 48px;
display: flex;
align-items: center;
cursor: pointer;
/* A drag on a phone scrubs rather than scrolls the page. */
touch-action: none;
border-radius: var(--radius-xs);
}
.scrubber:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* A video's controls overlay the picture, so its track stays thin. */
.scrubber--slim {
height: 1rem;
}
.scrubber-peaks {
display: block;
width: 100%;
height: 100%;
}
.scrubber-peaks path {
fill: color-mix(in srgb, var(--color-accent) 25%, var(--color-border) 75%);
}
/* The same bars, clipped to the playhead by this nested svg's width. */
.scrubber-peaks-played {
overflow: hidden;
}
.scrubber-peaks-played path {
fill: var(--color-accent);
}
.scrubber-bar {
position: relative;
width: 100%;
height: 6px;
border-radius: var(--radius-xs);
overflow: hidden;
background: color-mix(
in srgb,
var(--color-accent) 12%,
var(--color-border) 88%
);
}
.scrubber--slim .scrubber-bar {
height: 4px;
}
.video-player-controls .scrubber-bar {
background: rgba(255, 255, 255, 0.25);
}
.scrubber-fill {
position: absolute;
inset: 0 auto 0 0;
background: var(--color-accent);
pointer-events: none;
}
.audio-player-track--volume {
flex: 1 1 100px;
max-width: 120px;
}
.audio-player-fill {
position: absolute;
inset: 0 auto 0 0;
background: var(--color-accent);
border-radius: var(--radius-xs);
pointer-events: none;
}
.audio-player-range {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.audio-player-volume {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 1;
min-width: 0;
}
.audio-player-vol-btn {
flex-shrink: 0;
width: 1.4rem;
height: 1.4rem;
border: none;
background: transparent;
color: var(--color-text);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
opacity: 0.55;
transition: opacity var(--dur-base);
}
.audio-player-vol-btn:hover {
opacity: 1;
}
.audio-player-vol-btn svg {
width: 100%;
height: 100%;
display: block;
}
/* A narrow bar keeps the mute button but drops the volume slider, which took
as much room as the scrubber (a phone's volume is in its buttons anyway). */
@container (max-width: 420px) {
.audio-player-track--volume {
display: none;
}
}
.file-preview-pdf {
width: 100%;
height: 80vh;
min-height: 600px;
border-radius: var(--radius-lg);
border: var(--border-width) solid var(--color-border);
display: block;
}
.file-download-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: 2px solid var(--color-accent);
border-radius: var(--radius-lg);
color: var(--color-accent);
text-decoration: none;
font-weight: 500;
transition: background var(--dur-base), color var(--dur-base);
}
.file-download-link:hover {
background: var(--color-accent);
color: var(--color-on-accent);
}
.dump-file-notice {
margin: 0 0 0.25rem;
font-size: var(--text-md);
opacity: 0.8;
}
.rich-content-card {
display: flex;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
overflow: hidden;
transition: border-color var(--dur-slow);
}
.rich-content-card:has(.rich-content-body:hover) {
border-color: var(--color-accent);
}
.rich-content-card--youtube {
border-color: var(--color-youtube);
}
.rich-content-card--youtube:has(.rich-content-body:hover) {
border-color: var(--color-youtube-hover);
}
.rich-content-card--youtube .rich-content-badge {
background: var(--color-youtube);
}
.rich-content-card--bandcamp {
border-color: var(--color-bandcamp);
}
.rich-content-card--bandcamp:has(.rich-content-body:hover) {
border-color: var(--color-bandcamp-hover);
}
.rich-content-card--bandcamp .rich-content-badge {
background: var(--color-bandcamp);
}
.rich-content-card--soundcloud {
border-color: var(--color-soundcloud);
}
.rich-content-card--soundcloud:has(.rich-content-body:hover) {
border-color: var(--color-soundcloud-hover);
}
.rich-content-card--soundcloud .rich-content-badge {
background: var(--color-soundcloud);
}
/* Brand badges sit on a saturated brand colour — use the fixed white
foreground rather than the accent-tuned `on-accent` of the generic badge. */
.rich-content-card--youtube .rich-content-badge,
.rich-content-card--bandcamp .rich-content-badge,
.rich-content-card--soundcloud .rich-content-badge {
color: var(--color-on-brand);
}
/* ── Global persistent player ── */
.global-player {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 2.5rem);
max-width: 860px;
z-index: var(--z-modal);
background: var(--color-surface);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-3);
animation: player-enter 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both;
transition: opacity var(--dur-slow) ease, box-shadow var(--dur-slow) ease;
}
.global-player--reduced {
opacity: 0.6;
box-shadow: var(--shadow-2);
}
.global-player--reduced:hover {
opacity: 1;
box-shadow: var(--shadow-3);
}
@keyframes player-enter {
from {
transform: translateX(-50%) translateY(1.5rem);
}
to {
transform: translateX(-50%) translateY(0);
}
}
.global-player-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
}
.global-player-title {
flex: 1;
font-weight: 600;
font-size: var(--text-md);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
a.global-player-title {
color: inherit;
text-decoration: none;
}
a.global-player-title:hover {
text-decoration: underline;
}
.global-player-body {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.3s ease;
}
.global-player--reduced .global-player-body {
grid-template-rows: 0fr;
}
.global-player-iframe-wrap,
.global-player-media-wrap {
overflow: hidden;
min-height: 0;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
/* Inside the global player the shell already draws the border — drop the
nested media player's own border so it isn't doubled up. */
.global-player .audio-player,
.global-player .video-player {
border: none;
}
.global-player iframe {
width: 100%;
border: none;
display: block;
}
.global-player-iframe--youtube {
aspect-ratio: 16/9;
max-height: 40vh;
}
.global-player-iframe--soundcloud {
height: 166px;
}
.global-player-iframe--bandcamp {
height: 120px;
}
.global-player.global-player--bandcamp {
max-width: 600px;
}
/* ── Global player: queue ── */
/* The title/artist pair takes the space the bare title used to claim. */
.global-player-heading {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.global-player-heading .global-player-title {
flex: none;
}
.global-player-subtitle {
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-player-artwork {
width: 36px;
height: 36px;
flex: none;
object-fit: cover;
border-radius: var(--radius-sm);
}
.global-player-transport {
display: flex;
align-items: center;
gap: 0.25rem;
flex: none;
}
.global-player-position {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.global-player-transport .btn--ghost:disabled {
opacity: 0.35;
cursor: default;
}
/* On a phone the controls would leave the title a few letters, so they take a
row of their own under it. */
@media (max-width: 600px) {
.global-player-header {
flex-wrap: wrap;
row-gap: 0.25rem;
}
.global-player-transport {
order: 1;
width: 100%;
justify-content: center;
}
}
.global-player-status {
margin: 0;
padding: 0 1rem 0.5rem;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
/* The video already claims most of the viewport, so its list stays shorter. */
.global-player--youtube .global-player-tracks {
max-height: 30vh;
}
.global-player-tracks {
list-style: none;
margin: 0;
padding: 0 0.5rem 0.5rem;
max-height: 40vh;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.global-player-track > button {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.35rem 0.5rem;
background: none;
border: none;
border-left: 3px solid transparent;
color: inherit;
font: inherit;
font-size: var(--text-xs);
text-align: left;
cursor: pointer;
}
.global-player-track > button:hover {
background: var(--color-surface-alt, rgba(127, 127, 127, 0.12));
}
/* A YouTube tracklist is only clickable once its player API answers; until
then the rows are a table of contents. */
.global-player-track > button:disabled {
cursor: default;
}
.global-player-track > button:disabled:hover {
background: none;
}
/* An accent border rather than a filled pill, so no theme has to un-round it. */
.global-player-track.is-current > button {
border-left-color: var(--color-accent);
font-weight: 600;
}
.global-player-track-num {
flex: none;
min-width: 1.5em;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.global-player-track-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-player-track-dur {
flex: none;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.feed-loading-more {
text-align: center;
padding: 1rem;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
body.has-player {
padding-bottom: var(--player-height, 0px);
}
/* ── Floating "new dump" button ───────────────────────────────────────
Revealed once the header scrolls out of view. Bottom-right by default, but
moves to the top-right while the global player is mounted (it owns the
bottom of the screen, full-width on mobile). Sits below the player and
modal backdrop (both z-index 1000). */
body {
/* Both floating buttons' size, which the page's end clearance reads too. */
--fab-size: 3.5rem;
}
.dump-fab {
position: fixed;
/* Sit just outside the right edge of the centered content lane — in the
margin beside it — rather than over the content or pinned to the viewport
edge. The lane width is `--fab-lane` (860px for most feeds; the journal
grid is wider, see below). Falls back to a 1.25rem viewport gutter once
that margin is too narrow to hold the button. */
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) -
1rem));
/* Anchored to the bottom-right by default, but expressed via `top` (rather
than `bottom`) so the jump to the top-right when a player mounts can
animate as a slide. `dvh` tracks the visible viewport on mobile; `vh` is
the fallback for browsers without it. */
top: calc(100vh - var(--fab-size) - 1.25rem - env(safe-area-inset-bottom,
0px));
top: calc(100dvh - var(--fab-size) - 1.25rem - env(safe-area-inset-bottom,
0px));
z-index: var(--z-fab);
width: var(--fab-size);
height: var(--fab-size);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
/* Circle by default; a theme can square it off by setting --fab-radius: 0. */
border-radius: var(--fab-radius, 50%);
background: var(--color-accent);
color: var(--color-on-accent);
cursor: pointer;
box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%,
transparent),
0 2px 6px rgba(0, 0, 0, 0.3);
/* Hidden state: faded out, nudged down, non-interactive. */
opacity: 0;
transform: translateY(1rem) scale(0.9);
pointer-events: none;
transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease,
background var(--dur-base) ease,
top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.dump-fab-icon {
width: 1.6rem;
height: 1.6rem;
}
.dump-fab--visible {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.dump-fab:hover {
background: var(--color-accent-hover);
transform: translateY(-2px) scale(1);
box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%,
transparent),
0 2px 6px rgba(0, 0, 0, 0.3);
}
.dump-fab:active {
transform: translateY(0) scale(0.96);
}
/* The journal tab lays out its masonry grid in a wider lane than the other
feeds, so the button must track that wider edge to stay aligned with it. */
body:has(.journal-grid) {
--fab-lane: 1180px;
}
/* The player occupies the bottom of the screen (full-width on mobile), so when
one is mounted slide the button up to the top-right corner to stay clear. */
body.has-player .dump-fab {
top: calc(1.25rem + env(safe-area-inset-top, 0px));
}
/* Reserve room at the end of the scroll so the buttons never hide the last
content when scrolled all the way down: the chat button stacks on the "+",
so the pair reaches two buttons and their gaps (2rem) up from the bottom. */
body.has-fab .page-content {
padding-bottom: calc(var(--fab-size) * 2 + 3rem);
}
/* Chat FAB — mirrors .dump-fab and stacks directly above it (the "+" dump
button keeps the bottom slot; chat sits one button-height up) so the two
never overlap. */
.chat-fab {
position: fixed;
/* Share the dump-fab's column. */
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) -
1rem));
/* One button-height + gap above the dump-fab's bottom-anchored resting spot. */
top: calc(100vh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom,
0px));
top: calc(100dvh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom,
0px));
z-index: var(--z-fab);
width: var(--fab-size);
height: var(--fab-size);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: var(--fab-radius, 50%);
background: var(--color-accent);
color: var(--color-on-accent);
cursor: pointer;
box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%,
transparent),
0 2px 6px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: translateY(1rem) scale(0.9);
pointer-events: none;
transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease,
background var(--dur-base) ease,
top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-fab .chat-fab-icon {
width: 1.6rem;
height: 1.6rem;
}
.chat-fab--visible {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.chat-fab:hover {
background: var(--color-accent-hover);
transform: translateY(-2px) scale(1);
box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%,
transparent),
0 2px 6px rgba(0, 0, 0, 0.3);
}
.chat-fab:active {
transform: translateY(0) scale(0.96);
}
/* Match the dump-fab's clearance shift when a player is mounted. */
body.has-player .chat-fab {
top: calc(1.25rem + env(safe-area-inset-top, 0px));
/* Stack below the dump-fab in the top-right corner to avoid overlap. */
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) -
1rem));
top: calc(1.25rem + var(--fab-size) + 0.75rem + env(safe-area-inset-top,
0px));
}
/* The badge sits on the circular FAB; nudge it onto the rim. */
.chat-fab .notification-badge {
top: 2px;
right: 2px;
box-shadow: 0 0 0 2px var(--color-accent);
}
.rich-content-thumbnail-btn {
position: relative;
padding: 0;
border: none;
background: none;
cursor: pointer;
display: flex;
align-self: stretch;
overflow: hidden;
flex-shrink: 0;
}
.rich-content-play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.25);
color: #fff;
font-size: var(--text-3xl);
transition: background var(--dur-slow) ease;
}
.rich-content-thumbnail-btn:hover .rich-content-play-overlay {
background: rgba(0, 0, 0, 0.5);
}
.rich-content-thumbnail {
width: 180px;
min-width: 180px;
object-fit: cover;
display: block;
}
@media (max-width: 512px) {
.rich-content-card {
flex-direction: column;
}
.rich-content-thumbnail {
width: 100%;
min-width: 0;
max-height: 180px;
}
}
.rich-content-body {
display: flex;
flex-direction: column;
text-decoration: none;
color: var(--color-text);
gap: 0.4rem;
padding: 0.9rem 1.1rem;
flex: 1;
min-width: 0;
}
/* Generic badge (unknown providers) — themed accent. Brand providers override
the background below and switch to the fixed white `on-brand` foreground. */
.rich-content-badge {
display: inline-block;
background: var(--color-accent);
color: var(--color-on-accent);
font-size: var(--text-2xs);
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.15rem 0.5rem;
border-radius: var(--radius-sm);
align-self: flex-start;
}
.rich-content-title {
margin: 0;
font-weight: 600;
font-size: var(--text-base);
line-height: 1.35;
overflow-wrap: break-word;
word-break: break-word;
}
/* Multi-line truncation boilerplate. Each owner sets its own
`-webkit-line-clamp` line count; this group carries the rest. */
.rich-content-description,
.dump-card-comment,
.playlist-card-description,
.journal-card-title,
.journal-card--image .journal-card-comment,
.journal-card-quote-body {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rich-content-description {
margin: 0;
font-size: var(--text-sm);
opacity: 0.75;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.rich-content-url {
font-size: var(--text-xs);
opacity: 0.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.dump-card--fading {
filter: brightness(0.65);
}
.dump-card--dismissing {
opacity: 0;
filter: brightness(0.25);
grid-template-rows: 0fr;
pointer-events: none;
}
.rich-content-compact {
display: flex;
align-items: center;
text-decoration: none;
flex-shrink: 0;
}
.rich-content-compact-thumbnail {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
display: block;
}
.rich-content-compact-icon {
font-size: var(--text-base);
opacity: 0.6;
}
.avatar-img {
object-fit: cover;
border: 2px solid var(--color-surface);
border-radius: 50%;
display: block;
}
.avatar-initials {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-accent);
color: var(--color-on-accent);
font-weight: 700;
user-select: none;
flex-shrink: 0;
}
/* ── Vote button ── */
.vote-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.6rem;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: border-color var(--dur-base), background var(--dur-base), color
var(--dur-base);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
}
.vote-btn:hover:not([aria-disabled="true"]) {
border-color: var(--color-accent);
color: var(--color-accent);
}
.vote-btn--active {
border-color: var(--color-accent);
background: var(--color-accent);
color: var(--color-on-accent);
}
.vote-btn--active:hover:not([aria-disabled="true"]) {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
color: var(--color-on-accent);
}
.vote-btn:focus {
outline: none;
}
.vote-btn:focus-visible {
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.vote-btn[aria-disabled="true"] {
opacity: 0.5;
cursor: default;
}
/* Clickable counts. Padding + matching negative margin enlarge the click
target without shifting layout — the box grows, its visual position holds. */
.vote-count-clickable,
.like-count-clickable {
display: inline-block;
cursor: pointer;
}
.vote-count-clickable:hover,
.like-count-clickable:hover {
text-decoration: underline;
}
.vote-count-clickable {
padding: 0.4rem;
margin: -0.4rem;
}
/* ── Dump OP line ── */
.dump-op {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
font-size: var(--text-sm);
opacity: 0.7;
}
.dump-op-link {
font-weight: 600;
color: var(--color-text);
text-decoration: none;
min-width: 0;
overflow-wrap: anywhere;
}
.dump-op-link:hover {
color: var(--color-accent);
}
/* ── Avatar edit overlay ── */
/* ── ImagePicker (reusable clickable cover image) ── */
.img-picker {
position: relative;
flex-shrink: 0;
cursor: pointer;
outline: none;
}
.img-picker-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border: 1px solid var(--color-border);
}
.img-picker-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--color-border);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-2xl);
opacity: 0.4;
box-sizing: border-box;
}
.img-picker-overlay {
position: absolute;
inset: 0;
background: var(--color-overlay);
color: var(--color-on-accent);
font-size: var(--text-lg);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--dur-base);
}
.img-picker:hover .img-picker-overlay,
.img-picker:focus-visible .img-picker-overlay {
opacity: 1;
}
.profile-avatar-wrapper {
position: relative;
flex-shrink: 0;
}
.avatar-change-overlay {
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--color-overlay);
color: var(--color-on-accent);
font-size: var(--text-lg);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
cursor: pointer;
transition: opacity var(--dur-base);
}
.profile-avatar-wrapper:hover .avatar-change-overlay {
opacity: 1;
}
/* ── Public profile page ── */
.profile-header {
display: flex;
align-items: flex-start;
gap: 1.5rem;
}
.profile-info {
flex: 1;
min-width: 0;
/* A long name or email breaks rather than running past a phone's edge. */
overflow-wrap: anywhere;
}
@media (max-width: 600px) {
.profile-header {
gap: 1rem;
}
/* zoom shrinks the layout box too, which Avatar's inline size pins at 128px,
so the info column keeps room for its buttons. */
.profile-avatar-wrapper {
zoom: 0.7;
}
}
.profile-tabs-scroller {
padding-bottom: 0.5rem;
border-bottom: var(--border-width) solid var(--color-border);
}
.profile-columns {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 900px) {
.profile-columns {
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: start;
}
}
/* ── Dense dump cards in the two-column profile view ──
A ~400px column is narrow enough for the narrow card's grid (the
`@container dump-card` rules), which places the thumb, the text and the
vote; placing them here as well, more specifically, broke that grid. This
only makes the card denser for a column of a dozen: a smaller thumb, the
title clamped to two lines and smaller type. Scoped to the width where the
columns split; narrower than that the feed is full-width again. */
@media (min-width: 900px) {
.profile-columns .dump-feed {
padding: 0.75rem 0 0;
gap: 0.5rem;
}
.profile-columns .dump-card-inner {
padding: 0.5rem 0.6rem;
}
.profile-columns .dump-card-preview {
width: 88px;
height: 50px;
}
.profile-columns .dump-card-title {
font-size: var(--text-md);
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.profile-columns .dump-card-date,
.profile-columns .dump-card-comment-count {
font-size: var(--text-2xs);
}
.profile-columns .category-links {
gap: 0.25rem;
}
.profile-columns .category-link {
padding: 0.05rem 0.4rem;
font-size: var(--text-2xs);
}
.profile-columns .vote-btn {
padding: 0.15rem 0.45rem;
font-size: var(--text-xs);
}
}
.profile-section ul {
list-style: none;
margin: 0;
padding: 0;
}
.empty-state {
margin: 0;
opacity: 0.5;
font-size: var(--text-md);
}
/* ── Profile section header (shared between bare h2 and wrapper div) ── */
.profile-section > h2,
.profile-section-header {
margin-bottom: 0.75rem;
border-bottom: var(--border-width) solid var(--color-border);
padding-bottom: 0.4rem;
}
.profile-section-title {
margin: 0;
}
.profile-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
/* Several actions in the title row, as a list's category menu and its
"New dump" button. */
.profile-section-actions {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
/* A list's order tabs, as the playlists', take a line of their own under the
title where the row has no room for both, rather than scroll beside it. */
.profile-section-header:has(.playlist-sort-tabs) {
flex-wrap: wrap;
row-gap: 0.25rem;
}
/* ── Profile (own) page ── */
.profile-username-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.profile-username {
margin: 0;
font-size: var(--text-xl);
font-weight: 600;
}
/* The `@host` of a user whose account lives on another instance. */
.profile-username-host {
font-weight: 400;
color: var(--color-text-muted);
overflow-wrap: anywhere;
}
.profile-home {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0 0 0.4rem;
}
/* Role badge — elevated roles only (see RoleChip) */
.profile-role-chip {
padding: 0.1rem 0.55rem;
border-radius: var(--radius-full);
background: var(--color-accent);
color: var(--color-on-accent);
font-size: var(--text-2xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.profile-role-chip--moderator {
background: var(--color-success);
}
.profile-header .follow-btn {
margin-top: 0.5rem;
}
.follow-error {
margin-left: 0.5rem;
font-size: 0.85rem;
color: var(--color-danger);
}
/* ── Profile description ── */
/* No margins of its own: the page's gap already sets it apart. */
.profile-description {
width: 100%;
}
.profile-description-view {
position: relative;
padding: 0.5rem 0.6rem;
border-radius: var(--radius-md);
font-size: var(--text-md);
}
.profile-description-view--editable {
cursor: pointer;
}
.profile-description-view--editable:hover {
background: var(--color-surface);
}
.profile-description-text {
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
line-height: 1.6;
}
.profile-description-empty {
color: var(--color-muted);
font-style: italic;
}
.profile-description-edit-btn {
position: absolute;
top: 0.55rem;
right: 0.5rem;
font-size: var(--text-sm);
color: var(--color-muted);
opacity: 0;
transition: opacity var(--dur-fast);
pointer-events: none;
}
.profile-description-view--editable:hover .profile-description-edit-btn,
.profile-description-view--editable:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-description-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profile-email-display {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 0.35rem;
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0.1rem 0 0.4rem;
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0.1rem 0.2rem;
margin-left: -0.2rem;
}
.profile-email-display:hover {
background: var(--color-surface);
}
.profile-email-display:hover .profile-description-edit-btn,
.profile-email-display:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-email-editor {
margin: 0.2rem 0 0.4rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.profile-email-input {
width: min(260px, 100%);
padding: 0.35rem 0.6rem;
border-radius: var(--radius-md);
font-size: var(--text-sm);
line-height: 1.5;
}
.profile-email-actions {
display: flex;
gap: 0.4rem;
}
.profile-description-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.profile-invited-by {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0.15rem 0 0.4rem;
}
.profile-invited-by--founding {
font-style: italic;
}
.profile-invited-by-link {
color: var(--color-text-muted);
text-decoration: none;
font-weight: 600;
}
.profile-invited-by-link:hover {
color: var(--color-accent);
}
/* Grid sized by the button; the invite panel below only fills that width
(width: 0 + min-width: 100% keeps it out of the sizing). */
.profile-own-actions {
display: grid;
width: fit-content;
max-width: 100%;
gap: 0.5rem;
margin-top: 0.5rem;
}
.invite-btn {
padding: 0.3rem 0.9rem;
border: 1.5px solid var(--color-accent);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-accent);
font-size: var(--text-sm);
font-family: inherit;
cursor: pointer;
transition: background var(--dur-base), color var(--dur-base);
}
.invite-btn:hover:not(:disabled),
.invite-btn[aria-expanded="true"] {
background: var(--color-accent);
color: var(--color-on-accent);
}
.invite-btn:disabled {
cursor: progress;
}
.invite-panel {
width: 0;
min-width: 100%;
box-sizing: border-box;
}
.invite-panel:not(.invite-panel--error) {
display: flex;
flex-direction: column;
padding: 0.5rem;
background: var(--color-surface);
border-radius: var(--radius-md);
}
.invite-panel-hint {
margin-top: 0.4rem;
font-size: var(--text-xs);
color: var(--color-text-muted);
text-align: center;
}
/* Link field and Copy button fused into one control. */
.invite-url {
min-width: 0;
padding: 0.35rem 0.6rem;
border: 1.5px solid var(--color-border);
border-bottom: none;
border-radius: var(--radius-md) var(--radius-md) 0 0;
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
font-size: var(--text-sm);
text-overflow: ellipsis;
}
.invite-url:focus {
outline: none;
border-color: var(--color-accent);
}
.invite-copy-btn {
padding: 0.35rem 0.9rem;
border: 1.5px solid var(--color-accent);
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: var(--color-accent);
color: var(--color-on-accent);
font-family: inherit;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast), border-color var(--dur-fast);
}
.invite-copy-btn:hover {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
.invite-copy-btn--done,
.invite-copy-btn--done:hover {
background: var(--color-success-bg);
border-color: var(--color-success);
color: var(--color-success);
}
/* ── Profile "View all" link ── */
.profile-view-all {
display: inline-block;
margin-top: 0.75rem;
font-size: var(--text-sm);
color: var(--color-text-muted);
text-decoration: none;
}
.profile-view-all:hover {
color: var(--color-accent);
}
/* ── Border-tinted button ── */
.btn-border {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem 0.9rem;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text);
font-size: var(--text-sm);
/* A <button> and a <Link> come out the same height. */
line-height: 1.5;
font-family: inherit;
cursor: pointer;
transition: border-color var(--dur-base), color var(--dur-base), background
var(--dur-base);
}
/* Neutral: the accent can be a red (Smooth's), which would read as danger. */
.btn-border:hover {
border-color: var(--color-text-muted);
color: var(--color-text);
background: color-mix(in srgb, var(--color-text) 6%, transparent);
}
/* Log out: the one bordered button that ends something, muted until hovered. */
.btn-border--danger {
color: var(--color-text-muted);
}
.btn-border--danger:hover {
border-color: var(--color-danger);
color: var(--color-danger);
background: transparent;
}
/* ── ErrorCard ── */
.error-card-wrap {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.error-card {
display: flex;
align-items: flex-start;
gap: 0.6rem;
background: var(--color-danger-bg);
color: var(--color-text);
padding: 0.7rem 1rem;
border-radius: var(--radius-lg);
font-size: var(--text-md);
}
.error-card-icon {
flex-shrink: 0;
line-height: 1.45;
}
.error-card-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.error-card-title {
margin: 0;
font-size: var(--text-md);
font-weight: 700;
}
.error-card-message {
margin: 0;
line-height: 1.45;
opacity: 0.85;
}
.error-card-actions {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.app-header-status {
margin: 1rem auto 0;
max-width: 860px;
width: calc(100% - 2rem);
padding: 0.75rem 1rem;
border: none;
border-radius: var(--radius-xl);
background: var(--color-danger-bg);
color: var(--color-text);
line-height: 1.5;
overflow-wrap: anywhere;
word-break: break-word;
}
.app-header-status strong {
font-size: var(--text-md);
}
.page-error-wrap {
margin: 2rem auto;
max-width: 480px;
width: 100%;
}
/* ── Shared layout ── */
.page-shell {
display: flex;
flex-direction: column;
}
.page-content {
width: 100%;
max-width: 860px;
margin: 0 auto;
--page-gutter: 1.25rem;
padding: 2rem var(--page-gutter) 0;
box-sizing: border-box;
animation: page-enter 0.2s ease both;
display: flex;
flex-direction: column;
gap: 2.5rem;
}
/* ── Menu pages ──
The pages the user menu and the bell lead to (settings, manage,
notifications) share one frame: a narrow column, a bare h1 with its icon
(PageHeader), then groups under a small ruled label (PageGroup) whose
content sits on surface cards. The edit forms cap their panels with
.form-page-header instead. */
.page-content--narrow {
max-width: calc(680px + 2.5rem);
gap: 2rem;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem 1rem;
}
.page-title {
margin: 0;
font-size: var(--text-3xl);
font-weight: 700;
line-height: 1.15;
display: flex;
align-items: center;
gap: 0.5rem;
overflow-wrap: anywhere;
}
.page-title-icon {
font-size: var(--text-xl);
}
.page-group-label {
font-size: var(--text-2xs);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-text-muted);
margin: 0 0 0.6rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.page-group-label::after {
content: "";
flex: 1;
height: 1px;
background: var(--color-border-subtle);
}
/* The notification items are cards of their own, with an unread bar. */
.page-card,
.notification-item {
background: var(--color-surface);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-subtle);
}
.page-card {
padding: 1rem;
}
/* A list of dump or playlist cards in a group fills the column, its edges in
line with the label's rule. */
.page-group .dump-feed {
padding: 0;
max-width: none;
}
/* A card of settings, its grid being the card or the card's one child (a list
such as GrantList), rules its rows apart; they keep the grid's columns, so
the labels still line up. Every such card's label column has one width, so
the controls line up down the page too, not only within a card; a longer
label wraps. */
.page-card.profile-appearance-grid,
.page-card > .profile-appearance-grid {
grid-template-columns: 10rem minmax(0, 1fr);
row-gap: 0;
}
.page-card.profile-appearance-grid,
.page-card:has(> .profile-appearance-grid) {
padding-block: 0;
}
.page-card > .profile-appearance-row,
.page-card > .profile-appearance-grid > .profile-appearance-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
justify-items: start;
padding-block: 0.75rem;
}
.page-card .profile-appearance-row + .profile-appearance-row {
border-top: 1px solid var(--color-border-subtle);
}
@media (max-width: 512px) {
.page-card > .profile-appearance-row,
.page-card > .profile-appearance-grid > .profile-appearance-row {
display: flex;
align-items: flex-start;
}
}
.page-content--centered {
align-items: center;
padding-top: 2.5rem;
}
.page-loading {
text-align: center;
opacity: 0.6;
padding: 4rem 1rem;
}
/* ── Shared header ── */
.app-header {
position: relative;
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1.25rem;
min-height: 3.75rem;
background: var(--header-bg);
border-bottom: var(--border-width) solid var(--color-border);
}
@media (min-width: 860px) {
.app-header--has-center {
display: grid;
grid-template-columns: auto 1fr auto;
}
.app-header--has-center .app-header-nav {
grid-column: 3;
}
}
.app-header-brand {
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: -0.01em;
flex-shrink: 0;
color: var(--color-text);
text-decoration: none;
}
.app-header-center {
display: none;
align-items: center;
justify-content: center;
min-width: 0;
overflow-x: clip;
overflow-y: visible;
}
@media (min-width: 860px) {
.app-header-center {
display: flex;
}
.app-header-center .search-bar-btn {
display: none;
}
}
/* Below-header search row — small viewports only. The reveal wrapper eases
its single grid track between 0fr and 1fr so the row (theme borders and
all) slides open and shut; the clip keeps the collapsed track at zero. */
.header-search-reveal {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows var(--dur-reveal) var(--ease-out),
opacity var(--dur-reveal) var(--ease-out);
}
.header-search-reveal--open {
grid-template-rows: 1fr;
opacity: 1;
}
.header-search-reveal-clip {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.header-search-reveal {
transition: none;
}
}
.header-search-below {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
background: var(--header-bg);
border-bottom: var(--border-width) solid var(--color-border);
}
.header-search-below .search-bar {
flex: 1;
}
.header-search-below .search-bar-input {
max-width: 100%;
}
@media (min-width: 860px) {
.header-search-reveal {
display: none;
}
}
/* When the search bar is expanded, immediately clear the rest of the center —
the expanded input needs the full column width */
.app-header-center:has(.search-bar--expanded) .index-presence,
.app-header-center:has(.search-bar--expanded) .feed-sort {
display: none;
}
.header-center-slot {
display: flex;
align-items: center;
gap: 0.75rem;
/* Lets the tabs scroll as a last resort, rather than the centred slot
overflowing the column and being clipped at both ends. */
min-width: 0;
}
.app-header-brand:hover {
color: var(--color-accent);
}
.app-header-nav {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.6rem;
margin-left: auto;
flex-shrink: 0;
flex-wrap: nowrap;
}
/* Normalize every header-nav control to one height (search / notifications /
avatar / + new were all different sizes) and centre their contents — flex +
line-height:1 keeps text and emoji glyphs vertically centred in the box. */
.app-header-nav button,
.app-header-user {
height: 2.25rem;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--text-md);
font-family: inherit;
line-height: 1;
}
.app-header-nav button {
padding: 0.35rem 0.85rem;
}
/* Amplitude of the header button's idle rocking, as a 0–1 scale. Registered
so it can be transitioned: the keyframes below multiply their offsets by it,
which turns a hard switch between still and rocking into a fade. */
@property --btn-bob {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.app-header-nav .btn-primary {
padding: 0.35rem 1rem;
}
/* `:not(:disabled)` earns this rule a third class's worth of weight, which it
needs: geocities and brutalist re-declare the whole `transition` shorthand
on every button at one class plus one element plus a :not, and neither list
mentions `translate`, so without it their lists win and the lift lands
instantly instead of easing. Nothing here applies to a disabled button
anyway — it can't be hovered. */
.app-header-nav .btn-primary:not(:disabled) {
/* Pivot at the bottom edge, so the rock below reads as a head on a neck
rather than as the whole button spinning about its middle. */
transform-origin: 50% 100%;
/* Both motions are declared here rather than on :hover, and held paused, so
that leaving the button fades the rocking out instead of cutting it: the
amplitude eases to zero under an animation that is merely frozen. Paused
also means an untouched button isn't animating a thing. */
animation:
btn-hover-tilt 2.6s ease-in-out infinite,
btn-hover-float 3.4s ease-in-out infinite;
animation-play-state: paused;
/* The lift is carried by `translate` rather than `transform` so the rocking,
which needs `transform` and `rotate` to itself, composes with it instead
of replacing it. It eases in and out rather than out alone — a fast start
is what made the button feel like it snapped up to meet the cursor. */
transition:
background var(--dur-base),
color var(--dur-base),
border-color var(--dur-base),
box-shadow var(--dur-base),
translate var(--dur-lift) var(--ease-in-out),
--btn-bob var(--dur-bob-fade) var(--ease-in-out);
}
.app-header-nav .btn-primary:hover:not(:disabled) {
translate: 0 -2px;
/* The rocking starts the moment the lift does, but from nothing, and takes
longer than the lift to reach full throw — so the button is already
drifting as it settles, and never visibly changes gear. */
--btn-bob: 1;
animation-play-state: running;
}
/* Pressing puts it back down flat — the base :active transform can only take
effect once the animation stops claiming `transform`. */
.app-header-nav .btn-primary:active:not(:disabled) {
translate: none;
animation: none;
--btn-bob: 0;
/* The press has to land at once — at the lift's own easing it would float
back down under the finger. */
transition-duration: var(--dur-fast);
}
/* The rock is the motion; the float underneath it is small and on a period
that doesn't divide the rock's, so the two never line up the same way twice
and it never settles into a metronome. */
@keyframes btn-hover-tilt {
0%,
100% {
rotate: 0deg;
}
25% {
rotate: calc(var(--btn-bob, 0) * 2.5deg);
}
75% {
rotate: calc(var(--btn-bob, 0) * -2.5deg);
}
}
@keyframes btn-hover-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(calc(var(--btn-bob, 0) * -2px));
}
}
@media (prefers-reduced-motion: reduce) {
.app-header-nav .btn-primary {
animation: none;
}
}
/* Icon-only controls are square, so the row reads as a tidy set. */
.app-header-nav .nav-search-btn,
.app-header-nav .notification-bell,
.app-header-nav .chat-button,
.app-header-nav .user-menu-trigger {
width: 2.25rem;
padding: 0;
justify-content: center;
}
/* Line icons (NavIcons.tsx), stroked in the text colour. */
.nav-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: var(--nav-icon-stroke);
stroke-linecap: var(--nav-icon-linecap);
stroke-linejoin: var(--nav-icon-linejoin);
}
/* Two-tone in the header and its search: the icons' bodies take a wash of
the accent, which deepens on hover and fills while something is unread.
The floating chat button sits on the accent, so its bubble stays hollow. */
.app-header .nav-icon-tint,
.search-bar-btn .nav-icon-tint {
fill: color-mix(in srgb, var(--color-accent) var(--nav-icon-tint-strength),
transparent);
transition: fill var(--dur-base);
}
.nav-search-btn:hover,
.chat-button:hover,
.notification-bell:hover,
.search-bar-btn:hover {
--nav-icon-tint-strength: 45%;
}
.chat-button--unread,
.notification-bell--unread {
--nav-icon-tint-strength: 100%;
}
/* The text link to every playlist. A guest's header holds no other, so
theirs shows at every width. Signed in, the avatar's menu holds one too,
and this one shows where the row has room for it beside the icons: from
560px, where it fits with the "+ Dump" label in every style and language,
up to 860px, where the index page's tabs move into the header, and again
from 1150px, where the header holds both. */
.nav-links {
display: flex;
align-items: center;
gap: 0.6rem;
}
@media (max-width: 559px), (min-width: 860px) and (max-width: 1149px) {
.app-header:not(.app-header--guest) .nav-links {
display: none;
}
}
/* A guest's row is the link, the search and "Log in": on the narrowest
phones it gives up its margins rather than the link. */
@media (max-width: 400px) {
.app-header--guest {
gap: 0.5rem;
padding: 0 0.75rem;
}
.app-header--guest .app-header-nav {
gap: 0.4rem;
}
.app-header--guest .app-header-nav button,
.app-header--guest .app-header-user {
padding-inline: 0.6rem;
}
}
.app-header-user {
display: inline-flex;
align-items: center;
text-decoration: none;
color: var(--color-text);
font-weight: 600;
font-size: var(--text-md);
padding: 0.35rem 0.85rem;
border-radius: var(--radius-lg);
background: var(--color-header-user-bg);
transition: background var(--dur-base);
}
.app-header-user:hover {
background: var(--color-header-user-bg-hover);
}
/* ── User menu ── */
/* A flex box, so the inline-flex trigger leaves no line-box gap under it
that would sit it higher than the buttons beside it. */
.user-menu {
position: relative;
display: flex;
}
.user-menu-trigger {
display: flex;
align-items: center;
background: transparent;
border: none;
border-radius: var(--radius-lg);
padding: 0.25rem;
cursor: pointer;
transition: background var(--dur-base);
}
/* The avatar fills its button, so a hover background would read as a box
bigger than the icons' ones: an accent ring shows instead. */
.user-menu-trigger > * {
transition: box-shadow var(--dur-base);
}
.user-menu-trigger:hover > *,
.user-menu-trigger[aria-expanded="true"] > * {
box-shadow: 0 0 0 2px var(--color-accent);
}
/* The avatar leads the header's icon row, so the menu hangs from its left
edge and opens over the icons: from its right edge, it ran off a phone. */
.user-menu-dropdown {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
min-width: 150px;
background: var(--color-surface);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-xl);
padding: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
z-index: var(--z-sticky);
box-shadow: var(--shadow-2);
}
.user-menu-item {
display: block;
padding: 0.45rem 0.75rem;
border-radius: var(--radius-md);
text-decoration: none;
color: var(--color-text);
font-size: var(--text-md);
font-weight: 600;
white-space: nowrap;
transition: background var(--dur-fast);
}
.user-menu-item:hover {
background: var(--color-header-user-bg-hover);
}
.user-menu-separator {
margin: 0.15rem 0.35rem;
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* Log out is a button among links: it drops the button's own border,
background and weight, and is muted until hovered, as ending the session
should be. Two classes, to outweigh `.app-header-nav button`, which
centres it, and the themes' `[data-style] button`. */
.user-menu .user-menu-item--logout {
display: block;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
font-family: inherit;
font-size: var(--text-md);
font-weight: 600;
text-align: left;
line-height: inherit;
}
.user-menu .user-menu-item--logout:hover {
background: var(--color-header-user-bg-hover);
color: var(--color-danger);
}
.user-list-popover {
/* position/top/bottom/left/max-height set inline — anchored to the
trigger's viewport rect, flipping above it when there isn't room below */
min-width: 180px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
background: var(--color-surface);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-xl);
padding: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
z-index: var(--z-sticky);
box-shadow: var(--shadow-2);
}
.user-list-popover-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: var(--radius-md);
text-decoration: none;
color: var(--color-text);
font-size: var(--text-sm);
font-weight: 600;
transition: background var(--dur-fast);
}
.user-list-popover-item:hover {
background: var(--color-header-user-bg-hover);
}
.user-list-popover-status {
margin: 0;
padding: 0.5rem 0.6rem;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.user-list-popover-load-more {
background: none;
border: none;
cursor: pointer;
padding: 0.4rem 0.6rem;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-accent);
text-align: left;
font-family: inherit;
}
.user-list-popover-load-more:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Auth card ── */
.auth-card {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 1.25rem;
background: var(--color-surface);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
padding: 2rem;
}
.auth-card-title {
margin: 0;
font-size: var(--text-2xl);
font-weight: 700;
text-align: center;
}
.auth-card-footer {
text-align: center;
font-size: var(--text-md);
opacity: 0.7;
margin: 0;
}
.auth-reset-sent {
font-size: var(--text-md);
text-align: center;
margin: 0;
opacity: 0.85;
}
/* Signing in with an account on another instance, under the login and
register forms, and the consent page that instance shows. */
.auth-federated {
border-top: 1px solid var(--color-border-subtle);
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-federated-title {
margin: 0;
font-size: var(--text-sm);
text-align: center;
color: var(--color-text-muted);
}
.auth-federated-peer {
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
}
.auth-federated-host {
font-size: var(--text-xs);
opacity: 0.7;
}
.auth-consent {
margin: 0;
line-height: 1.5;
overflow-wrap: anywhere;
}
.auth-consent-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.auth-consent-actions > * {
flex: 1 1 auto;
text-align: center;
}
/* ── Form pages (DumpCreate / DumpEdit) ── */
@keyframes page-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.form-page {
display: grid;
/* minmax(0, …): a 1fr track grows to its widest unbreakable content, so a
long URL or button row would push the page past a phone's edge. */
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto;
gap: 3px;
animation: page-enter 0.2s ease both;
}
/* The edit form spans the page grid and lays out its own panels: the dump
itself (preview, thumbnail, source) beside its presentation (title, why,
categories, visibility), with a full-width action bar underneath so both
columns end on the same line. Only the header and footer are rounded. */
.dump-edit-form {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: inherit;
}
@media (min-width: 860px) {
.form-page--two-col,
.dump-edit-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-page--two-col .form-page-header,
.dump-edit-form,
.dump-edit-form > .form-panel--bar {
grid-column: 1 / -1;
}
/* "Why?" soaks up whatever height the source column adds, so a tall
preview doesn't leave a hole under the details. */
.dump-edit-panel--details .form-field:has(.mention-textarea-wrap) {
flex: 1 1 auto;
}
.dump-edit-panel--details .mention-textarea-wrap {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.dump-edit-panel--details .text-editor-field {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.dump-edit-panel--details .mention-textarea-wrap textarea {
flex: 1 1 auto;
}
}
.form-page-header {
padding: 1.25rem;
background: var(--color-surface);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.form-page-eyebrow {
margin: 0;
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
opacity: 0.45;
}
.form-page-title {
margin: 0;
font-size: var(--text-2xl);
font-weight: 700;
word-break: break-word;
}
/* A form split into panels, a few pixels apart (as on the page's grid), in
place of rules between its parts: the fields, then a bar of actions, which
ends the card. */
.form-panel {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
padding: 1.25rem;
background: var(--color-surface);
}
.dump-edit-preview {
overflow: hidden;
}
.form-panel--bar {
padding: 0.875rem 1.25rem;
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
container: form-bar / inline-size;
}
.form-panel--bar .form-actions {
flex-wrap: nowrap;
padding-top: 0;
border-top: 0;
margin-top: 0;
}
/* Too narrow for Delete, Cancel and Save side by side in every face and
language, the bar shows Delete as its icon. */
.form-delete-icon {
display: none;
}
@container form-bar (max-width: 18rem) {
.form-delete-icon {
display: inline;
}
.form-delete-label {
display: none;
}
}
.dump-edit-refresh {
margin-top: 0.75rem;
}
.dump-edit-refresh-error {
margin-top: 0.5rem;
font-size: var(--text-sm);
color: var(--color-danger);
}
.dump-edit-thumbnail-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border-subtle);
margin-top: 0.5rem;
gap: 0.75rem;
}
.form-actions-right {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
}
/* Subtle text action. Resets the global <button> chrome so it looks the same
whether rendered as a <button> (modal/page forms) or a <Link> (DumpEdit). */
.form-cancel {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: var(--text-md);
color: var(--color-text);
opacity: 0.6;
text-decoration: none;
transition: opacity var(--dur-base);
}
.form-cancel:hover {
opacity: 1;
}
/* ── Buttons ── */
.btn-primary,
.btn-secondary,
.btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25em;
white-space: nowrap;
cursor: pointer;
border-radius: var(--radius-lg);
font-family: inherit;
font-size: var(--text-md);
font-weight: 600;
padding: 0.4rem 1rem;
transition:
background var(--dur-base),
color var(--dur-base),
border-color var(--dur-base),
box-shadow var(--dur-base),
transform var(--dur-fast);
}
.btn-primary {
background: var(--color-accent);
color: var(--color-on-accent);
border: none;
box-shadow: var(--btn-primary-shadow);
}
.btn-primary:hover {
background: var(--color-accent-hover);
box-shadow: var(--btn-primary-shadow-hover);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
box-shadow: none;
}
.btn-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
.btn-secondary {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text);
}
.btn-secondary:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.btn-secondary:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
/* The compact size, for the inline editors that sit in a narrow column or
beside small text: a comment, a chat message, the profile's email. Every
inline editor ends in a btn-primary Save then a btn-secondary Cancel. */
.btn-sm {
font-size: var(--text-sm);
padding: 0.25rem 0.75rem;
}
.btn-danger {
background: var(--color-danger-bg);
color: var(--color-text);
border: none;
}
.btn-danger:hover {
background: var(--color-danger-hover);
color: var(--color-on-danger);
}
/* ── Ghost button — chromeless icon/toggle action (e.g. global player) ── */
.btn--ghost {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0.3rem 0.55rem;
border-radius: var(--radius-md);
color: var(--color-text);
font-family: inherit;
opacity: 0.7;
transition: opacity var(--dur-base), background var(--dur-base);
}
.btn--ghost:hover {
opacity: 1;
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
/* ── Ghost icon buttons (shared base) ── */
.modal-close-btn,
.playlist-remove-btn,
.playlist-card-delete-btn {
background: none;
border: none;
cursor: pointer;
line-height: 1;
border-radius: var(--radius-sm);
transition: opacity var(--dur-base), color var(--dur-base);
}
/* ── Index page ── */
.index-page {
display: flex;
flex-direction: column;
animation: page-enter 0.2s ease both;
}
.index-presence {
display: flex;
align-items: center;
gap: 0;
}
@media (min-width: 860px) {
.index-page .dump-feed {
margin-top: 1rem;
}
}
.index-presence-avatar {
display: block;
flex-shrink: 0;
margin-left: -10px;
transition: margin-left var(--dur-slow) ease, transform var(--dur-slow) ease;
position: relative;
}
.index-presence-avatar:first-child {
margin-left: 0;
}
/* Hover only where there's a pointer to hover with — a tap would otherwise
leave the row fanned out and the tapped avatar raised. */
@media (hover: hover) {
.index-presence:hover .index-presence-avatar {
margin-left: 4px;
}
.index-presence:hover .index-presence-avatar:first-child {
margin-left: 0;
}
.index-presence-avatar:hover {
transform: translateY(-3px) scale(1.12);
z-index: 2;
}
}
/* The "+N" that ends a crowded row, sized and ringed like the avatars. */
.index-presence-more {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: var(--text-2xs);
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: default;
box-sizing: border-box;
border: var(--border-width) solid var(--color-border-subtle);
}
.index-status {
text-align: center;
opacity: 0.6;
padding: 3rem 1rem;
}
/* ── Below-header strip (presence + sort on narrow viewports) ── */
.index-below-header {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 0.5rem;
gap: 0.75rem;
padding: 0.6rem 1.25rem 0.4rem;
max-width: 860px;
width: 100%;
box-sizing: border-box;
align-self: center;
}
@media (min-width: 860px) {
.index-below-header {
display: none;
}
}
/* ── Feed sort scroller + buttons ── */
/* Wrapper: constrains width, clips scroll overflow, and carries the gradient
hint. --tab-row-bg is what the row sits on, which the fade and the pinned
leading content paint so the tabs vanish under them. */
.feed-sort-scroller {
position: relative;
min-width: 0;
overflow: hidden;
--tab-row-bg: var(--color-bg);
}
.app-header .feed-sort-scroller {
--tab-row-bg: var(--header-bg);
}
/* Right-edge fade, over the scroll container whatever its scroll position.
It shows only while tabs are hidden past that edge (TabBar's
--more), so it never dims the last tab once it is scrolled to or fits. */
.feed-sort-scroller::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2rem;
background: linear-gradient(to right, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more::after {
opacity: 1;
}
/* Its twin on the left edge once the row has scrolled, unless leading content
is pinned there, which draws its own (.feed-sort-leading::after). */
.feed-sort-scroller::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 2rem;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more-left:not(:has(.feed-sort-leading))::before {
opacity: 1;
}
/* The leading content (the scope switcher) stays at the row's start while the
tabs scroll under it. Its padding hides them right after its divider, and
the matching negative margin keeps the gap to the first tab unchanged. */
.feed-sort-leading {
position: sticky;
left: 0;
z-index: 1;
display: flex;
flex-shrink: 0;
padding-right: 0.4rem;
margin-right: -0.4rem;
background: var(--tab-row-bg);
}
/* Once tabs have scrolled under it, a short fade softens their cut edge. */
.feed-sort-leading::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 100%;
width: 0.75rem;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more-left .feed-sort-leading::after {
opacity: 1;
}
/* In the index below-header the scroller should fill the available row width */
.index-below-header .feed-sort-scroller {
flex: 1;
}
.feed-sort {
display: flex;
align-items: center;
gap: 0.4rem;
overflow-x: auto;
scrollbar-width: none;
/* Symmetric, so the tabs stay centred against their row (the header's
brand and buttons), with room on both sides for the focus ring that
overflow-x would otherwise clip. */
padding-block: 0.3rem;
padding-right: 0.25rem;
min-width: 0;
}
.feed-sort::-webkit-scrollbar {
display: none;
}
.feed-sort-btn {
padding: 0.25rem 0.8rem;
border-radius: var(--radius-md);
border: var(--border-width) solid var(--tab-border);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: var(--tab-tracking);
text-transform: var(--tab-text-transform);
transition: border-color var(--dur-base), background var(--dur-base), color
var(--dur-base);
white-space: nowrap;
flex-shrink: 0;
}
.feed-sort-btn:hover:not(.active) {
color: var(--color-text);
}
.feed-sort-btn.active {
border-color: var(--tab-active-border, var(--color-accent));
background: var(--tab-active-bg);
color: var(--tab-active-fg);
}
/* Narrower pills on a phone, so more tabs fit before the row has to scroll. */
@media (max-width: 600px) {
.feed-sort-btn {
padding-inline: 0.6rem;
}
}
/* ── Dump feed ── */
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.index-page .error-card-wrap {
max-width: 860px;
width: 100%;
padding: 1rem 1.25rem 0;
box-sizing: border-box;
align-self: center;
}
.dump-feed {
list-style: none;
margin: 0;
padding: 1rem 1.25rem 0;
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 860px;
width: 100%;
box-sizing: border-box;
align-self: center;
}
.dump-feed > li {
animation: card-enter 0.2s ease both;
}
/* ── Shared card skin (dump-card + playlist-card) ── */
.dump-card,
.playlist-card {
border: var(--card-border-width) solid var(--card-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
box-shadow: var(--card-shadow);
min-width: 0;
}
.dump-card:hover,
.playlist-card:hover {
border-color: var(--card-hover-border);
}
/* Card-specific layout. A container, since a card is often much narrower than
the viewport (a playlist's has a drag handle and a remove button beside it),
and its own width decides when the body has no room left beside the thumb. */
.dump-card {
container: dump-card / inline-size;
display: grid;
grid-template-rows: 1fr;
transition:
border-color var(--dur-base),
grid-template-rows 0.32s ease,
opacity 0.25s ease,
filter 0.3s ease;
}
.playlist-card {
position: relative;
transition: border-color var(--dur-base);
}
/* ── Shared card inner layout ── */
.dump-card-inner,
.playlist-card-inner {
overflow: hidden;
min-height: 0;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
min-width: 0;
cursor: pointer;
}
/* The `overflow: hidden` above is there for the dump card's collapse animation
(grid-template-rows: 0fr). A playlist card never collapses, and its height is
set by the 80px square thumb itself — so the clip had nothing to spare on
hover: it shaved the corners off the scaled thumb and sliced its drop shadow
into a hard-edged slab against the card border. Nothing else in the card
needs the clip (the thumb and the description clamp carry their own), so the
lift is simply allowed to render. */
.playlist-card-inner {
overflow: visible;
}
/* Desktop: wrapper is transparent — preview and vote become direct flex items of dump-card-inner */
.dump-card-left {
display: contents;
}
/* With display:contents, all three are siblings in dump-card-inner's flex context.
DOM order is: preview, vote, body — use order to restore: preview, body, vote. */
.dump-card-body {
order: 1;
margin-left: 0.25rem; /* total gap to preview = container gap (0.75) + this = 1rem */
}
.dump-card-vote {
order: 2;
}
@media (max-width: 512px) {
.playlist-card-inner {
gap: 0.5rem;
padding: 0.6rem 0.75rem;
}
.playlist-card-description {
display: none;
}
}
/* ── Shared card preview thumbnail ── */
.dump-card-preview,
.playlist-card-preview {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-bg);
/* Subtle matte so any unfilled space (icons, off-ratio art) reads as
intentional framing rather than a broken bar. */
box-shadow: inset 0 0 0 1px var(--color-border);
transition: transform var(--dur-slow) ease, box-shadow var(--dur-slow) ease;
}
/* Feed thumbnails are mostly 16:9 (YouTube, og:image) — match that ratio so
object-fit: cover crops almost nothing, and go wider for legibility. */
.dump-card-preview {
width: 128px;
height: 72px;
}
/* Playlist art is square (album covers) — keep it square. */
.playlist-card-preview {
width: 80px;
height: 80px;
}
.dump-card-inner:hover .dump-card-preview,
.playlist-card-inner:hover .playlist-card-preview {
transform: scale(1.08);
box-shadow: inset 0 0 0 1px var(--color-border), 0 4px 12px rgba(0, 0, 0,
0.25);
}
/* ── Dump card preview icon ── */
.dump-card-preview-icon {
font-size: var(--text-3xl);
opacity: 0.7;
line-height: 1;
}
/* ── Generated thumbnail placeholder ──
Stands in for pages that offer no preview image. The site's own color is
mixed *into* the theme surface rather than painted raw, so an arbitrary
third-party accent can never break contrast against --color-text — in any
style, in either color scheme. Themes tune --thumb-tint-strength alone. */
/* Zero-specificity sizing: the placeholder fills its box by default, but any
class it shares with the <img> it replaces (.rich-content-thumbnail's 180px,
.rich-content-compact-thumbnail's 36px) still wins, so it lands exactly where
the image would have. */
:where(.thumb-placeholder) {
width: 100%;
height: 100%;
}
.thumb-placeholder {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
user-select: none;
color: var(--color-text);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.12)),
color-mix(
in srgb,
var(--thumb-tint, var(--color-accent)) var(--thumb-tint-strength, 34%),
var(--color-surface)
);
}
.thumb-placeholder-icon {
max-width: 44%;
max-height: 44%;
object-fit: contain;
display: block;
}
.thumb-placeholder-initials {
font-size: var(--text-xl);
font-weight: 800;
line-height: 1;
opacity: 0.75;
letter-spacing: 0.02em;
}
/* The initial has no intrinsic size, so scale it per surface. */
.dump-card-preview .thumb-placeholder-initials,
.playlist-card-preview .thumb-placeholder-initials,
.audio-file-preview .thumb-placeholder-initials {
font-size: var(--text-3xl);
}
.journal-card-image .thumb-placeholder-initials {
font-size: var(--text-display);
}
/* The title and meta cover the tile's foot, so the glyph is centred in the
space above them, where a one-row tile has room for it. */
.journal-card-image .thumb-placeholder {
box-sizing: border-box;
padding-bottom: 5rem;
}
.rich-content-thumbnail.thumb-placeholder .thumb-placeholder-initials {
font-size: var(--text-display);
}
/* `.rich-content-thumbnail` sizes an <img> by width alone and lets the natural
aspect supply the height, which a <div> has none of. Stretching to the card's
height matches the image; the floor keeps it from collapsing when there's
little text, or once the card stacks on narrow screens. */
.rich-content-thumbnail.thumb-placeholder {
height: auto;
align-self: stretch;
min-height: 110px;
}
/* Fill the 48×48 preview box and center content for media buttons */
.dump-card-preview .rich-content-thumbnail-btn {
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
.dump-card-preview .rich-content-compact {
position: relative;
width: 100%;
height: 100%;
justify-content: center;
}
/* ── Two targets, one card ──
The card opens the dump page; a playable (or source-linked) thumbnail does
its own thing. Only one of them may look armed at a time: over the thumb
the card's "open" cue (the accent title) stands down and the thumb's own
dim and badge take over. At rest the thumb carries only a small badge in
its centre, so it reads as "playable" without claiming the whole card. An
audio file's cover on its dump's page wears the same badge. */
.dump-card-inner:has(.dump-card-preview :is(button, a):hover) .dump-card-title {
color: var(--color-text);
}
/* Over a placeholder, the badge stands in for its glyph. */
:is(.dump-card-preview, .audio-file-preview) .rich-content-thumbnail-btn .thumb-placeholder-initials {
visibility: hidden;
}
:is(.dump-card-preview, .audio-file-preview) .rich-content-play-overlay,
.rich-content-open-overlay {
position: absolute;
inset: 0;
margin: auto;
z-index: 1;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: var(--text-xs);
line-height: 1;
pointer-events: none;
transition:
background var(--dur-base) ease,
opacity var(--dur-base) ease,
transform var(--dur-base) ease;
}
/* The badge never moves: card hover grows it a touch; hovering the thumb
itself grows it a touch more while a soft dim fades in behind it. */
.dump-card-inner:hover .dump-card-preview .rich-content-play-overlay {
transform: scale(1.1);
}
.dump-card-preview .rich-content-thumbnail-btn::after,
.audio-file-preview .rich-content-thumbnail-btn::after,
.dump-card-preview .rich-content-compact::after {
content: "";
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.2);
opacity: 0;
pointer-events: none;
transition: opacity var(--dur-base) ease;
}
/* A link out has nothing to badge at rest — the ↗ only appears on hover. */
.rich-content-open-overlay {
opacity: 0;
}
:is(.dump-card-preview, .audio-file-preview) .rich-content-thumbnail-btn:is(:hover, :focus-visible)::after,
.dump-card-preview .rich-content-compact:is(:hover, :focus-visible)::after {
opacity: 1;
}
:is(.dump-card-preview, .audio-file-preview) .rich-content-thumbnail-btn:is(:hover, :focus-visible) .rich-content-play-overlay,
.rich-content-compact:is(:hover, :focus-visible) .rich-content-open-overlay {
transform: scale(1.2);
background: rgba(0, 0, 0, 0.8);
opacity: 1;
}
/* The one playing keeps its badge lit, in the accent, showing ⏸. */
:is(.dump-card-preview, .audio-file-preview) .rich-content-thumbnail-btn.is-playing .rich-content-play-overlay {
background: var(--color-accent);
color: var(--color-on-accent);
}
:is(.dump-card-preview, .audio-file-preview) .rich-content-compact-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0;
border: none;
}
/* ── Shared card body ── */
.dump-card-body,
.playlist-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-left: 0.25rem;
}
/* ── Shared card title link ── */
.dump-card-title,
.playlist-card-title {
font-weight: 600;
font-size: var(--text-base);
color: var(--color-text);
text-decoration: none;
word-break: break-word;
transition: color var(--dur-base);
line-height: 1.35;
}
.dump-card-inner:hover .dump-card-title,
.playlist-card-inner:hover .playlist-card-title {
color: var(--color-accent);
}
/* ── Unread dot ── */
.unread-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-accent);
vertical-align: baseline;
position: relative;
top: -0.15em;
margin-right: 0.4em;
flex-shrink: 0;
}
/* ── Shared card description / comment ── */
.dump-card-comment,
.playlist-card-description {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-secondary);
word-break: break-word;
line-height: 1.4;
}
.dump-card-comment {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.playlist-card-description {
-webkit-line-clamp: 2;
line-clamp: 3;
}
/* ── Shared card meta row ── */
.dump-card-date,
.playlist-card-meta {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-top: 0.2rem;
}
.dump-card-meta {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-top: 0.2rem;
}
.dump-card-date {
margin-top: 0;
}
.dump-edited-label,
.playlist-edited-label {
font-size: var(--text-2xs);
opacity: 0.5;
font-style: italic;
}
.playlist-card-meta {
display: flex;
align-items: center;
gap: 0.6rem;
}
@media (max-width: 512px) {
.playlist-card-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
}
/* ── Playlist grid ──
The playlists page shows its cards as tiles, the cover across the tile's
top and the text under it: the same card as in a .dump-feed, laid out by
the list it is in, so every theme's card skin carries over. */
.playlist-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: 1rem;
}
.playlist-grid > li {
animation: card-enter 0.2s ease both;
}
/* The tiles of a row are as tall as the tallest, each one's text starting at
the same height under covers of one size. */
.playlist-grid .playlist-card {
display: flex;
}
.playlist-grid .playlist-card-inner {
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 0.65rem;
padding: 0.6rem 0.6rem 0.8rem;
}
.playlist-grid .playlist-card-preview {
width: 100%;
height: auto;
aspect-ratio: 1;
}
/* A cover this size fills the tile: grown as a thumb is, it would spill over
the card's edge. */
.playlist-grid .playlist-card-inner:hover .playlist-card-preview {
transform: none;
}
.playlist-grid .playlist-card-preview .thumb-placeholder-initials {
font-size: var(--text-display);
}
.playlist-grid .playlist-card-body {
margin-left: 0;
padding-inline: 0.15rem;
}
.playlist-grid .playlist-card-title,
.playlist-grid .playlist-card-description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
/* Owner, count and date wrap whole rather than run past a narrow tile. */
.playlist-grid .playlist-card-meta {
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 0.1rem 0.6rem;
}
@media (max-width: 512px) {
.playlist-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
}
.dump-card-vote {
flex-shrink: 0;
align-self: center;
}
/* A narrow card (on a phone, or in a playlist, whose drag handle and remove
button narrow it further) is a grid that both columns' wrappers dissolve
into, so the vote ends the date line, as on the journal's cards, rather
than hanging under the thumb. The empty 1fr rows centre the text against
the thumb when it is the taller; the fixed row after the title keeps the
vote, which the title's last line can run over, clear of it. */
@container dump-card (max-width: 472px) {
.dump-card-inner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: 1fr auto 0.4rem auto auto 1fr;
/* No row gap: the empty rows would add theirs; the spacer row and the
categories' margin set the space instead. */
gap: 0 0.75rem;
padding: 0.6rem 0.75rem;
}
.dump-card-left,
.dump-card-body {
display: contents;
}
.dump-card-preview {
grid-column: 1;
grid-row: 1 / -1;
align-self: center;
/* Slightly smaller 16:9 thumb, so the text keeps enough width. */
width: 104px;
height: 58px;
}
.dump-card-title {
grid-column: 2 / -1;
grid-row: 2;
}
.dump-card-comment {
display: none;
}
.dump-card-meta {
grid-column: 2;
grid-row: 4;
align-self: center;
margin-top: 0;
/* Its parts wrap whole, so a date never breaks across lines. */
flex-wrap: wrap;
row-gap: 0.1rem;
white-space: nowrap;
}
.dump-card-vote {
grid-column: 3;
grid-row: 4;
}
.dump-card-categories {
grid-column: 2 / -1;
grid-row: 5;
}
}
/* Too narrow for any text beside the thumb, which left a title a few letters
per line: the thumb takes the top row and the text the full width under it,
the vote still ending the date line. */
@container dump-card (max-width: 300px) {
.dump-card-inner {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto 0.4rem auto auto;
}
.dump-card-preview {
grid-column: 1 / -1;
grid-row: 1;
margin-bottom: 0.5rem;
}
.dump-card-title {
grid-column: 1 / -1;
}
.dump-card-meta {
grid-column: 1;
}
.dump-card-vote {
grid-column: 2;
}
.dump-card-categories {
grid-column: 1 / -1;
}
}
@media (max-width: 512px) {
.app-header-brand-name {
display: none;
}
.btn-new-label {
display: none;
}
/* Down to its "+", the button squares up with the icons beside it. */
.app-header-nav .btn-primary {
width: 2.25rem;
padding: 0;
}
}
/* ── Playlist card image thumbnail ── */
.playlist-card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* ── Playlist badge ── */
.playlist-badge {
display: inline-block;
font-size: var(--text-2xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
color: var(--color-accent);
}
.playlist-badge--private {
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
color: var(--color-text-muted);
}
/* ── Playlist card dump count ── */
.playlist-card-count {
opacity: 0.7;
}
.playlist-card-owner,
.playlist-detail-owner {
color: inherit;
text-decoration: none;
font-weight: 600;
}
.playlist-card-owner:hover {
color: var(--color-accent);
}
/* ── Playlist card delete button ── */
.playlist-card-delete-btn {
position: absolute;
top: 0.4rem;
right: 0.4rem;
color: var(--color-text);
opacity: 0;
font-size: var(--text-xs);
padding: 0.2rem 0.35rem;
}
.playlist-card:hover .playlist-card-delete-btn {
opacity: 0.4;
}
.playlist-card-delete-btn:hover {
opacity: 1 !important;
color: var(--color-danger);
}
/* ── Modal (shared) ── */
.modal-backdrop {
position: fixed;
inset: 0;
background: var(--color-overlay);
backdrop-filter: var(--overlay-filter);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: 1rem;
}
/* Shared modal box surface */
.modal-card,
.confirm-modal {
background: var(--color-surface);
border: var(--border-width) solid var(--modal-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-3);
width: 100%;
}
.modal-card {
max-width: 420px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-card--wide {
max-width: 600px;
}
.dump-create-success {
margin-bottom: 1rem;
font-size: var(--text-md);
color: var(--color-text-muted);
}
.dump-create-success a {
color: var(--color-accent);
font-weight: 600;
}
.confirm-modal {
max-width: 340px;
padding: 1.5rem 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.modal-title {
font-weight: 700;
font-size: var(--text-base);
}
.modal-close-btn {
color: var(--color-text);
font-size: var(--text-base);
opacity: 0.6;
padding: 0.25rem;
}
.modal-close-btn:hover {
opacity: 1;
}
.modal-body {
padding: 1rem 1.25rem;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* ── Live chat ── */
/* The chat fills the (now taller) modal so it makes better use of the viewport
on desktop — see the .modal-card:has(.chat) height override below. */
.chat {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
height: 100%;
gap: 0.75rem;
}
.chat-messages-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-messages {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
/* Reserve a gutter for the scrollbar so it never sits on top of the
messages — without this, overlay scrollbars overlap the content. The extra
padding keeps the text clear of the scrollbar rather than right against it. */
scrollbar-gutter: stable;
/* A slim, theme-tinted scrollbar whose thumb stays hidden until the list is
hovered or focused, instead of the heavy native bar. */
scrollbar-width: thin;
scrollbar-color: transparent transparent;
display: flex;
flex-direction: column;
padding-right: 0.6rem;
}
/* One box around everything the list scrolls, so its height can be watched as
a whole (see the ResizeObserver in ChatModal). Grows to fill the list, which
keeps the empty state centered. */
.chat-messages-content {
flex: 1 0 auto;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.chat-messages:hover,
.chat-messages:focus-within {
scrollbar-color: color-mix(in srgb, var(--color-text-muted) 45%, transparent)
transparent;
}
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
}
.chat-messages:hover::-webkit-scrollbar-thumb,
.chat-messages:focus-within::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-text-muted) 45%, transparent);
}
.chat-empty {
margin: auto 0;
text-align: center;
color: var(--color-text-muted);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
.chat-empty p {
margin: 0;
}
.chat-load-older {
text-align: center;
padding-bottom: 0.25rem;
}
.chat-message {
display: flex;
gap: 0.6rem;
align-items: flex-start;
/* Each message animates once, when its element first mounts — so newly
arriving messages slide in while existing ones stay put (React reuses
keyed DOM nodes). */
animation: chat-msg-in 0.18s ease-out;
}
@keyframes chat-msg-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.chat-message {
animation: none;
}
}
/* Brief highlight when a message is jumped to via a reply reference. */
.chat-message {
position: relative;
border-radius: var(--radius-lg);
transition: background-color 0.4s ease;
}
.chat-message--flash {
background-color: color-mix(in srgb, var(--color-accent) 18%, transparent);
}
/* A run of messages from one author: the follow-ups drop the avatar and the
author line and sit close under the first, pulling back most of the list's
gap. The avatar column shows the time instead, on hover. */
.chat-message--grouped {
margin-top: -0.6rem;
}
.chat-message-gutter {
flex: 0 0 32px;
/* The time may be wider than the column; let it spill evenly both sides
rather than widen the column and push the text out of line. */
min-width: 0;
display: flex;
justify-content: center;
/* Drawn, but no height of its own: the tooltip wrapper around the time is
taller than a line of text and would pad every follow-up message. */
height: 0;
overflow: visible;
white-space: nowrap;
padding-top: 0.2rem;
font-size: var(--text-2xs);
line-height: 1.2;
color: var(--color-text-muted);
opacity: 0;
transition: opacity var(--dur-base);
}
.chat-message:hover .chat-message-gutter,
.chat-message:focus-within .chat-message-gutter {
opacity: 1;
}
/* With no meta row to trail, a grouped message's actions float at its top
right, over a patch of surface so they stay legible over the text. */
.chat-message-actions--float {
position: absolute;
top: -0.35rem;
right: 0.25rem;
margin: 0;
padding: 0.1rem 0.25rem;
border-radius: var(--radius-sm);
background: var(--color-surface);
box-shadow: 0 0 0 1px var(--color-border-subtle);
}
/* Messages that mention you (or reply to you) carry an accent edge. */
.chat-message--mention {
background-color: color-mix(in srgb, var(--color-accent) 7%, transparent);
box-shadow: inset 2px 0 0 var(--color-accent);
padding: 0.25rem 0.35rem 0.25rem 0.5rem;
margin-left: -0.5rem;
}
.chat-message--grouped.chat-message--mention {
margin-top: -0.85rem;
}
/* A message on its way: dimmed until the server echoes it back. */
.chat-message--pending .chat-message-text {
opacity: 0.55;
}
.chat-message--failed .chat-message-text {
opacity: 0.75;
}
.chat-pending-status {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.2rem 0.6rem;
margin-top: 0.15rem;
font-size: var(--text-2xs);
color: var(--color-text-muted);
}
.chat-pending-error {
color: var(--color-danger);
}
.chat-text-btn {
padding: 0;
background: none;
border: none;
font: inherit;
font-weight: 600;
color: var(--color-accent);
cursor: pointer;
}
.chat-text-btn:hover {
text-decoration: underline;
}
/* Subtle day separator: a hairline rule with a centered, muted label. */
.chat-day-sep {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0.15rem 0;
color: var(--color-text-muted);
}
.chat-day-sep::before,
.chat-day-sep::after {
content: "";
flex: 1;
height: 1px;
background: var(--color-border);
}
.chat-day-sep-label {
font-size: var(--text-2xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* Where the unread messages start — the day separator's rule, in accent. */
.chat-new-sep {
color: var(--color-accent);
}
.chat-new-sep::before,
.chat-new-sep::after {
background: currentColor;
opacity: 0.6;
}
/* Scrolled up while messages arrive: a pill over the list's foot says how
many, and takes you down to them. */
.chat-new-pill {
position: absolute;
left: 50%;
bottom: 0.5rem;
transform: translateX(-50%);
padding: 0.3rem 0.8rem;
border: none;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-on-accent);
font: inherit;
font-size: var(--text-xs);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
box-shadow: var(--shadow-2);
animation: chat-msg-in 0.18s ease-out;
}
.chat-new-pill:hover {
background: var(--color-accent-hover);
}
/* Connection trouble and failed edits/deletes, in a slim bar. */
.chat-status,
.chat-error {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
padding: 0.35rem 0.6rem;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
}
.chat-status {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
color: var(--color-text-muted);
}
.chat-error {
border-left: 2px solid var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-text);
}
.chat-error-text {
flex: 1;
min-width: 0;
}
.chat-message-body {
flex: 1;
min-width: 0;
}
.chat-message-meta {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.1rem;
}
.chat-message-author {
font-weight: 700;
font-size: var(--text-sm);
color: inherit;
text-decoration: none;
}
.chat-message-author:hover {
color: var(--color-accent);
}
.chat-message-time {
font-size: var(--text-2xs);
color: var(--color-text-muted);
}
.chat-message-text {
font-size: var(--text-md);
}
.chat-message-text.md {
line-height: 1.45;
}
/* A message that's only an emoji or three, shown large as chats do. */
.chat-message-text--jumbo {
font-size: 2.1em;
line-height: 1.2;
}
.chat-message-text--jumbo.md {
line-height: 1.2;
}
.chat-message-edited {
font-size: var(--text-2xs);
color: var(--color-text-muted);
opacity: 0.7;
font-style: italic;
}
/* Edit/Delete actions: bare inline icons that follow the author/time in the
meta row, sitting right next to the message they belong to. Faded in on hover
(or focus, for keyboard use) so they stay unobtrusive. */
.chat-message-actions {
margin-left: 0.35rem;
display: inline-flex;
gap: 0.15rem;
opacity: 0;
transition: opacity var(--dur-base);
}
.chat-message:hover .chat-message-actions,
.chat-message:focus-within .chat-message-actions {
opacity: 1;
}
@media (hover: none) {
/* No hover on touch devices — keep the actions visible. */
.chat-message-actions {
opacity: 1;
}
}
.chat-icon-btn {
background: none;
border: none;
padding: 0 0.1rem;
font-size: var(--text-xs);
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
transition: color var(--dur-base);
}
.chat-icon-btn:hover {
color: var(--color-text);
}
.chat-icon-btn--delete:hover {
color: var(--color-danger);
}
.chat-edit {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.chat-edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.chat-composer {
display: flex;
gap: 0.5rem;
align-items: flex-end;
flex-shrink: 0;
}
.chat-composer .mention-textarea-wrap {
flex: 1 1 auto;
min-width: 0;
}
/* `display: block` removes the inline-block baseline descender gap below the
textarea — that phantom space was making the wrapper taller than the
textarea, so the bottom-aligned Send button landed a few px low.
A long message grows the box only so far, then scrolls inside it, so the
composer can't squeeze the message list away and an edit box can't outgrow
the chat. (Qualified to outrank .comment-reply-textarea's overflow.) */
.comment-reply-textarea.chat-input {
display: block;
max-height: min(35vh, 15rem);
overflow-y: auto;
scrollbar-width: thin;
}
/* The counter (only shown near the limit) and the upload notice sit just
above the input, in the gap over the composer: inside the textarea, the text
ran under them, and in a row below they would knock the Send button out of
alignment. */
.chat-composer .text-editor-footer {
position: absolute;
left: 0.25rem;
right: 0.25rem;
bottom: calc(100% + 1px);
margin: 0;
line-height: 1;
}
.chat-composer .text-editor-footer > * {
margin: 0;
}
.chat-send-btn {
flex-shrink: 0;
/* Match the input's resting height so the two line up exactly (both edges),
while the input is free to grow taller as you type. */
height: 2.4rem;
box-sizing: border-box;
}
/* ── Reply reference (subtle, above a message's body) ── */
.chat-reply-ref {
display: flex;
align-items: baseline;
gap: 0.3rem;
max-width: 100%;
margin-bottom: 0.15rem;
padding: 0;
background: none;
border: none;
font: inherit;
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
text-align: left;
}
.chat-reply-ref:disabled {
cursor: default;
font-style: italic;
}
.chat-reply-ref:not(:disabled):hover .chat-reply-ref-author,
.chat-reply-ref:not(:disabled):hover .chat-reply-ref-text {
color: var(--color-text);
}
.chat-reply-ref-mark {
opacity: 0.7;
flex-shrink: 0;
}
.chat-reply-ref-author {
font-weight: 700;
flex-shrink: 0;
transition: color var(--dur-base);
}
.chat-reply-ref-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.85;
transition: color var(--dur-base);
}
/* ── Reply composer bar (subtle, above the input) ── */
.chat-reply-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
padding: 0.35rem 0.6rem;
border-left: 2px solid var(--color-accent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.chat-reply-bar-mark {
opacity: 0.7;
flex-shrink: 0;
}
.chat-reply-bar-text {
display: flex;
align-items: baseline;
gap: 0.3rem;
min-width: 0;
flex: 1;
}
.chat-reply-bar-author {
font-weight: 700;
color: var(--color-text);
flex-shrink: 0;
}
.chat-reply-bar-snippet {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
}
.chat-reply-bar-cancel {
margin-left: auto;
flex-shrink: 0;
font-size: var(--text-sm);
}
/* ── "Is typing" indicator (reuses presence avatars) ── */
.chat-typing {
display: flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
min-height: 1.25rem;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.chat-typing-avatars {
display: inline-flex;
}
/* Overlap multiple typers' avatars slightly, like a stacked presence cluster. */
.chat-typing-avatar:not(:first-child) {
margin-left: -0.4rem;
}
.chat-typing-avatar {
border-radius: 50%;
box-shadow: 0 0 0 2px var(--color-surface);
}
.chat-typing-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-typing-dots {
display: inline-flex;
gap: 2px;
flex-shrink: 0;
}
.chat-typing-dots span {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
animation: chat-typing-bounce 1.2s infinite ease-in-out;
}
.chat-typing-dots span:nth-child(2) {
animation-delay: 0.15s;
}
.chat-typing-dots span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes chat-typing-bounce {
0%,
60%,
100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 0.9;
transform: translateY(-2px);
}
}
@media (prefers-reduced-motion: reduce) {
.chat-typing-dots span {
animation: none;
}
}
/* Give the chat a tall, stable panel so it uses the viewport well on desktop
instead of floating as a small box. Bounded by max-height so it never spills
off short screens; the inner message list scrolls within it. */
.modal-card:has(.chat) {
height: min(86vh, 820px);
max-height: 92vh;
}
/* The chat sizes itself to the modal and the message list carries its own
scroll, so the body never has anything to scroll — dropping its clip keeps a
rounding overflow from raising a second, empty scrollbar beside the messages.
(The composer's popovers no longer need this: they are portaled to <body>.) */
.modal-card:has(.chat) .modal-body {
overflow: visible;
}
/* Very narrow viewports: stack the input and Send button. */
@media (max-width: 400px) {
.chat-composer {
flex-direction: column;
align-items: stretch;
}
.chat-send-btn {
height: auto;
width: 100%;
}
/* The author/time/edited row gets cramped when squeezed side-by-side here —
stack the metadata instead so each piece stays readable. Keep the stacked
lines tight so they don't balloon the message height. */
.chat-message-meta {
flex-direction: column;
align-items: flex-start;
gap: 0;
line-height: 1.25;
}
/* No longer trailing the time inline; align it with the stacked metadata. */
.chat-message-actions {
margin-left: 0;
}
}
.confirm-modal-message {
margin: 0;
font-size: var(--text-md);
line-height: 1.5;
}
.confirm-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
}
/* ── Membership rows ── */
.playlist-membership-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
max-height: 40vh;
}
.playlist-membership-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border-radius: var(--radius-lg);
cursor: pointer;
transition: background var(--dur-base);
}
.playlist-membership-row:hover {
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.playlist-membership-row--active {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.playlist-membership-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-membership-check {
font-size: var(--text-base);
color: var(--color-accent);
width: 1.2rem;
text-align: center;
flex-shrink: 0;
}
/* ── Inline new-playlist form ── */
.modal-new-item-toggle {
background: none;
border: 1px dashed var(--color-border-subtle);
border-radius: var(--radius-lg);
color: var(--color-accent);
cursor: pointer;
padding: 0.6rem 1rem;
font-size: var(--text-md);
text-align: left;
width: 100%;
transition: border-color var(--dur-base), background var(--dur-base);
}
.modal-new-item-toggle:hover {
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
/* ── Playlist detail page ── */
.playlist-detail-header {
background: var(--color-surface);
border-radius: var(--radius-xl);
padding: 1.25rem;
}
/* While edited, the header gives way to the form's own panels. */
.playlist-detail-header--editing {
padding: 0;
background: none;
box-shadow: none;
}
.playlist-edit-form {
gap: 3px;
}
.playlist-edit-form > .form-panel:first-child {
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
.playlist-detail-header-top {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* The cover is a banner over the title, cropped to 3:1 and no taller than
14rem on wide screens, and the edit form's picker takes the same shape. It
stays inside the header's padding, so themes that outline panels with
--panel-ring keep their ring around it. */
.playlist-detail-img,
.playlist-cover-banner {
display: block;
width: 100%;
aspect-ratio: 3 / 1;
max-height: 14rem;
}
.playlist-detail-img {
object-fit: cover;
border-radius: var(--radius-lg);
}
/* Or the picker's image would hold it at its own height: a box sized by
aspect-ratio grows to fit its content. */
.playlist-cover-banner {
min-height: 0;
}
.playlist-detail-title-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.playlist-detail-title {
margin: 0;
font-size: var(--text-2xl);
font-weight: 700;
word-break: break-word;
}
.playlist-detail-description {
font-size: var(--text-base);
margin: 0 0 0.5rem;
opacity: 0.75;
line-height: 1.75;
overflow-wrap: break-word;
word-break: break-word;
}
.playlist-detail-meta {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: var(--text-sm);
opacity: 0.6;
}
@media (max-width: 512px) {
.playlist-detail-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
}
.playlist-detail-owner:hover {
color: var(--color-accent);
opacity: 1;
}
.playlist-detail-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.playlist-edit-btn {
background: none;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text);
font-size: var(--text-sm);
padding: 0.25rem 0.7rem;
opacity: 0.7;
transition: opacity var(--dur-base), border-color var(--dur-base), color
var(--dur-base);
}
.playlist-edit-btn:hover {
opacity: 1;
border-color: var(--color-accent);
color: var(--color-accent);
}
/* ── Playlist dump list ── */
.playlist-dump-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.playlist-dump-item {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: center;
transition: background var(--dur-base);
border-radius: var(--radius-xl);
}
/* For those who may edit the playlist, each card has a drag handle on its
left and a remove button on its right. They sit in the page's side
padding, so the cards keep the header's edges. Undo shows over the card it
would bring back, dimmed during its cooldown. */
.playlist-dump-list--editable .playlist-dump-item {
grid-template-columns: var(--page-gutter) minmax(0, 1fr) var(--page-gutter);
margin-inline: calc(-1 * var(--page-gutter));
}
.playlist-dump-item .dump-card {
min-width: 0;
}
.playlist-dump-list--editable .playlist-dump-item .dump-card {
grid-area: 1 / 2;
}
.playlist-dump-item--drag-over {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
outline: 2px dashed var(--color-accent);
}
.drag-handle {
grid-column: 1;
justify-self: center;
cursor: grab;
opacity: 0.35;
font-size: var(--text-lg);
user-select: none;
}
.drag-handle:active {
cursor: grabbing;
}
.playlist-remove-btn {
grid-column: 3;
align-self: stretch;
padding: 0;
color: var(--color-text);
opacity: 0.4;
font-size: var(--text-sm);
}
.playlist-remove-btn:hover {
opacity: 1;
color: var(--color-danger);
}
.playlist-undo-btn {
grid-area: 1 / 2;
justify-self: center;
/* Over the card, which is positioned. */
position: relative;
z-index: 1;
}
/* ── Add to playlist button (dump detail) ── */
.btn-add-playlist {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.6rem;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: none;
cursor: pointer;
color: var(--color-text);
font-size: var(--text-xs);
font-weight: 600;
white-space: nowrap;
transition: border-color var(--dur-base), color var(--dur-base);
}
.btn-add-playlist:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* ── New playlist toggle (profile page header) ── */
.new-item-toggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-accent);
font-size: var(--text-sm);
padding: 0;
transition: opacity var(--dur-base);
white-space: nowrap;
}
.new-item-toggle:hover {
opacity: 0.75;
}
/* ── Dump card comment count ── */
.dump-card-comment-count {
font-size: var(--text-2xs);
color: var(--color-text-muted);
}
/* ── Dump card private badge ── */
.dump-card-private-badge {
font-size: var(--text-2xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1em 0.45em;
border-radius: var(--radius-sm);
background: var(--color-badge-bg);
color: var(--color-text-muted);
}
/* ── Comments / related (shared section chrome) ── */
.comment-section,
.related-section {
margin-top: 2.5rem;
padding-top: 1.75rem;
border-top: var(--border-width) solid var(--color-border);
}
.comment-section-title,
.related-section-title {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-muted);
margin: 0 0 1.25rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* Backlinks are supporting context, not the main event — render them as a
tighter, lighter list so they stay scannable but clearly subordinate to
the dump's own content. */
.related-section .related-section-title {
margin-bottom: 0.85rem;
}
.related-section .dump-feed {
padding: 0;
gap: 0.4rem;
max-width: none;
}
/* Subordination is carried by the dimming and the tighter box, not by
dropping the fill: a theme is free to draw its cards with the surface alone
(smooth and melange both set --card-border: transparent), so blanking the
background there left nothing to see — text floating on the page, or, in
melange, a shadow around a hole. Whatever skin the theme gives a card is
kept; only its weight comes down. */
.related-section .dump-card {
border-width: 1px;
border-radius: var(--radius-lg);
opacity: 0.78;
transition: border-color var(--dur-base), opacity var(--dur-base);
}
.related-section .dump-card:hover {
opacity: 1;
}
.related-section .dump-card-inner {
padding: 0.45rem 0.65rem;
gap: 0.6rem;
}
.related-section .dump-card-preview {
width: 76px;
height: 43px;
align-self: center;
}
.related-section .dump-card-title {
font-size: var(--text-sm);
}
/* Keep just enough description to orient, but only a single muted line. */
.related-section .dump-card-comment {
font-size: var(--text-xs);
-webkit-line-clamp: 1;
line-clamp: 1;
}
@media (max-width: 512px) {
.related-section .dump-card-preview {
width: 64px;
height: 36px;
}
}
/* "In collections" reuses the related-section chrome (and its per-theme
overrides), so the playlist cards inside it get the same subordinate,
tightened treatment the backlink dump cards do. */
.collections-section .playlist-card {
border-width: 1px;
border-radius: var(--radius-lg);
opacity: 0.78;
transition: border-color var(--dur-base), opacity var(--dur-base);
}
.collections-section .playlist-card:hover {
opacity: 1;
}
.collections-section .playlist-card-inner {
padding: 0.45rem 0.65rem;
gap: 0.6rem;
}
.collections-section .playlist-card-preview {
width: 48px;
height: 48px;
align-self: center;
}
.collections-section .playlist-card-preview .thumb-placeholder-initials {
font-size: var(--text-lg);
}
.collections-section .playlist-card-title {
font-size: var(--text-sm);
}
.collections-section .playlist-card-description {
font-size: var(--text-xs);
-webkit-line-clamp: 1;
line-clamp: 1;
}
@media (max-width: 512px) {
.collections-section .playlist-card-preview {
width: 40px;
height: 40px;
}
}
.comment-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.comment-node {
display: flex;
flex-direction: column;
}
.comment-node-inner {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0.85rem;
border-radius: var(--radius-lg);
background: var(--color-surface);
transition: background var(--dur-fast);
}
.comment-node-inner:hover {
background: color-mix(
in srgb,
var(--color-surface) 80%,
var(--color-accent) 20%
);
}
.comment-avatar {
flex-shrink: 0;
padding-top: 1px;
}
.comment-content {
flex: 1;
min-width: 0;
}
.comment-meta {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.comment-author {
font-weight: 700;
font-size: var(--text-sm);
color: var(--color-accent);
text-decoration: none;
}
.comment-author:hover {
text-decoration: underline;
}
.comment-time {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-decoration: none;
}
.comment-time:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
@keyframes comment-highlight {
0% {
background: color-mix(in srgb, var(--color-accent) 18%, transparent);
}
100% {
background: transparent;
}
}
.comment-node--highlight {
border-radius: var(--radius-md);
animation: comment-highlight 2s ease-out forwards;
}
.comment-edited {
font-size: var(--text-2xs);
color: var(--color-text-muted);
opacity: 0.7;
font-style: italic;
}
.comment-body {
font-size: var(--text-md);
line-height: 1.65;
color: var(--color-text);
overflow-wrap: break-word;
word-break: break-word;
}
.comment-actions {
display: flex;
gap: 0.25rem;
margin-top: 0.5rem;
}
.comment-action-btn {
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 0.15rem 0.45rem;
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
font-family: inherit;
transition: color var(--dur-fast), border-color var(--dur-fast), background
var(--dur-fast);
}
.comment-action-btn[aria-disabled="true"] {
cursor: default;
}
.comment-action-btn:not([aria-disabled="true"]):hover {
color: var(--color-text);
border-color: var(--color-border-subtle);
background: color-mix(in srgb, var(--color-text) 6%, transparent);
}
.comment-delete-btn:hover,
.comment-action-btn--liked {
color: var(--color-danger);
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
}
.comment-action-btn--liked:hover {
color: var(--color-danger);
border-color: color-mix(in srgb, var(--color-danger) 60%, transparent);
background: color-mix(in srgb, var(--color-danger) 16%, transparent);
}
.like-count-clickable {
padding: 0.3rem;
margin: -0.3rem;
}
.comment-replies {
padding-left: max(0.4rem, calc(1.25rem - var(--depth, 0) * 0.1rem));
margin-left: max(0.25rem, calc(1.1rem - var(--depth, 0) * 0.09rem));
margin-top: 0.35rem;
border-left: 2px solid
color-mix(in srgb, var(--color-accent) 30%, transparent);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.comment-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.6rem;
}
.comment-top-form {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-subtle);
}
.comment-top-form-inner {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.comment-top-form-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.comment-reply-textarea {
width: 100%;
box-sizing: border-box;
background: var(--color-bg);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
padding: 0.55rem 0.75rem;
font-family: inherit;
font-size: var(--text-sm);
color: var(--color-text);
resize: none;
overflow: hidden;
min-height: 2.4rem;
transition: border-color var(--dur-base), box-shadow var(--dur-base);
}
.comment-reply-textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.comment-form-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.comment-node-inner--deleted {
opacity: 0.35;
}
.comment-node-inner--deleted:hover {
background: var(--color-surface);
}
.comment-avatar-placeholder {
border-radius: 50%;
background: var(--color-border-subtle);
}
.comment-deleted-placeholder {
font-size: var(--text-sm);
font-style: italic;
color: var(--color-text-muted);
margin: 0;
padding: 0.2rem 0;
}
/* ── User cards: the follow tab's users, the community's new members ── */
.user-card-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.user-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0.5rem;
border-radius: var(--radius-md);
}
.user-card-link {
display: flex;
align-items: center;
gap: 0.6rem;
text-decoration: none;
color: var(--color-text);
min-width: 0;
flex: 1;
}
.user-card-link:hover .user-card-name {
color: var(--color-accent);
}
.user-card-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-card-name,
.user-card-note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-card-name {
font-size: var(--text-md);
font-weight: 600;
}
.user-card-note {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
/* ── Invite tree ── */
/* Every row but the very first has the same gap above it, a child's as a
sibling's: the gap is the node's top padding, so the connectors can run
through it. --invite-tree-avatar-center is where a row's avatar is centered,
from the top of its node. */
.invite-tree {
--invite-tree-gap: 0.5rem;
--invite-tree-avatar-center: calc(var(--invite-tree-gap) + 12px);
list-style: none;
padding: 0;
margin: 0;
}
.invite-tree-node {
position: relative;
padding-top: var(--invite-tree-gap);
}
.invite-tree-node:first-child:not(.invite-tree-node .invite-tree-node) {
padding-top: 0;
}
/* A child list is indented so that the vertical connector, at its left edge,
is centered under the parent's 24px avatar (12px center - 1px half-line). */
.invite-tree-node > .invite-tree {
margin-left: 11px;
padding-left: calc(12px + 0.5rem);
}
/* Each child draws its own connectors, so they anchor to its avatar however
deep its subtree is. The vertical one drops from the row above to its
avatar center, and on through its subtree when a sibling follows. */
.invite-tree-node .invite-tree-node::before {
content: "";
position: absolute;
left: calc(-12px - 0.5rem);
top: 0;
height: var(--invite-tree-avatar-center);
width: 2px;
background: var(--color-border-subtle);
}
.invite-tree-node .invite-tree-node:not(:last-child)::before {
height: auto;
bottom: 0;
}
/* The horizontal one runs from the vertical line to the avatar. */
.invite-tree-node .invite-tree-node::after {
content: "";
position: absolute;
left: calc(-12px - 0.5rem);
top: calc(var(--invite-tree-avatar-center) - 1px);
width: calc(12px + 0.5rem);
height: 2px;
background: var(--color-border-subtle);
}
/* A block, not inline: in a line box, the theme's line height would add space
under the avatar and move it off --invite-tree-avatar-center. */
.invite-tree-user {
display: flex;
width: fit-content;
align-items: center;
gap: 0.5rem;
color: var(--color-text);
text-decoration: none;
font-size: var(--text-md);
font-weight: 600;
}
.invite-tree-user:hover {
color: var(--color-accent);
}
.invite-tree-empty {
color: var(--color-text-muted);
font-size: var(--text-md);
}
/* ── Follow button ── */
.follow-btn {
padding: 0.25rem 0.9rem;
border-radius: var(--radius-md);
border: 2px solid var(--color-accent);
background: transparent;
color: var(--color-accent);
cursor: pointer;
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: inherit;
transition: background var(--dur-base), color var(--dur-base);
}
.follow-btn:hover,
.follow-btn--following {
background: var(--color-accent);
color: var(--color-on-accent, #fff);
}
.follow-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.follow-btn--following:hover {
background: transparent;
color: var(--color-accent);
}
/* ── Followed feed layout ── */
.followed-feed {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.followed-sub-nav {
padding: 0.75rem 1.25rem;
align-self: center;
}
.followed-feed .dump-feed {
padding-top: 0;
}
.followed-feed .index-status {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
/* ── Journal masonry grid ── */
/* ══ Journal — editorial mosaic ══════════════════════════════════════
A composed scrapbook: one hero feature plus an asymmetric mix of
banners, portraits, squares and pull-quotes. Footprints are set by the
layout composer; how a card paints (image / quote / text) follows its
content. The composer clamps every footprint to the cells left in its row,
so the four columns close on every row with no gap to backfill — and
without `dense` moving a later card up into one, the order cards are read
in stays the order they were ranked in. */
/* The lane and its gutter belong to the feed, not to any one block: the
composer cuts the mosaic into several grids and they all have to line up
on the same edges. */
.journal-feed {
margin: 0 auto;
padding: 1.25rem;
max-width: 1180px;
width: 100%;
box-sizing: border-box;
}
.journal-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 9rem;
gap: 0.75rem;
}
/* ── Sub-block breaks ──────────────────────────────────────────────
Three custom properties are the whole contract a theme writes against:
the air around the break, the rule through it, and a glyph centred on
that rule. `none` on the last two is the real absence of each — no
border, and no pseudo-element at all — so a theme that wants no
separation zeroes the space and drops the rule, and the break collapses
to nothing rather than to an empty band. Themes wanting something a rule
can't be (a gradient, a short centred pill) set height and background
here directly; the properties are the convenient path, not the only one. */
.journal-break {
--journal-break-space: 1.6rem;
--journal-break-rule: 1px solid var(--color-border);
--journal-break-ornament: none;
position: relative;
height: 0;
margin: var(--journal-break-space) 0;
border: none;
border-top: var(--journal-break-rule);
overflow: visible;
}
/* Page-coloured, so the glyph opens a gap in the rule exactly as wide as
itself instead of sitting on top of it. */
.journal-break::before {
content: var(--journal-break-ornament);
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
padding: 0 0.8em;
background: var(--color-bg);
color: var(--color-text-muted);
font-family: var(--font-display, inherit);
font-size: var(--text-sm);
line-height: 1;
}
/* ── Footprints ── */
.journal-card--feature {
grid-column: span 2;
grid-row: span 2;
}
.journal-card--wide {
grid-column: span 2;
}
.journal-card--tall {
grid-row: span 2;
}
/* ── Shared shell ── */
.journal-card {
position: relative;
display: flex;
flex-direction: column;
border: var(--card-border-width) solid var(--card-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
overflow: hidden;
cursor: pointer;
min-width: 0;
transition: border-color var(--dur-base), transform var(--dur-base),
box-shadow var(--dur-base);
}
.journal-card:hover {
border-color: var(--card-hover-border);
transform: translateY(-2px);
box-shadow: var(--shadow-2);
}
.journal-card-title {
font-weight: 600;
font-size: var(--text-md);
color: var(--color-text);
text-decoration: none;
line-height: 1.3;
word-break: break-word;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.journal-card-title:hover {
color: var(--color-accent);
}
.journal-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.journal-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--text-xs);
color: var(--color-text-secondary);
flex-wrap: wrap;
min-width: 0;
}
.journal-card-vote {
flex-shrink: 0;
}
/* ── Image cards — full-bleed thumbnail, text laid over a scrim ── */
.journal-card--image .journal-card-image {
position: absolute;
inset: 0;
background: var(--color-bg);
}
.journal-card--image .journal-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* The play affordance is decorative — clicks fall through to the card, which
plays the embed. This keeps the hit target from being covered by the text
overlay on smaller card sizes.
Here the card *is* the play button and only the title opens the dump. The
badge sits in the top corner, clear of the text on every card size, and
speaks the list view's language: card hover wakes it a touch, and it
settles back while the pointer is on the title or the vote. */
.journal-card--image .rich-content-play-overlay {
pointer-events: none;
inset: 0.6rem 0.6rem auto auto;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
/* Keeps it findable on black letterbox bars. */
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
font-size: var(--text-xs);
line-height: 1;
transition:
background var(--dur-base) ease,
transform var(--dur-base) ease;
}
.journal-card--image:hover .rich-content-play-overlay {
transform: scale(1.15);
background: rgba(0, 0, 0, 0.75);
}
.journal-card--image:has(.journal-card-title:hover, .journal-card-vote:hover) .rich-content-play-overlay {
transform: none;
background: rgba(0, 0, 0, 0.6);
}
.journal-card--image .journal-card-overlay {
position: relative;
margin-top: auto;
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 1.4rem 0.8rem 0.65rem;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.55) 55%,
rgba(0, 0, 0, 0) 100%
);
}
/* The scrim is tuned for white ink at rest; the hover colour is whatever the
theme's accent is, which over a busy or bright thumbnail can sink into it.
So a pointed-at title picks up a backing of its own — a dark glass plate by
default, reskinned per theme through the --journal-title-hover-* props. The
padding is always there and cancelled by an equal negative margin, so the
plate grows around the text instead of shifting it; `align-self` keeps it
hugging the headline rather than spanning the whole card. */
.journal-card--image .journal-card-title {
--journal-title-hover-pad-y: 0.12em;
--journal-title-hover-pad-x: 0.35em;
--journal-title-hover-bg: rgb(0 0 0 / 0.66);
--journal-title-hover-color: var(--color-accent);
--journal-title-hover-radius: var(--radius-sm);
--journal-title-hover-shadow: none;
--journal-title-hover-filter: none;
--journal-title-hover-text-shadow: none;
align-self: flex-start;
max-width: calc(100% + 2 * var(--journal-title-hover-pad-x));
padding: var(--journal-title-hover-pad-y) var(--journal-title-hover-pad-x);
margin: calc(-1 * var(--journal-title-hover-pad-y))
calc(-1 * var(--journal-title-hover-pad-x));
border-radius: var(--journal-title-hover-radius);
color: #fff;
transition:
color var(--dur-base),
background-color var(--dur-base),
box-shadow var(--dur-base);
}
.journal-card--image .journal-card-title:hover {
color: var(--journal-title-hover-color);
background-color: var(--journal-title-hover-bg);
box-shadow: var(--journal-title-hover-shadow);
backdrop-filter: var(--journal-title-hover-filter);
text-shadow: var(--journal-title-hover-text-shadow);
}
.journal-card--image .journal-card-meta {
color: rgba(255, 255, 255, 0.78);
}
/* The resting vote button is transparent and inks itself in --color-text,
which in a light palette (or a muted dark one) sinks into the black scrim.
Over an image it takes the overlay's own white-on-glass treatment; the
voted state keeps its solid accent fill, which already reads. */
.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active) {
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
background: rgba(0, 0, 0, 0.35);
}
.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not([aria-disabled="true"]) {
color: var(--color-accent);
border-color: var(--color-accent);
}
.journal-card--image .journal-card-comment {
font-size: var(--text-sm);
line-height: 1.4;
margin: 0;
color: rgba(255, 255, 255, 0.85);
-webkit-line-clamp: 2;
line-clamp: 2;
}
.journal-card--feature .journal-card-title {
font-size: var(--text-xl);
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--square.journal-card--image .journal-card-title {
font-size: var(--text-sm);
}
/* ── Quote cards — the note is the subject, on an accent wash ── */
.journal-card--quote {
--journal-quote-pad: 0.9rem;
padding: var(--journal-quote-pad);
gap: 0.4rem;
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
}
/* Two things had this mark reading as a smudge in the corner rather than as
the note's own opening quote. The card clips its overflow, so hanging the
glyph off the top edge didn't bleed it past the corner — it cut away the
half that carries the ink. And a fixed offset put what was left outside the
text column, floating against the card's edge instead of lining up with the
words it belongs to. So: keep the whole glyph inside the card, and set it
against the same padding the note is set against. The leading above the ink
is what the top offset takes back, so the mark starts on the note's first
line rather than below it. */
.journal-card-quotemark {
position: absolute;
top: calc(var(--journal-quote-pad) - 0.75rem);
right: var(--journal-quote-pad);
font-family: var(--font-display, Georgia, "Times New Roman", serif);
font-size: 4.5rem;
line-height: 1;
color: var(--color-accent);
opacity: 0.22;
pointer-events: none;
}
.journal-card-quote-body {
position: relative;
z-index: var(--z-raised);
margin: 0;
font-size: var(--text-md);
line-height: 1.45;
color: var(--color-text);
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--wide.journal-card--quote .journal-card-quote-body,
.journal-card--feature.journal-card--quote .journal-card-quote-body {
font-size: var(--text-base);
-webkit-line-clamp: 2;
line-clamp: 2;
}
.journal-card-attribution {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-top: 0.5rem;
}
.journal-card--quote .journal-card-title {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-secondary);
-webkit-line-clamp: 1;
line-clamp: 1;
}
.journal-card--quote .journal-card-title::before {
content: "— ";
}
/* ── Text cards — typographic fallback for note-less, image-less dumps ── */
.journal-card--text {
padding: 0.85rem;
gap: 0.4rem;
}
.journal-card-glyph {
font-size: var(--text-2xl);
line-height: 1;
}
.journal-card--text .journal-card-title {
font-size: var(--text-md);
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--text .journal-card-footer {
margin-top: auto;
}
/* feed-end message */
.feed-end {
text-align: center;
padding: 1.5rem 1rem;
font-size: var(--text-sm);
color: var(--color-text-secondary);
opacity: 0.55;
}
/* Below the full width the composer's four-column tiling no longer describes
the grid, so a banner can meet a single free cell and wrap. Dense auto-flow
comes back to backfill what that leaves — worth the reordering here, where
the alternative is a hole. */
@media (max-width: 980px) {
.journal-grid {
grid-template-columns: repeat(3, 1fr);
grid-auto-flow: dense;
}
}
@media (max-width: 680px) {
.journal-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 460px) {
.journal-feed {
padding: 0.85rem 1rem;
}
.journal-grid {
grid-template-columns: 1fr;
grid-auto-rows: auto;
}
/* Collapse the mosaic into a single readable column. */
.journal-card {
grid-column: auto;
grid-row: auto;
}
.journal-card--image {
aspect-ratio: 16 / 10;
}
.journal-card--feature.journal-card--image {
aspect-ratio: 4 / 3;
}
}
/* ── Notification bell ── */
@keyframes bell-ring {
0% {
transform: rotate(0deg);
}
12% {
transform: rotate(-20deg);
}
28% {
transform: rotate(20deg);
}
42% {
transform: rotate(-14deg);
}
56% {
transform: rotate(10deg);
}
70% {
transform: rotate(-6deg);
}
84% {
transform: rotate(3deg);
}
100% {
transform: rotate(0deg);
}
}
/* ── Header icon buttons ──
Search (which opens the search, in the center column or the row under the
header on small viewports, and turns into its ✕), chat and the bell. */
.nav-search-btn,
.chat-button,
.notification-bell {
position: relative;
background: var(--color-header-user-bg);
color: inherit;
border: none;
cursor: pointer;
border-radius: var(--radius-lg);
transition: background var(--dur-base);
display: inline-flex;
align-items: center;
}
.nav-search-btn:hover,
.chat-button:hover,
.notification-bell:hover {
background: var(--color-header-user-bg-hover);
}
.notification-bell-icon {
transform-origin: 50% 10%;
}
.notification-bell--ringing .notification-bell-icon {
animation: bell-ring 0.65s cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
.notification-badge {
position: absolute;
top: -3px;
right: -3px;
background: var(--color-danger);
color: #fff;
font-size: var(--text-2xs);
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
line-height: 1;
pointer-events: none;
box-shadow: 0 0 0 2px var(--color-bg);
}
/* The header's buttons fill their icon while something is unread, so a dot
is enough; the count is in their label. */
.notification-badge--dot {
top: 6px;
right: 6px;
min-width: 0;
width: 8px;
height: 8px;
padding: 0;
background: var(--color-accent);
}
/* ── Notifications page ── */
.notifications-unread-pill {
font-size: var(--text-xs);
font-weight: 700;
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
color: var(--color-danger);
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
border-radius: var(--radius-xl);
padding: 0.2rem 0.65rem;
white-space: nowrap;
}
.notifications-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
}
.notifications-empty-icon {
font-size: var(--text-display);
display: block;
margin-bottom: 0.75rem;
opacity: 0.5;
}
.notifications-empty p {
margin: 0.25rem 0;
}
.notifications-empty-hint {
font-size: var(--text-sm);
max-width: 340px;
margin: 0.5rem auto 0 !important;
line-height: 1.5;
}
.notification-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.notification-item {
border-left: 3px solid transparent;
transition: background var(--dur-fast), border-color var(--dur-fast);
overflow: hidden;
}
.notification-item:hover {
background: color-mix(
in srgb,
var(--color-surface) 80%,
var(--color-text) 8%
);
}
.notification-item-link {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1rem;
text-decoration: none;
color: inherit;
width: 100%;
}
.notification-item--unread {
border-left-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface));
}
.notification-item--unread:hover {
background: color-mix(in srgb, var(--color-accent) 14%, var(--color-surface));
}
.notif-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent);
box-shadow: 0 0 5px color-mix(in srgb, var(--color-accent) 70%, transparent);
flex-shrink: 0;
align-self: center;
}
.notif-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-md);
flex-shrink: 0;
}
.notif-icon--upvote {
background: color-mix(in srgb, #f59e0b 30%, transparent);
color: #f59e0b;
}
.notif-icon--follow {
background: color-mix(in srgb, #8b5cf6 30%, transparent);
color: #8b5cf6;
}
.notif-icon--dump {
background: color-mix(in srgb, #3b82f6 30%, transparent);
color: #3b82f6;
}
.notif-icon--playlist {
background: color-mix(in srgb, #10b981 30%, transparent);
color: #10b981;
}
.notif-icon--mention {
background: color-mix(in srgb, #ec4899 30%, transparent);
color: #ec4899;
font-weight: 700;
font-family: var(--font-mono, monospace);
}
.notif-icon--comment {
background: color-mix(in srgb, #0ea5e9 30%, transparent);
color: #0ea5e9;
}
.notif-icon--like {
background: color-mix(in srgb, #f43f5e 30%, transparent);
color: #f43f5e;
}
.notification-body {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.75rem;
min-width: 0;
}
.notification-content {
flex: 1;
font-size: var(--text-sm);
line-height: 1.5;
min-width: 0;
}
.notification-time {
font-size: var(--text-2xs);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.load-more-btn {
display: block;
margin: 1.5rem auto 0;
padding: 0.5rem 1.5rem;
background: none;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-text-muted);
transition: border-color var(--dur-base), color var(--dur-base);
}
.load-more-btn:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-accent);
}
.load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Mention autocomplete ── */
.mention-textarea-wrap {
position: relative;
}
.mention-textarea-wrap textarea {
width: 100%;
box-sizing: border-box;
}
/* The textarea and the emoji button in its corner. */
.text-editor-field {
position: relative;
}
/* Room for the button, so text never runs under it. Qualified to outrank the
per-editor and per-theme padding shorthands. */
.mention-textarea-wrap .text-editor-field > textarea {
padding-right: 2.25rem;
}
/* Top corner, not bottom: the bottom one is a resizable textarea's grip. */
.text-editor-emoji-btn {
position: absolute;
right: 0.3rem;
top: 0.3rem;
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-muted);
opacity: 0.6;
cursor: pointer;
transition: opacity var(--dur-fast), color var(--dur-fast),
background var(--dur-fast);
}
.text-editor-emoji-btn svg {
width: 1.15rem;
height: 1.15rem;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
}
.text-editor-emoji-btn svg circle[r=".6"] {
fill: currentColor;
}
.text-editor-emoji-btn:hover:not(:disabled),
.text-editor-emoji-btn:focus-visible,
.text-editor-emoji-btn--open {
opacity: 1;
color: var(--color-accent);
}
.text-editor-emoji-btn:disabled {
opacity: 0.25;
cursor: default;
}
/* Clear of the scrollbar a long chat message grows. */
.chat-input + .text-editor-emoji-btn {
right: 0.6rem;
}
.text-editor-footer {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-top: 2px;
}
.text-editor-footer .text-editor-count {
margin: 0 0 0 auto;
}
.text-editor-notice {
font-size: var(--text-2xs);
color: var(--color-text-muted);
}
.text-editor-notice--error {
color: var(--color-danger, #c0392b);
}
.mention-textarea-wrap--dragover textarea {
outline: 2px dashed var(--color-accent, #6c8ebf);
outline-offset: -2px;
}
.input-with-count {
display: flex;
flex-direction: column;
width: 100%;
}
.text-editor-count {
display: block;
text-align: right;
font-size: var(--text-2xs);
color: var(--color-text-muted);
margin-top: 2px;
user-select: none;
}
.text-editor-count--warn {
color: var(--color-warning, #c97a00);
}
.text-editor-count--danger {
color: var(--color-danger, #c0392b);
font-weight: 600;
}
/* Portaled to <body> and positioned from JS (see useAnchoredFloat), for the
same reason as the emoji picker below: the editor sits inside cards, modal
bodies and scrollers whose overflow would clip an absolutely positioned
popup. Above the modal backdrop (1000), below tooltips (9999). */
.mention-dropdown {
position: fixed;
z-index: var(--z-popover);
max-width: calc(100vw - 16px);
list-style: none;
margin: 0;
padding: 4px 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-2);
max-height: var(--float-max-h, 220px);
overflow-y: auto;
}
.mention-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
transition: background var(--dur-fast);
}
.mention-dropdown-item:hover,
.mention-dropdown-item--selected {
background: var(--color-bg);
}
.mention-dropdown-username {
font-size: var(--text-md);
color: var(--color-text);
}
/* Stands where an avatar would, for @everyone. */
.mention-dropdown-everyone {
width: 20px;
text-align: center;
font-size: 14px;
line-height: 20px;
}
/* ── Emoji picker (frimousse) ── */
/* Portaled to <body> and positioned from JS (see useAnchoredFloat): anchored
with `position: fixed` so cards, modal bodies and scrollers can't clip it.
Above the modal backdrop (1000), below tooltips (9999). */
.emoji-picker-float {
position: fixed;
z-index: var(--z-popover);
width: 320px;
max-width: calc(100vw - 16px);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-2);
overflow: hidden;
}
.emoji-picker-search-row {
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.emoji-picker-float input {
display: block;
flex: 1;
min-width: 0;
padding: 8px 10px;
border: none;
background: transparent;
color: var(--color-text);
font-size: var(--text-md);
outline: none;
}
/* The row has its own ✕; the browser's clear button would be a second one. */
.emoji-picker-float input::-webkit-search-cancel-button {
display: none;
}
.emoji-picker-float input::placeholder {
color: var(--color-text-muted, #888);
}
.emoji-picker-float [frimousse-skin-tone-selector] {
flex-shrink: 0;
padding: 0 6px;
height: 100%;
background: none;
border: none;
font-size: var(--text-md);
line-height: 1;
cursor: pointer;
border-radius: var(--radius-sm);
}
.emoji-picker-float [frimousse-skin-tone-selector]:hover {
background: var(--color-surface);
}
.emoji-picker-close-btn {
flex-shrink: 0;
padding: 0 10px;
height: 100%;
background: none;
border: none;
color: var(--color-text);
opacity: 0.45;
cursor: pointer;
font-size: var(--text-xs);
line-height: 1;
}
.emoji-picker-close-btn:hover {
opacity: 1;
}
/* frimousse uses bare attributes (no data- prefix) */
.emoji-picker-float [frimousse-viewport] {
max-height: var(--float-max-h, 220px);
/* frimousse already sets overflow-y: auto inline */
}
.emoji-picker-float [frimousse-category-header] {
padding: 4px 8px 2px;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted, #888);
background: var(--color-surface);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.emoji-picker-float [frimousse-emoji] {
flex: 0 0 calc(100% / var(--frimousse-list-columns, 9));
background: none;
border: none;
padding: 4px 0;
font-size: 1.35em;
line-height: 1;
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--dur-fast);
}
.emoji-picker-float [frimousse-emoji]:hover,
.emoji-picker-float [frimousse-emoji][data-active] {
background: var(--color-bg);
}
.emoji-picker-float [frimousse-loading],
.emoji-picker-float [frimousse-empty] {
display: block;
padding: 16px;
text-align: center;
font-size: var(--text-sm);
color: var(--color-text-muted, #888);
}
/* ── Tooltip ── */
.tooltip {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border-subtle);
padding: 0.3em 0.65em;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-style: normal;
white-space: nowrap;
pointer-events: none;
z-index: var(--z-toast);
box-shadow: var(--shadow-2);
/* The inline `transform` places it above the anchor, so the entrance rides
on the independent `translate`/`scale` properties, which compose with it.
A short delay keeps it from flashing while the pointer sweeps across. */
transform-origin: 50% 100%;
animation: tooltip-in 160ms cubic-bezier(0.2, 0.7, 0.3, 1) 120ms both;
}
@keyframes tooltip-in {
from {
opacity: 0;
translate: 0 4px;
scale: 0.96;
}
to {
opacity: 1;
translate: 0 0;
scale: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tooltip {
animation-name: tooltip-fade;
}
@keyframes tooltip-fade {
from {
opacity: 0;
}
}
}
/* ── Search bar (in header center slot) ── */
.search-bar {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
max-width: 420px;
}
.search-bar--collapsible {
width: auto;
max-width: none;
}
.search-bar--collapsible.search-bar--expanded {
width: 100%;
max-width: 420px;
}
.search-bar-input {
flex: 1;
padding: 0.3rem 0.7rem;
border-radius: var(--radius-lg);
border: var(--border-width) solid var(--color-border-subtle);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
font-family: inherit;
outline: none;
transition:
max-width 0.25s ease,
opacity var(--dur-slow) ease,
padding 0.25s ease,
border-width 0.25s ease,
border-color var(--dur-base);
min-width: 0;
}
/* Collapsed state: hide the input while keeping it in the DOM */
.search-bar--collapsible .search-bar-input {
max-width: 0;
opacity: 0;
padding-left: 0;
padding-right: 0;
border-width: 0;
pointer-events: none;
}
/* Expanded state: animate it open */
.search-bar--collapsible.search-bar--expanded .search-bar-input {
max-width: 380px;
opacity: 1;
padding-left: 0.7rem;
padding-right: 0.7rem;
border-width: 2px;
pointer-events: auto;
}
.search-bar-input:focus {
border-color: var(--color-accent);
}
.search-bar-btn {
display: flex;
align-items: center;
padding: 0.3rem 0.55rem;
background: transparent;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-lg);
cursor: pointer;
font-size: var(--text-sm);
flex-shrink: 0;
transition: border-color var(--dur-base);
}
.search-bar-btn:hover {
border-color: var(--color-accent);
}
/* ── Search page ── */
.search-page {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 3rem;
}
.search-tabs {
width: 100%;
max-width: 860px;
padding: 1rem 1.25rem 0;
box-sizing: border-box;
}
/* The category menu of the dumps and playlists tabs, at the end of the
results' column, as at the end of a profile list's title row. */
.search-filter {
display: flex;
justify-content: flex-end;
width: 100%;
max-width: 860px;
padding: 0.75rem 1.25rem 0;
box-sizing: border-box;
}
.search-filter:empty {
display: none;
}
.search-page-empty {
padding: 3rem 1.25rem;
color: var(--color-text-secondary);
text-align: center;
font-size: var(--text-md);
}
/* ── User results ── */
.user-results {
list-style: none;
margin: 0;
padding: 0.75rem 1.25rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 860px;
width: 100%;
box-sizing: border-box;
}
.user-result-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border-radius: var(--radius-xl);
border: var(--border-width) solid var(--color-border-subtle);
text-decoration: none;
color: var(--color-text);
transition: border-color var(--dur-base);
}
.user-result-item:hover {
border-color: var(--color-accent);
}
.user-result-name {
font-weight: 600;
font-size: var(--text-md);
}
.user-result-description {
font-size: var(--text-sm);
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Profile appearance settings ── */
/* Labels in one column, controls in the rest of the width. Each control keeps
its own width, up to the column's, past which a segmented control wraps. */
.profile-appearance-grid {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 0.75rem 1rem;
align-items: center;
justify-items: start;
}
.profile-appearance-row {
display: contents;
}
/* On a phone the label goes above its control, and the segmented controls
take the whole width, so they line up and a long one wraps evenly. */
@media (max-width: 512px) {
.profile-appearance-grid {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.profile-appearance-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.3rem;
}
.profile-appearance-row > .visibility-toggle {
align-self: stretch;
}
}
.profile-appearance-value {
font-weight: 600;
}
.profile-appearance-label {
font-size: var(--text-sm);
font-weight: 600;
opacity: 0.7;
}
.profile-appearance-note {
margin: 0.75rem 0 0;
}
/* ── Instances (/manage, /settings) ─────────────────────────────────────── */
.peer-manager {
display: flex;
flex-direction: column;
gap: 1rem;
}
.peer-name {
display: flex;
flex-direction: column;
min-width: 0;
overflow-wrap: anywhere;
}
.peer-host {
font-weight: 400;
font-size: var(--text-xs);
}
.peer-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.peer-add {
margin: 0;
}
.peer-add-actions {
display: flex;
gap: 0.5rem;
}
.peer-add-actions > * {
flex: 1;
}
/* ── Categories ─────────────────────────────────────────────────────────── */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* Scope switcher — the leading item of the feed-sort tab row, and in the
title row of a profile's lists. The trigger reuses .feed-sort-btn so it
shares the tabs' exact pill metrics; in the tab row, a thin divider sets it
apart from the sort tabs that follow. */
.category-switcher {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.category-switcher-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.category-switcher-btn:hover,
.category-switcher-btn--open {
border-color: var(--color-accent);
color: var(--color-text);
}
.category-switcher-label {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Caret tracks the open state. */
.category-switcher-caret {
width: 0;
height: 0;
border-left: 0.26rem solid transparent;
border-right: 0.26rem solid transparent;
border-top: 0.3rem solid currentColor;
transition: transform var(--dur-base) ease;
}
.category-switcher-btn--open .category-switcher-caret {
transform: rotate(180deg);
}
/* Vertical rule separating the scope selector from the sort tabs. */
.feed-sort .category-switcher::after {
content: "";
width: 2px;
height: 1.05rem;
background: var(--color-border);
border-radius: var(--radius-xs);
flex-shrink: 0;
}
/* Themed popover (fixed-positioned so the row's overflow can't clip it). */
.category-switcher-menu {
position: fixed;
z-index: var(--z-dropdown);
min-width: 11rem;
max-height: 60vh;
overflow-y: auto;
margin: 0;
padding: 0.35rem;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.15rem;
background: var(--color-surface);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-2);
}
.category-switcher-option {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.75rem;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
cursor: pointer;
transition: background var(--dur-fast);
}
.category-switcher-option:hover {
background: var(--color-header-user-bg-hover);
}
.category-switcher-option--active {
background: var(--color-accent);
color: var(--color-on-accent);
}
/* Toggle chips used in the dump create/edit forms */
.category-select {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.category-chip-toggle {
padding: 0.25rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-bg);
color: var(--color-text-secondary);
font-size: var(--text-sm);
cursor: pointer;
}
.category-chip-toggle:hover {
border-color: var(--color-accent);
}
.category-chip-toggle--active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-on-accent);
}
/* Read-only category chips on dump and playlist cards and pages */
.category-links {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.35rem;
}
.category-link {
padding: 0.1rem 0.5rem;
border-radius: var(--radius-full);
background: var(--color-header-user-bg);
color: var(--color-text-secondary);
text-decoration: none;
font-size: var(--text-xs);
}
.category-link:hover {
background: var(--color-accent);
color: var(--color-on-accent);
}
/* Admin category management — chips that expand into an inline editor */
.category-chips {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.5rem;
}
.category-chip {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-md);
font-family: inherit;
line-height: 1.4;
cursor: pointer;
transition: border-color var(--dur-base), background var(--dur-base), color
var(--dur-base), transform var(--dur-fast);
}
.category-chip:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-accent);
}
.category-chip:active:not(:disabled) {
transform: scale(0.96);
}
.category-chip:disabled {
opacity: 0.4;
cursor: default;
}
/* Inherits the base chip's font-size/padding so its height matches exactly;
only the glyph weight/color set it apart. */
.category-chip--add {
font-weight: 700;
color: var(--color-text-secondary);
}
.category-chip--add:hover:not(:disabled) {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-on-accent);
}
/* Inline editor: the expanded form of a chip */
.category-editor-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.category-editor {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.3rem 0.2rem 0.7rem;
border: 1px solid var(--color-accent);
border-radius: var(--radius-full);
background: var(--color-bg);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 16%, transparent);
transform-origin: left center;
animation: category-editor-in 0.18s ease-out;
}
@keyframes category-editor-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.category-editor-input {
min-width: 0;
border: none;
background: transparent;
color: var(--color-text);
font-size: var(--text-md);
font-family: inherit;
padding: 0.2rem 0;
outline: none;
}
.category-editor-input--name {
width: 8.5rem;
font-weight: 600;
}
.category-editor-input--slug {
width: 6.5rem;
font-family: var(--font-mono, monospace);
color: var(--color-text-secondary);
}
.category-editor-sep {
color: var(--color-text-secondary);
opacity: 0.5;
}
.category-editor-actions {
display: inline-flex;
align-items: center;
gap: 0.1rem;
}
.category-editor-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: var(--color-text-secondary);
font-size: var(--text-xs);
line-height: 1;
cursor: pointer;
transition: background var(--dur-base), color var(--dur-base), filter
var(--dur-base);
}
.category-editor-btn:hover:not(:disabled) {
background: var(--color-header-user-bg);
}
.category-editor-btn:disabled {
opacity: 0.4;
cursor: default;
}
.category-editor-btn--save {
background: var(--color-accent);
color: var(--color-on-accent);
}
.category-editor-btn--save:hover:not(:disabled) {
filter: brightness(1.1);
}
.category-editor-btn--save:disabled {
background: var(--color-border);
color: var(--color-text-secondary);
opacity: 1;
}
.category-editor-btn--delete:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-danger, #c0392b) 18%, transparent);
color: var(--color-danger, #c0392b);
}
/* ── Community page ── */
.community-figures {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin: 0;
}
.community-figure {
display: flex;
flex-direction: column-reverse;
/* In column-reverse, flex-end is the top: the figures stay on one line
when a label wraps to two. */
justify-content: flex-end;
align-items: center;
gap: 0.2rem;
min-width: 0;
}
.community-figure-value {
margin: 0;
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.community-figure-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-align: center;
}
@media (max-width: 480px) {
.community-figures {
grid-template-columns: repeat(2, 1fr);
}
}