From ea478369a672d698afbaa0713ee5ad898e80c08d Mon Sep 17 00:00:00 2001 From: Olav Groenaas Gjerde Date: Sat, 8 Aug 2026 20:27:59 +0200 Subject: [PATCH] refactor(css): remove duplicated rules and fix three destructive-button hovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.css had accumulated rules written twice: sibling components with byte-identical declaration blocks, and one full second copy of every theme palette. Deduped without changing what renders — verified by diffing the flattened rule set (every selector expanded out of its selector list, declarations compared as a set) against HEAD, and by hand-checking cascade order wherever equal-specificity rules compete. Merged, one skin instead of N copies: - Archive / Leave / Delete-forever: 6 blocks that existed three times - Sidebar rows: .channel-list and .dm-list shared hover, active, unread, muted and name truncation — six blocks that existed twice - Search dropdown and /search results: the "not joined" pill, the tint, file-chip icons, snippet block-spacing - .profile-content/.admin-content, the poll/about modal backdrops, .logo-mark, .send-icon sizing, the ADMIN role pill Theme swatches now read the theme's own tokens. The swatch element carries that theme's data-theme and the [data-theme] blocks are attribute-only selectors, so it already resolves the right values; the 60 hardcoded hex codes were a second copy of every palette — two places to change a colour and one place to forget. A theme added to the picker now gets a correct swatch for free. All 60 stripes verified to resolve to the colour they did before. Removed as dead: a second `padding` on .day-divider span, a duplicate `.message { position: relative }`, .composer-preview's restatement of .message-body, `min-width: 0` on .content inside a media query where the base rule already set it, repeated `cursor: pointer` on emoji-picker hovers, and a comment paragraph printed twice. Three var() references to custom properties that have never existed (--hover-bg, --surface, --text-muted) always rendered their fallback. Collapsed to that value so the code says what it does, with a comment naming the real token; the colours are unchanged. Fixes: archive, admin-danger and call-end buttons lost their colour on hover. Each declared only `filter: brightness()`, so the background fell through to `button:hover` (0,1,1) or `.call-btn:hover` (0,2,0), which out-specify a single class — the two red buttons pressed in a hurry (decline, hang up) and Suspend/Clear messages repainted accent blue or a neutral tint. .channel-leave-danger and .channel-destroy-danger already restated their own background inside :hover; the same fix was applied twice and missed three times, which is the drift the duplication hid. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/resources/static/css/app.css | 577 ++++++++++---------------- 1 file changed, 208 insertions(+), 369 deletions(-) diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index b886321..2669507 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -151,14 +151,11 @@ html, body { margin: 0; padding: 0; height: 100%; - /* Explicit cross-platform UI font stack. We avoid the `system-ui` and `-apple-system` - generic keywords because Firefox's resistFingerprinting probes their OS-specific - fallbacks (e.g. macOS "Hiragino Kaku Gothic ProN" for CJK) and logs each blocked - probe to the console. Listing real font names skips that path entirely. */ - /* Figtree (self-hosted, above) leads; the explicit system fallbacks avoid the - `system-ui` keyword (Firefox resistFingerprinting probes its OS-specific fallbacks - and logs console noise), and the explicit emoji families keep emoji from walking - the platform fallback list for the same reason. */ + /* Figtree (self-hosted, above) leads, then an explicit cross-platform stack. We avoid the + `system-ui` and `-apple-system` generic keywords because Firefox's resistFingerprinting + probes their OS-specific fallbacks (e.g. macOS "Hiragino Kaku Gothic ProN" for CJK) and + logs each blocked probe to the console; listing real font names skips that path entirely. + The explicit emoji families keep emoji off the platform fallback list for the same reason. */ font-family: "Figtree", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; background: var(--bg); @@ -348,14 +345,17 @@ input:focus, textarea:focus, select:focus { /* Let the wordmark shrink-and-ellipsize instead of overflowing the column. */ min-width: 0; } -.topbar-left .logo .logo-mark { - /* Height-only sizing: width follows the image's aspect ratio, so an uploaded - wide wordmark-style logo renders at its natural proportions. max-width guards - the bar against extreme banners; object-fit keeps a capped undistorted. */ +/* Height-only sizing: width follows the image's aspect ratio, so an uploaded wide + wordmark-style logo renders at its natural proportions. max-width guards the bar against + extreme banners; object-fit keeps a capped undistorted. Shared by the topbar mark and + the admin console's current/preview slots, so the preview matches what the bar will show. */ +.logo-mark { height: 32px; width: auto; max-width: 180px; object-fit: contain; +} +.topbar-left .logo .logo-mark { display: block; flex: 0 0 auto; } @@ -735,12 +735,22 @@ a.me:hover { text-decoration: none; background: var(--sb-bg-2); } it is still secondary to the channel name. */ .channel-star { opacity: 0.55; } } -.channel-list a:hover { +/* ---------- Sidebar row skin ---------- + Channels and direct messages are two lists of the same thing — a room you can be in, with the + same hover, the same active highlight and the same unread cues. They are styled together rather + than side by side so the two lists cannot drift into looking like two different controls. The + DM section further down adds only what is genuinely DM-specific (avatar, tags, empty state). + Order matters here and is load-bearing: :hover and .active are the same specificity, so .active + must come second to win on an active row you are pointing at, and the unread rule comes after + both because an unread active row should still read as unread. */ +.channel-list a:hover, +.dm-list > li > a:hover { background: var(--sb-bg-2); color: var(--sb-text); text-decoration: none; } -.channel-list a.active { +.channel-list a.active, +.dm-list > li > a.active { background: var(--sb-active); color: var(--sb-active-text); font-weight: 600; @@ -750,7 +760,8 @@ a.me:hover { text-decoration: none; background: var(--sb-bg-2); } opacity: 0.7; font-weight: 400; } -.channel-list .channel-name { +.channel-list .channel-name, +.dm-name { flex: 1 1 auto; min-width: 0; overflow: hidden; @@ -770,18 +781,24 @@ a.me:hover { text-decoration: none; background: var(--sb-bg-2); } exists" — and someone who has learned to ignore all of them ignores the one that mattered. Both surfaces (Thymeleaf here, index.js live) set data-unread-cue from the same rule in ChannelSidebarDto.unreadCue, so a reload never changes what you were looking at. */ -.channel-list li[data-unread-cue="bold"] > a { +.channel-list li[data-unread-cue="bold"] > a, +.dm-list li.has-unread > a { color: var(--sb-text); font-weight: 600; } -.channel-list li[data-unread-cue="bold"] > a .channel-name { font-weight: 700; } +.channel-list li[data-unread-cue="bold"] > a .channel-name, +.dm-list li.has-unread .dm-name { font-weight: 700; } /* Muted: dimmed, never bold, and marked. The unread is still counted behind this — muting means "stop telling me", not "pretend nothing happened" — it just does not interrupt. Hover restores - full strength so the row is still readable when you go looking for it. */ -.channel-list li[data-muted="true"] > a { opacity: 0.55; } + full strength so the row is still readable when you go looking for it. The unread badge is + deliberately left undimmed: the count is still true. */ +.channel-list li[data-muted="true"] > a, +.dm-list li[data-muted="true"] > a { opacity: 0.55; } .channel-list li[data-muted="true"] > a:hover, -.channel-list li[data-muted="true"] > a.active { opacity: 1; } +.channel-list li[data-muted="true"] > a.active, +.dm-list li[data-muted="true"] > a:hover, +.dm-list li[data-muted="true"] > a.active { opacity: 1; } .channel-muted-marker { flex-shrink: 0; opacity: 0.8; } .unread-badge { @@ -1032,10 +1049,23 @@ a.me:hover { text-decoration: none; background: var(--sb-bg-2); } color: var(--muted); } .channel-rename label + input { margin-bottom: 0.35rem; } -.channel-rename input { +/* Every field in the settings panel: full width, body-size text — a select and a text box that + disagree about either look like they belong to different forms. */ +.channel-rename input, +.channel-notify select, +.channel-destroy-confirm input { width: 100%; font-size: var(--fs-md); } +/* Caption above a field. The rename block is a grid and spaces its own rows, so it only needs + the type; the other two are ordinary flow and have to make the line themselves. */ +.channel-notify label, +.channel-destroy-confirm label { + display: block; + margin-bottom: 0.25rem; + font-size: var(--fs-sm); + color: var(--muted); +} .channel-rename-note { margin: 0.15rem 0 0; font-size: var(--fs-xs); @@ -1059,42 +1089,46 @@ a.me:hover { text-decoration: none; background: var(--sb-bg-2); } color: var(--muted); } .channel-rename-status[hidden] { display: none; } -.channel-rename-status.is-error { color: var(--danger); } /* Notification level picker in the channel settings panel. It had no rule of its own, so the select sat flush against the invite form below it. */ .channel-notify { margin-bottom: 10px; } -.channel-notify label { - display: block; - margin-bottom: 0.25rem; - font-size: var(--fs-sm); - color: var(--muted); -} -.channel-notify select { - width: 100%; - font-size: var(--fs-md); -} .channel-notify-status { margin: 0.35rem 0 0; font-size: var(--fs-xs); color: var(--muted); } .channel-notify-status[hidden] { display: none; } +/* Both inline save-status lines turn red on a refusal. */ +.channel-rename-status.is-error, .channel-notify-status.is-error { color: var(--danger); } -/* ---------- Leave channel ---------- - Bottom of the channel settings panel, below a rule: it is the destructive action in a panel of - ordinary ones, and putting it inside the same visual group as "invite a member" is how people - click it by accident. Quiet until hovered — red-by-default in a panel you open to change a - notification setting reads as an alarm about nothing. */ -.channel-leave { +/* ---------- Archive / Leave / Delete forever ---------- + The three closing blocks of the channel settings panel. Each is a rule, a quiet full-width + trigger and an inline confirm step, so they share one skin here and state below only what + legitimately differs: the trigger's hover colour and the confirm button. + + Quiet until hovered, all three: a permanently red button in a panel people open to change a + notification setting is an alarm that stops being read. Each gets its own rule above it rather + than sharing one group, because archiving is a decision about the channel, leaving is a decision + about yourself, and deleting is the only one of the three with no undo — stacked into a single + visual group each reads as a milder version of the one above, which is how people click them by + accident. The markup order is archive, then leave, then delete forever, for the same reason. + + (.channel-destroy's own extras — the confirm field and the disabled state — stay with the + "Delete forever" block below.) */ +.channel-leave, +.channel-archive, +.channel-destroy { margin-top: 0.9rem; padding-top: 0.75rem; border-top: 1px solid var(--border); } -.channel-leave-trigger { +.channel-leave-trigger, +.channel-archive-trigger, +.channel-destroy-trigger { display: flex; align-items: center; gap: 0.45rem; @@ -1104,71 +1138,51 @@ a.me:hover { text-decoration: none; background: var(--sb-bg-2); } color: var(--muted); font-size: var(--fs-md); } -.channel-leave-trigger:hover { +/* Leaving and deleting hover red. Archiving is reversible, so it only firms up. */ +.channel-leave-trigger:hover, +.channel-destroy-trigger:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); } -.channel-leave-confirm[hidden] { display: none; } -.channel-leave-confirm p { - margin: 0.6rem 0; - font-size: var(--fs-sm); - line-height: 1.45; - color: var(--muted); -} -.channel-leave-confirm strong { color: var(--text); } -.channel-leave-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; -} -.channel-leave-danger { background: var(--danger); } -.channel-leave-danger:hover { background: var(--danger); filter: brightness(0.9); } -/* ---------- Archive / unarchive ---------- - Above the leave block and below a rule of its own: archiving is a decision about the channel, - leaving is a decision about yourself, and stacking them in one group makes the second look like a - milder version of the first. Quiet until hovered, like the leave trigger — a panel opened to - change a notification setting should not be shouting. */ -.channel-archive { - margin-top: 0.9rem; - padding-top: 0.75rem; - border-top: 1px solid var(--border); -} -.channel-archive-trigger { - display: flex; - align-items: center; - gap: 0.45rem; - width: 100%; - background: transparent; - border: 1px solid var(--border); - color: var(--muted); - font-size: var(--fs-md); -} .channel-archive-trigger:hover { background: var(--hover); border-color: var(--border-strong); color: var(--text); } -.channel-archive-confirm[hidden] { display: none; } -.channel-archive-confirm p { +.channel-leave-confirm[hidden], +.channel-archive-confirm[hidden], +.channel-destroy-confirm[hidden] { display: none; } +.channel-leave-confirm p, +.channel-archive-confirm p, +.channel-destroy-confirm p { margin: 0.6rem 0; font-size: var(--fs-sm); line-height: 1.45; color: var(--muted); } -.channel-archive-confirm strong { color: var(--text); } -.channel-archive-actions { +.channel-leave-confirm strong, +.channel-archive-confirm strong, +.channel-destroy-confirm strong { color: var(--text); } +.channel-leave-actions, +.channel-archive-actions, +.channel-destroy-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } +/* The confirm buttons. Leave and delete restate their own background inside :hover because the + global `button:hover` is (0,1,1) and out-specifies a single class — without it the red button + repaints accent-blue under the pointer. */ +.channel-leave-danger { background: var(--danger); } +.channel-leave-danger:hover { background: var(--danger); filter: brightness(0.9); } /* Amber rather than red. Archiving is reversible and deleting is not, and if both buttons are the same colour the one that matters stops standing out. */ .channel-archive-danger { background: var(--warning); color: #fff; } -.channel-archive-danger:hover { filter: brightness(0.92); } +.channel-archive-danger:hover { background: var(--warning); filter: brightness(0.92); } /* The chip beside the channel name, and the notice where the composer would be. */ .channel-archived-chip { @@ -1219,59 +1233,16 @@ a.me:hover { text-decoration: none; background: var(--sb-bg-2); } } /* ---------- Delete forever ---------- - Last in the panel, below the leave block, because it is the only thing here with no undo. Quiet - until hovered like its neighbours — a permanently red button in a panel people open to change a - notification setting is an alarm that stops being read. */ -.channel-destroy { - margin-top: 0.9rem; - padding-top: 0.75rem; - border-top: 1px solid var(--border); -} -.channel-destroy-trigger { - display: flex; - align-items: center; - gap: 0.45rem; - width: 100%; - background: transparent; - border: 1px solid var(--border); - color: var(--muted); - font-size: var(--fs-md); -} -.channel-destroy-trigger:hover { - background: var(--danger-soft); - border-color: var(--danger); - color: var(--danger); -} -.channel-destroy-confirm[hidden] { display: none; } -.channel-destroy-confirm p { - margin: 0.6rem 0; - font-size: var(--fs-sm); - line-height: 1.45; - color: var(--muted); -} -.channel-destroy-confirm strong { color: var(--text); } -.channel-destroy-confirm label { - display: block; - margin-bottom: 0.25rem; - font-size: var(--fs-sm); - color: var(--muted); -} -.channel-destroy-confirm input { - width: 100%; - font-size: var(--fs-md); - margin-bottom: 0.5rem; -} + Last in the panel because it is the only thing here with no undo. The rule, trigger, confirm + copy and action row come from the shared block above; what is specific to deleting is the + "type the channel name" field and the button that stays inert until it matches. */ +.channel-destroy-confirm input { margin-bottom: 0.5rem; } .channel-destroy-status { margin: 0 0 0.5rem; font-size: var(--fs-xs); color: var(--danger); } .channel-destroy-status[hidden] { display: none; } -.channel-destroy-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; -} .channel-destroy-danger { background: var(--danger); } .channel-destroy-danger:hover:not(:disabled) { background: var(--danger); filter: brightness(0.9); } /* Disabled until the typed name matches. Visibly inert rather than merely unclickable — a button @@ -1534,7 +1505,6 @@ button.icon-btn > .icon { background: var(--border); } .day-divider span { - padding: 0 0.8rem; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-full); @@ -1706,12 +1676,10 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af border-radius: var(--radius-sm); color: inherit; font: inherit; + transition: background-color 0.1s; } /* Hover/focus tint — var(--hover), not var(--bg): in the light theme --bg equals the menu panel's white, which made the old hover invisible. */ -.presence-menu-item { - transition: background-color 0.1s; -} .presence-menu-item:hover, .presence-menu-item:focus { background: var(--hover); @@ -1770,7 +1738,6 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af border-radius: var(--radius-sm); } - /* When the user has uploaded a profile picture the renderer adds an inside the .avatar wrapper. The image covers the wrapper completely and obscures the fallback initial; if the image fails to load it stays hidden via onerror so @@ -2121,9 +2088,8 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af font-weight: 700; margin-bottom: 0.25rem; } -.composer-preview .message-body { line-height: 1.45; color: var(--text); } -.composer-preview .message-body p:first-child { margin-top: 0; } -.composer-preview .message-body p:last-child { margin-bottom: 0; } +/* The preview renders the same .message-body the sent message will, so it needs no type rules + of its own — see the "Messages" section. */ .composer-toolbar { display: flex; @@ -2167,11 +2133,6 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af font-size: var(--fs-md); border-radius: var(--radius-sm); } -.composer-toolbar .send-btn .send-icon { - width: 14px; - height: 14px; - flex-shrink: 0; -} /* Explicit hover: the 2-class background rule above out-specifies the global button:hover, so without this the send button had no hover feedback at all. */ .composer-toolbar .send-btn:hover:not(:disabled) { @@ -2183,8 +2144,11 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af box-shadow: none; } /* Paper-plane micro-interaction: a small ready-for-takeoff tilt while hovering. - Shared with the hovercard's "Send DM" button, which uses the same icon. */ + Shared with the hovercard's "Send DM" button, which uses the same icon at the same size. */ .send-icon { + width: 14px; + height: 14px; + flex-shrink: 0; transition: transform 0.15s ease-out; } .composer-toolbar .send-btn:hover:not(:disabled) .send-icon, @@ -2340,18 +2304,51 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af font-size: var(--fs-md); } .search-dropdown-channel { color: var(--accent-fg); } +/* ---------- Shared by the dropdown and the /search results page ---------- + A result is a result whether it arrives in the topbar popup or on the full page, so the parts + that carry meaning — the "not joined" pill, the highlighted term, the matched-filename chips — + are declared once for both. They were written twice and had already drifted apart once. */ + /* "not joined" on a result from a public channel the viewer has never opened. Same pill as the sidebar's channel-discovery results, because it is the same fact about the same thing. */ -.search-dropdown-tag { +.search-dropdown-tag, +.search-result-tag { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); border: 1px solid currentColor; - border-radius: 999px; + border-radius: var(--radius-full); padding: 0 0.35rem; white-space: nowrap; } +/* Lucene's Highlighter wraps matched terms in . The browser default is a heavy yellow + background; tone it down so the text stays readable in both themes. The snippet's mark inherits + its colour because the snippet is already body-coloured; the filename chips are muted, so their + mark states --text to lift the matched part out of the chip. */ +.search-dropdown-snippet mark, +.search-result-snippet mark, +.search-dropdown-files mark, +.search-result-file mark { + background: rgba(255, 193, 7, 0.35); + padding: 0 1px; + border-radius: 2px; + font-weight: 600; +} +.search-dropdown-snippet mark, +.search-result-snippet mark { color: inherit; } +.search-dropdown-files mark, +.search-result-file mark { color: var(--text); } +.search-dropdown-files .icon, +.search-result-file .icon { flex: none; opacity: 0.7; } +/* Markdown rendered into a one- or two-line excerpt: no block spacing, and code keeps its wrap. */ +.search-dropdown-snippet > :first-child, +.search-result-snippet > :first-child { margin-top: 0; } +.search-dropdown-snippet > :last-child, +.search-result-snippet > :last-child { margin-bottom: 0; } +.search-dropdown-snippet pre, +.search-result-snippet pre { white-space: pre-wrap; } + .search-dropdown-time { margin-left: auto; } .search-dropdown-snippet { margin-top: 0.15rem; @@ -2364,24 +2361,12 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af -webkit-box-orient: vertical; line-height: 1.35; } -.search-dropdown-snippet > :first-child { margin-top: 0; } -.search-dropdown-snippet > :last-child { margin-bottom: 0; } .search-dropdown-snippet p, .search-dropdown-snippet ul, .search-dropdown-snippet ol, .search-dropdown-snippet pre, .search-dropdown-snippet blockquote { margin: 0; padding: 0; } -.search-dropdown-snippet pre { white-space: pre-wrap; } .search-dropdown-snippet code { font-size: var(--fs-sm); } -/* Lucene Highlighter wraps matched terms in . The browser default is a heavy - yellow background; tone it down so the snippet stays readable in both themes. */ -.search-dropdown-snippet mark { - background: rgba(255, 193, 7, 0.35); - color: inherit; - padding: 0 1px; - border-radius: 2px; - font-weight: 600; -} /* Matched filenames, same job as .search-result-files on the results page: a row whose snippet is empty (a file posted with no caption) needs to say why it is a match. One line, clipped. */ .search-dropdown-files { @@ -2395,18 +2380,10 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af white-space: nowrap; text-overflow: ellipsis; } -.search-dropdown-files .icon { flex: none; opacity: 0.7; } .search-dropdown-files span { overflow: hidden; text-overflow: ellipsis; } -.search-dropdown-files mark { - background: rgba(255, 193, 7, 0.35); - color: var(--text); - padding: 0 1px; - border-radius: 2px; - font-weight: 600; -} /* ---------- Mention typeahead ---------- Reuses .search-dropdown for the panel itself — same surface, same elevation, same row hover — @@ -2810,7 +2787,11 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af .profile-page .topbar-center, .profile-page .topbar-right { padding: 0 1rem; } -.profile-content { +/* The scrolling sheet under the topbar. Profile and the admin console are the same kind of page — + one full-height panel, no sidebar — so they share it; the phone override further down already + treated them as one. */ +.profile-content, +.admin-content { flex: 1; overflow-y: auto; padding: 2rem 1.5rem; @@ -2902,17 +2883,8 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af flex: none; } -/* ---------- Admin console ---------- */ -.admin-content { - flex: 1; - overflow-y: auto; - padding: 2rem 1.5rem; - background: var(--panel-2); - margin-top: var(--chrome-gap); - border-radius: var(--radius-lg) var(--radius-lg) 0 0; - height: calc(100vh - var(--topbar-h) - var(--chrome-gap)); - height: calc(100dvh - var(--topbar-h) - var(--chrome-gap)); -} +/* ---------- Admin console ---------- + .admin-content's sheet is declared with .profile-content in the profile section above. */ .admin-wrap { max-width: 960px; margin: 0 auto; @@ -2970,12 +2942,7 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af color: var(--muted); font-size: var(--fs-md); } -.admin-current-logo .logo-mark { - height: 32px; - width: auto; - max-width: 180px; - object-fit: contain; -} +/* Sizing comes from the shared .logo-mark rule in the topbar section. */ .admin-logo-note { color: var(--muted); } .admin-logo-note.error { color: var(--danger); font-weight: 600; } /* Authored display beats the UA's [hidden] rule — restore it for the preview slot. */ @@ -2999,11 +2966,11 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af border-collapse: collapse; font-size: var(--fs-md); } -.admin-table th, .admin-table td { white-space: nowrap; } .admin-table th, .admin-table td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); + white-space: nowrap; } .admin-table th { color: var(--muted); font-weight: 600; } .admin-table tbody tr:hover { background: var(--hover); } @@ -3063,91 +3030,26 @@ html[data-self-presence="DND"] .topbar .me .avatar:has(.avatar-status-emoji)::af overflow: hidden; border: 1px solid var(--border); } +/* Each stripe reads the theme's own token, so a swatch is never a second copy of a palette. + The swatch element carries that theme's data-theme and the [data-theme] blocks above are + attribute-only selectors, so it already resolves the right values — this used to be 60 + hardcoded hex codes, a full second copy of every palette, i.e. two places to change a colour + and one place to forget. A theme added to the picker now gets a correct swatch for free. */ .theme-swatch .sw { flex: 1; height: 100%; } -.theme-swatch .sw-sidebar { flex: 0 0 35%; } -.theme-swatch .sw-content { flex: 0 0 50%; } -.theme-swatch .sw-accent { flex: 0 0 15%; } - -/* Swatch palettes — mirror the data-theme variables */ -.theme-swatch[data-theme="default"] .sw-sidebar { background: #1e325c; } -.theme-swatch[data-theme="default"] .sw-accent { background: #1c58d9; } -.theme-swatch[data-theme="default"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="dark"] .sw-sidebar { background: #1f1f23; } -.theme-swatch[data-theme="dark"] .sw-accent { background: #5d8eff; } -.theme-swatch[data-theme="dark"] .sw-content { background: #25262b; } - -.theme-swatch[data-theme="orange"] .sw-sidebar { background: #b8470b; } -.theme-swatch[data-theme="orange"] .sw-accent { background: #f4a623; } -.theme-swatch[data-theme="orange"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="pink"] .sw-sidebar { background: #b1316a; } -.theme-swatch[data-theme="pink"] .sw-accent { background: #ff7eb6; } -.theme-swatch[data-theme="pink"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="green"] .sw-sidebar { background: #176b3a; } -.theme-swatch[data-theme="green"] .sw-accent { background: #5fdd8f; } -.theme-swatch[data-theme="green"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="purple"] .sw-sidebar { background: #4f2c8a; } -.theme-swatch[data-theme="purple"] .sw-accent { background: #a78bef; } -.theme-swatch[data-theme="purple"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="red"] .sw-sidebar { background: #a31b1b; } -.theme-swatch[data-theme="red"] .sw-accent { background: #ff7676; } -.theme-swatch[data-theme="red"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="cyan"] .sw-sidebar { background: #0d6f7a; } -.theme-swatch[data-theme="cyan"] .sw-accent { background: #5fdde6; } -.theme-swatch[data-theme="cyan"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="indigo"] .sw-sidebar { background: #3730a3; } -.theme-swatch[data-theme="indigo"] .sw-accent { background: #a5b4fc; } -.theme-swatch[data-theme="indigo"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="teal"] .sw-sidebar { background: #0f766e; } -.theme-swatch[data-theme="teal"] .sw-accent { background: #5eead4; } -.theme-swatch[data-theme="teal"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="amber"] .sw-sidebar { background: #92620e; } -.theme-swatch[data-theme="amber"] .sw-accent { background: #fbbf24; } -.theme-swatch[data-theme="amber"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="slate"] .sw-sidebar { background: #334155; } -.theme-swatch[data-theme="slate"] .sw-accent { background: #94a3b8; } -.theme-swatch[data-theme="slate"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="mocha"] .sw-sidebar { background: #6b4423; } -.theme-swatch[data-theme="mocha"] .sw-accent { background: #d9a066; } -.theme-swatch[data-theme="mocha"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="ocean"] .sw-sidebar { background: #0c4a6e; } -.theme-swatch[data-theme="ocean"] .sw-accent { background: #7dd3fc; } -.theme-swatch[data-theme="ocean"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="plum"] .sw-sidebar { background: #6b2154; } -.theme-swatch[data-theme="plum"] .sw-accent { background: #f0abfc; } -.theme-swatch[data-theme="plum"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="lime"] .sw-sidebar { background: #3f6212; } -.theme-swatch[data-theme="lime"] .sw-accent { background: #bef264; } -.theme-swatch[data-theme="lime"] .sw-content { background: #ffffff; } - -.theme-swatch[data-theme="midnight"] .sw-sidebar { background: #131a2b; } -.theme-swatch[data-theme="midnight"] .sw-accent { background: #5b8dff; } -.theme-swatch[data-theme="midnight"] .sw-content { background: #1a2133; } - -.theme-swatch[data-theme="carbon"] .sw-sidebar { background: #161618; } -.theme-swatch[data-theme="carbon"] .sw-accent { background: #f0883e; } -.theme-swatch[data-theme="carbon"] .sw-content { background: #202024; } - -.theme-swatch[data-theme="forest"] .sw-sidebar { background: #10201a; } -.theme-swatch[data-theme="forest"] .sw-accent { background: #4ade80; } -.theme-swatch[data-theme="forest"] .sw-content { background: #18211b; } - -.theme-swatch[data-theme="dusk"] .sw-sidebar { background: #1d1630; } -.theme-swatch[data-theme="dusk"] .sw-accent { background: #c4a6ff; } -.theme-swatch[data-theme="dusk"] .sw-content { background: #221c30; } +.theme-swatch .sw-sidebar { flex: 0 0 35%; background: var(--sb-bg); } +.theme-swatch .sw-content { flex: 0 0 50%; background: var(--panel); } +.theme-swatch .sw-accent { flex: 0 0 15%; background: var(--sb-active); } + +/* Fallbacks for the tokens a swatch needs but its theme may not declare: the light themes set + only their sidebar and accent and inherit the white content surface, and "default" has no + [data-theme] block at all. :where() gives these zero specificity, so any real theme block + (0,1,0) beats them whatever the source order. Without them a light swatch would inherit + --panel from whichever theme the VIEWER is running and preview the wrong colour. */ +:where(.theme-swatch) { + --sb-bg: #1e325c; + --sb-active: #1c58d9; + --panel: #ffffff; +} .theme-picker #theme-feedback { margin: 0.2rem 0 0; } @@ -3492,7 +3394,11 @@ main.content.searching > :not(.channel-search-results) { display: none; } padding: 0.5rem 0.6rem; border-radius: 6px; } -.channel-search-list li:hover { background: var(--hover-bg, rgba(127, 127, 127, 0.1)); } +/* NOTE: this was written as var(--hover-bg, …) against a token that has never existed, so it has + always rendered the neutral grey fallback rather than the accent tint every other row hover in + the app uses. Kept as-is to preserve the current look — switch it to var(--hover) to bring it + in line with .search-result-link:hover, .admin-table tbody tr:hover and .search-dropdown-row. */ +.channel-search-list li:hover { background: rgba(127, 127, 127, 0.1); } .channel-search-name { font-weight: 600; text-decoration: none; color: inherit; } /* Ordinary unread, same convention as the sidebar: weight, not a number. */ .channel-search-list li.has-unread .channel-search-name { font-weight: 800; } @@ -3503,12 +3409,10 @@ main.content.searching > :not(.channel-search-results) { display: none; } letter-spacing: 0.03em; color: var(--muted); border: 1px solid currentColor; - border-radius: 999px; + border-radius: var(--radius-full); padding: 0.05rem 0.4rem; } -.message { position: relative; } - /* ---------- Optimistic send ---------- A message the sender has submitted but the server hasn't confirmed yet. Broadcasts wait for the database commit, so this is normally on screen for a few tens of milliseconds — long enough to @@ -3847,7 +3751,6 @@ main.content.searching > :not(.channel-search-results) { display: none; } .emoji-picker-tab:hover { opacity: 1; background: var(--panel-3); - cursor: pointer; } .emoji-picker-tab.active { opacity: 1; @@ -3892,7 +3795,6 @@ main.content.searching > :not(.channel-search-results) { display: none; } } .emoji-picker-btn:hover { background: var(--panel-2); - cursor: pointer; transform: scale(1.18); } .emoji-picker-btn:active { @@ -4340,7 +4242,8 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) padding-bottom: calc(1rem + env(safe-area-inset-bottom)); } - /* Content: full-bleed sheet on phones — no inset or rounding at this size. */ + /* Content: full-bleed sheet on phones — no inset or rounding at this size. It already + carries min-width: 0, so it takes the full single column without further help. */ .content { margin-top: 0; border-radius: 0; @@ -4352,9 +4255,6 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) } body.sidebar-open .sidebar-toggle[aria-expanded="true"] { background: var(--sb-bg-2); } - /* Content takes the full width. */ - .content { min-width: 0; } - /* Channel header: tighten spacing, allow channel name + actions to wrap. */ .channel-header { flex-wrap: wrap; @@ -4538,17 +4438,10 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) text-overflow: ellipsis; white-space: nowrap; } +/* The workspace-admin pill; its skin is shared with the member/DM lists' — see .dm-admin-tag + below. Only the inline-with-a-name placement is specific to the hovercard. */ .user-hovercard-badge { display: inline-block; - margin-left: 0.4rem; - padding: 0.05rem 0.4rem; - border-radius: var(--radius-full); - background: var(--accent); - color: var(--accent-on); - font-size: var(--fs-xs); - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; vertical-align: middle; } .user-hovercard-status { @@ -4599,11 +4492,6 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) cursor: pointer; } .user-hovercard-btn:hover { background: var(--accent-2); color: var(--accent-on); } -.user-hovercard-btn .send-icon { - width: 14px; - height: 14px; - flex-shrink: 0; -} /* ---------- Sidebar DM list ---------- */ .dm-list { @@ -4628,34 +4516,14 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) text-decoration: none; font-size: var(--fs-md); } -.dm-list > li > a:hover { - background: var(--sb-bg-2); - color: var(--sb-text); - text-decoration: none; -} -.dm-list > li > a.active { - background: var(--sb-active); - color: var(--sb-active-text); - font-weight: 600; -} +/* Hover, active, unread and muted states are shared with the channel list — see the + "Sidebar row skin" block up in the sidebar section. */ .dm-list .avatar { width: 22px; height: 22px; flex: 0 0 22px; font-size: var(--fs-xs); } -.dm-name { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.dm-list li.has-unread > a { - color: var(--sb-text); - font-weight: 600; -} -.dm-list li.has-unread .dm-name { font-weight: 700; } .dm-empty { padding: 0.4rem 0.5rem; color: var(--sb-muted); @@ -4668,18 +4536,14 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) font-size: 0.85em; font-weight: 600; } -/* A muted DM row, dimmed exactly as a muted channel row is (see .channel-list[data-muted]) and - restored on hover and while active — you have to be able to read what you muted. The unread - badge is deliberately not dimmed with it: the count is still true. */ -.dm-list li[data-muted="true"] > a { opacity: 0.55; } -.dm-list li[data-muted="true"] > a:hover, -.dm-list li[data-muted="true"] > a.active { opacity: 1; } .dm-muted-tag { margin-left: 0.25rem; color: var(--sb-muted); } +/* The role pill, wherever a name is shown: the DM list, the member lists, the hovercard. */ .dm-admin-tag, -.channel-role-tag { +.channel-role-tag, +.user-hovercard-badge { margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: var(--radius-full); @@ -4807,7 +4671,10 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) /* ---------- Poll builder modal ---------- */ /* Deliberately the same shell as the About dialog — same backdrop, same panel, same header — so the app has one modal, not two that drifted. */ -.poll-modal-backdrop { +/* One backdrop for every modal in the app. Two dialogs that dim the page differently read as two + applications, so the poll builder, the forward dialog and About all share this. */ +.poll-modal-backdrop, +.about-backdrop { position: fixed; inset: 0; z-index: 60; @@ -4835,7 +4702,8 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) padding: 0.9rem 1rem; border-bottom: 1px solid var(--border); } -.poll-modal-head h2 { margin: 0; font-size: var(--fs-lg); } +.poll-modal-head h2, +.about-head h2 { margin: 0; font-size: var(--fs-lg); } .poll-modal-body { display: grid; gap: 0.7rem; padding: 1rem; } .poll-field { display: grid; gap: 0.3rem; font-size: var(--fs-sm); color: var(--muted); } .poll-field input { font-size: var(--fs-md); } @@ -4952,16 +4820,7 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) resize: vertical; } -.about-backdrop { - position: fixed; - inset: 0; - z-index: 60; - display: flex; - align-items: center; - justify-content: center; - padding: 1.5rem; - background: rgba(0, 0, 0, 0.45); -} +/* .about-backdrop is declared with the poll modal's backdrop above. */ .about-dialog { background: var(--panel); color: var(--text); @@ -4981,7 +4840,6 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--border); } -.about-head h2 { margin: 0; font-size: var(--fs-lg); } .about-body { padding: 1.1rem; overflow-y: auto; } .about-body h3 { margin: 1.4rem 0 0.5rem; @@ -5037,7 +4895,10 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) border-color: var(--danger); color: #fff; } -.admin-danger:hover { filter: brightness(1.08); } +/* Restates its own background: the global `button:hover` is (0,1,1) and out-specifies this single + class, so without it Suspend and Clear messages repaint accent-blue under the pointer and the + filter below just lightens the blue. */ +.admin-danger:hover { background: var(--danger); filter: brightness(1.08); } .admin-badge { display: inline-block; padding: 1px 7px; @@ -5304,7 +5165,7 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) .saved-when { font-size: var(--fs-xs); } .saved-chip { padding: 1px 6px; - border-radius: 999px; + border-radius: var(--radius-full); border: 1px solid var(--border); font-size: var(--fs-xs); font-weight: 500; @@ -5414,34 +5275,14 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) } .search-result-author { font-weight: 700; color: var(--text); font-size: var(--fs-md); } .search-result-where { color: var(--accent-fg); } -/* Same pill as the dropdown's and the sidebar's, for the same fact. */ -.search-result-tag { - font-size: 0.68rem; - text-transform: uppercase; - letter-spacing: 0.03em; - border: 1px solid currentColor; - border-radius: 999px; - padding: 0 0.35rem; - white-space: nowrap; -} +/* .search-result-tag, the treatment and the snippet's block-spacing reset are declared + with the dropdown's, in the shared block up in the search-dropdown section. */ .search-result-time { margin-left: auto; } .search-result-snippet { margin-top: 0.3rem; line-height: 1.45; color: var(--text); } -.search-result-snippet > :first-child { margin-top: 0; } -.search-result-snippet > :last-child { margin-bottom: 0; } -.search-result-snippet pre { white-space: pre-wrap; } -/* Matching the dropdown's treatment of Lucene's : the browser default yellow is loud enough - to fight the text it is meant to help you find. */ -.search-result-snippet mark { - background: rgba(255, 193, 7, 0.35); - color: inherit; - padding: 0 1px; - border-radius: 2px; - font-weight: 600; -} /* The files a hit matched on. Drawn as chips rather than as another line of prose because they are evidence, not content: they answer "why is this row here" for a result whose snippet shows @@ -5461,21 +5302,12 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) max-width: 100%; padding: 0.1rem 0.5rem 0.1rem 0.35rem; border: 1px solid var(--border); - border-radius: 999px; + border-radius: var(--radius-full); background: var(--panel-2); font-size: var(--fs-sm); color: var(--muted); overflow-wrap: anywhere; } -.search-result-file .icon { flex: none; opacity: 0.7; } -/* Same tint as the snippet's, so "this is your search term" reads as one idea across the row. */ -.search-result-file mark { - background: rgba(255, 193, 7, 0.35); - color: var(--text); - padding: 0 1px; - border-radius: 2px; - font-weight: 600; -} .search-pager { display: flex; @@ -5574,7 +5406,9 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) flex-direction: column; gap: 0.85rem; padding: 1.1rem 1rem 0.9rem; - background: var(--surface, var(--bg)); + /* --surface does not exist in this stylesheet; this always resolved to --bg. Stated directly so + the panel's real colour is readable. Use --panel if you want it to match the other popovers. */ + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-lg); /* The heaviest shadow in the set. This is the one element allowed to sit on @@ -5641,7 +5475,9 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) .call-timer { margin: 0; font-size: 0.85rem; - color: var(--text-muted, var(--text)); + /* --text-muted does not exist in this stylesheet; this always resolved to --text, dimmed by the + opacity below. The app's token for secondary text is --muted (which would let the opacity go). */ + color: var(--text); opacity: 0.75; } @@ -5699,7 +5535,10 @@ body.sidebar-open .sidebar-toggle-bar:nth-child(3) { transform: translateY(-6px) border-color: var(--danger); color: #fff; } -.call-btn-end:hover { filter: brightness(0.92); } +/* Restates its own background, for the same reason .call-btn-answer:hover does: `.call-btn:hover` + is (0,2,0) and beats this single class, so decline and hang up would drop to the faint neutral + hover tint — the two buttons that must stay unmistakably red are the ones pressed in a hurry. */ +.call-btn-end:hover { background: var(--danger); filter: brightness(0.92); } @media (max-width: 720px) { .call-panel {