/* MeshBay Web Client — Phase 9.6 */
/* ── Theme variables ──────────────────────────────────────────────────────── */
:root, .theme-light {
--bg-base: #f8fafc;
--bg-surface: #ffffff;
--bg-raised: #f1f5f9;
--text: #0f172a;
--text-secondary: #475569;
--text-dim: #94a3b8;
--border: #e2e8f0;
--border-focus: #0ea5e9;
--accent: #0ea5e9;
--accent-hover: #0284c7;
--accent-text: #ffffff;
--error: #ef4444;
--error-bg: #fef2f2;
--success: #22c55e;
--nav-bg: #0f172a;
--nav-text: #e2e8f0;
/* One step down from sky-400 towards sky-500: the wordmark sat brighter than
the picture beside it, which made the two read as different materials. */
--nav-brand: #23b1f0;
--sidebar-bg: #f1f5f9;
--sidebar-hover: #e2e8f0;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.theme-dark {
--bg-base: #0f172a;
--bg-surface: #1e293b;
--bg-raised: #334155;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--text-dim: #64748b;
--border: #334155;
--error-bg: #450a0a;
--nav-bg: #020617;
--sidebar-bg: #1e293b;
--sidebar-hover: #334155;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.3);
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* A tapped button keeps real DOM focus on mobile Chrome/Safari the way a
click never does on desktop — there's no mouse to move away afterward, so
the browser's own default focus ring (never reset anywhere in this file)
is what stayed visibly "pressed" until something else was tapped (found
live, 2026-08-26 — the nav hamburger has no :hover rule of its own at all,
so the ring was the only thing that could have been showing). `:focus-
visible` still shows it for real keyboard navigation — this only clears
the leftover from a pointer/touch interaction, never accessibility. */
:focus:not(:focus-visible) { outline: none; }
/* A third, separate mechanism from :hover/:focus above, found live
2026-08-26 (screenshots): Android Chrome's own tap-highlight flash —
never disabled anywhere in this file — filled a tapped round button
(music-player-btn's "next", which carries no state class at all) with a
solid colour that stayed until another element was tapped. Left at its
default, this shade is drawn from the OS accent colour on many Android
builds, which is why it read as "the app's own blue", not an obviously
foreign highlight. Unrelated to real hover/focus — safe to kill outright. */
*, *::before, *::after { -webkit-tap-highlight-color: transparent; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg-base);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Cross-group search page ──────────────────────────────────────────────── */
.search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-raised);
margin-bottom: 12px;
}
.search-bar .icon { width: 16px; height: 16px; flex-shrink: 0; color: var(--text-dim); }
.search-bar input {
border: none; outline: none; background: transparent;
flex: 1; font-size: 0.95em; color: var(--text);
min-width: 0;
}
.search-bar input::placeholder { color: var(--text-dim); }
.search-bar:focus-within { border-color: var(--border-focus); }
.search-bar-clear {
background: none; border: none; padding: 2px; cursor: pointer;
color: var(--text-dim); display: flex; align-items: center;
}
.search-bar-clear:hover { color: var(--text); }
.search-bar-clear .icon { width: 14px; height: 14px; }
.search-progress {
display: flex; align-items: center; gap: 8px;
font-size: 0.85em; color: var(--text-dim); margin-bottom: 12px;
}
.search-progress-bar {
flex: 1; height: 4px; background: var(--border);
border-radius: 2px; overflow: hidden;
}
.search-progress-fill {
height: 100%; background: var(--accent);
transition: width 0.3s;
}
.search-unreachable {
font-size: 0.8em; color: var(--text-dim); margin-bottom: 8px;
}
.view-toggle {
display: flex; gap: 0; flex-shrink: 0; margin-left: auto;
}
.view-toggle button {
padding: 6px 12px; border: 1px solid var(--border);
background: transparent; color: var(--text-dim);
cursor: pointer; border-radius: 0; display: flex;
align-items: center; gap: 4px; font-size: 0.85em;
}
.view-toggle button:first-child { border-radius: 6px 0 0 6px; }
.view-toggle button:last-child { border-radius: 0 6px 6px 0; }
.view-toggle button + button { margin-left: -1px; }
.view-toggle button.active {
background: var(--accent-bg, rgba(59, 130, 246, 0.1));
color: var(--accent); border-color: var(--accent);
z-index: 1;
}
.view-toggle .icon { width: 16px; height: 16px; }
.search-media-list {
display: flex; flex-direction: column; gap: 2px;
}
.search-media-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px; cursor: pointer;
}
.search-media-row:hover { background: var(--bg-raised); }
.search-media-icon {
flex-shrink: 0; width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
background: var(--bg-raised); border-radius: 4px; color: var(--text-dim);
}
.search-media-icon .icon { width: 18px; height: 18px; }
.search-media-info { flex: 1; min-width: 0; }
.search-media-title {
font-weight: 500; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.search-media-meta {
font-size: 0.8em; color: var(--text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.search-media-section {
font-size: 0.75em; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 0.05em;
padding: 12px 10px 4px;
}
.search-media-row .badge { flex-shrink: 0; font-size: 0.78em; }
.search-media-show { opacity: 0.85; }
/* ── Icons ────────────────────────────────────────────────────────────────── */
/* Sized in em and stroked in currentColor, so an icon inherits the size and
colour of whatever it sits next to and needs no rule of its own. Only the
places that want a fixed size say so below. */
.icon {
width: 1em;
height: 1em;
vertical-align: -0.14em;
flex-shrink: 0;
}
.icon.flip { transform: rotate(180deg); }
/* ── Navigation ───────────────────────────────────────────────────────────── */
.nav {
background: var(--nav-bg);
color: var(--nav-text);
padding: 0 16px;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.nav-left, .nav-right { display: flex; align-items: center; gap: 12px; }
.nav-brand {
color: var(--nav-brand);
font-weight: 700;
/* 22px measured in the application. 1.15em was lettering sized for plain
text and read as a caption beside the picture; 1.45em overshot. */
font-size: 1.375em;
text-decoration: none;
}
.nav-brand:hover { text-decoration: none; }
/* The M is a picture standing in for a letter, so it is sized in `em` and
follows the wordmark rather than being pinned to a pixel height.
Sized by measuring the running application, twice. At 1.35em of the old
font size it rendered 25px against a 29px line box — *smaller* than the
letters beside it, so the wordmark read as unchanged. At 1.9em it was 35px
against 29px, which overshot the other way. The pair is now set together:
the picture leads, the lettering follows a little behind it. */
.nav-brand {
display: inline-flex;
align-items: center;
}
.nav-brand-m {
/* **In pixels, not `em` — and that is the point.** Written in `em` this was
a fraction of the lettering's size, so every adjustment to the text moved
the picture too and the pair could never be settled: making the words
bigger made the M bigger by the same stroke, and the ratio never changed.
Two independent things need two independent numbers.
30px against 22px of lettering. */
height: 30px;
width: auto;
/* Positive, deliberately. The glyph this replaces carried its own side
bearing and an image has none, so without this the picture sits flush
against the "e". */
margin-right: 4px;
}
.nav-hamburger {
display: none;
background: none;
border: none;
color: var(--nav-text);
font-size: 1.4em;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
}
.nav-user { color: var(--text-dim); font-size: 0.9em; }
/* ── User Menu ──────────────────────────────────────────────────────────── */
.user-menu-wrap { position: relative; }
.user-menu-trigger {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--nav-text);
padding: 4px 10px 4px 4px;
border-radius: 20px;
cursor: pointer;
font-size: 0.85em;
transition: background 0.12s;
}
.user-menu-trigger:hover { background: rgba(255, 255, 255, 0.15); }
.user-menu-avatar {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--accent);
color: #fff;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.8em;
flex-shrink: 0;
}
.user-menu-avatar.lg { width: 36px; height: 36px; font-size: 0.95em; }
.user-menu-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-menu-caret { font-size: 0.7em; color: rgba(255,255,255,0.5); }
.user-menu-dropdown {
position: absolute;
right: 0; top: calc(100% + 6px);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-lg);
min-width: 220px;
z-index: 120;
/* The language submenu expands to ten entries inside this dropdown, which
overruns a short viewport. Clip horizontally as before, scroll vertically
rather than hiding the last languages where nothing can reach them. */
max-height: calc(100vh - 72px);
overflow-x: hidden;
overflow-y: auto;
padding: 4px 0;
}
.user-menu-header {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px 8px;
}
.user-menu-uname { font-weight: 600; font-size: 0.9em; color: var(--text); }
.user-menu-role { font-size: 0.75em; color: var(--text-dim); text-transform: capitalize; }
.user-menu-divider { height: 1px; background: var(--border); margin: 4px 0; }
.user-menu-section-label {
padding: 6px 14px 2px;
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-dim); font-weight: 600;
}
.user-menu-item {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 8px 14px;
background: none; border: none; border-radius: 0;
color: var(--text); font-size: 0.85em;
cursor: pointer; text-align: left;
}
.user-menu-item:hover { background: var(--bg-raised); }
.user-menu-logout { color: var(--error); }
.umi-icon { width: 17px; height: 17px; flex-shrink: 0; color: var(--text-secondary); }
.umi-arrow { margin-left: auto; width: 13px; height: 13px; color: var(--text-dim); }
.umi-check { margin-left: auto; width: 14px; height: 14px; color: var(--accent); }
.user-menu-caret { width: 13px; height: 13px; color: var(--nav-text); opacity: 0.8; }
.user-menu-sub { padding-left: 32px; }
.nav-btn {
background: rgba(255, 255, 255, 0.1);
color: var(--nav-text);
border: none;
padding: 6px 14px;
border-radius: 6px;
font-size: 0.85em;
cursor: pointer;
text-decoration: none;
}
.nav-btn:hover { background: rgba(255, 255, 255, 0.18); text-decoration: none; }
/* ── Layout ───────────────────────────────────────────────────────────────── */
.layout { display: flex; min-height: calc(100vh - 52px); }
/* ── Sidebar ──────────────────────────────────────────────────────────────── */
.sidebar {
width: 240px;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
padding: 16px 0;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-section { padding: 0 12px; margin-bottom: 24px; }
.sidebar-heading {
font-size: 0.7em;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
padding: 4px 8px;
margin-bottom: 4px;
font-weight: 600;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 6px;
color: var(--text);
text-decoration: none;
font-size: 0.9em;
transition: background 0.12s;
}
/* A group entry: the name+dot on one line, the @owner on a subordinate line
under it — so the status dot sits in front of the name, not floating
between the name and the owner. */
.sidebar-item.sidebar-group {
flex-direction: column;
align-items: stretch;
gap: 1px;
}
.si-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
.sidebar-owner {
margin-left: 16px; /* clear the 8px dot + 8px gap */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8em;
color: var(--text-dim);
}
.sidebar-item.active .sidebar-owner { color: inherit; opacity: 0.75; }
.sidebar-item:hover { background: var(--sidebar-hover); text-decoration: none; }
.sidebar-item.active { background: var(--accent); color: var(--accent-text); }
.sidebar-empty {
padding: 8px 12px;
color: var(--text-dim);
font-size: 0.85em;
font-style: italic;
}
/* ── Main content ─────────────────────────────────────────────────────────── */
.main {
flex: 1;
padding: 24px 32px;
max-width: 960px;
min-width: 0;
}
.main h2 { font-size: 1.25em; margin-bottom: 16px; }
.page-message {
color: var(--text-secondary);
margin-bottom: 24px;
}
/* ── Page center (login / register) ───────────────────────────────────────── */
.page-center {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 52px);
padding: 24px;
}
/* A centring page already reserves the whole viewport below the header, so the
main column's own padding is added on top of it and the page overflows by
exactly that much — 48px, and a scrollbar on sign-in at every window size.
Measured, not deduced: `.page-center` ended at 795 and `main` at 819.
The page brings its own 24px, so dropping main's here changes nothing
visible and removes the duplicated arithmetic rather than adding more. */
.main:has(> .page-center) { padding: 0; }
/* ── Cards ────────────────────────────────────────────────────────────────── */
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
box-shadow: var(--shadow);
}
.login-card { width: 100%; max-width: 380px; }
.login-card h2 { text-align: center; margin-bottom: 20px; }
.login-card form { display: flex; flex-direction: column; gap: 12px; }
.login-footer {
text-align: center;
margin-top: 16px;
font-size: 0.85em;
color: var(--text-dim);
}
/* ── Form elements ────────────────────────────────────────────────────────── */
input[type="text"],
input[type="password"],
input[type="email"] {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-base);
color: var(--text);
font-size: 0.95em;
width: 100%;
transition: border-color 0.15s;
}
input:focus { outline: none; border-color: var(--border-focus); }
button {
padding: 10px 20px;
background: var(--accent);
color: var(--accent-text);
border: none;
border-radius: 6px;
font-size: 0.95em;
cursor: pointer;
transition: background 0.15s;
}
button:hover { background: var(--accent-hover); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Alerts ───────────────────────────────────────────────────────────────── */
.error-msg {
background: var(--error-bg);
color: var(--error);
border: 1px solid var(--error);
border-radius: 6px;
padding: 8px 12px;
font-size: 0.85em;
}
.success-msg {
background: #16a34a20;
color: var(--success);
border: 1px solid var(--success);
border-radius: 6px;
padding: 8px 12px;
font-size: 0.85em;
}
.settings-hint {
font-size: 0.85em;
color: var(--text-dim);
margin-bottom: 8px;
}
/* ── Group cards (9.7 prep) ───────────────────────────────────────────────── */
.group-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.group-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: border-color 0.12s, box-shadow 0.12s;
text-decoration: none;
color: var(--text);
display: block;
}
.group-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-lg);
text-decoration: none;
}
.group-card h3 { font-size: 1em; margin-bottom: 4px; }
.badge {
display: inline-block;
background: var(--bg-raised);
color: var(--text-secondary);
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75em;
}
.badge-ok { background: #16a34a20; color: var(--success); }
.badge-err { background: var(--error-bg); color: var(--error); }
/* ── Group page header ───────────────────────────────────────────────────── */
.group-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.group-header h2 { margin-bottom: 0; }
/* ── Group tabs ──────────────────────────────────────────────────────────── */
.group-tabs {
display: flex;
gap: 0;
margin-bottom: 16px;
border-bottom: 2px solid var(--border);
}
.group-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
padding: 10px 18px;
color: var(--text-secondary);
cursor: pointer;
border-radius: 0;
transition: color 0.12s, border-color 0.12s;
display: flex;
align-items: center;
justify-content: center;
}
.group-tab:hover { color: var(--text); background: none; }
.group-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-icon { width: 22px; height: 22px; }
.group-mute-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
margin-left: auto;
}
.group-mute-btn:hover { color: var(--text); }
.group-mute-btn .icon { width: 20px; height: 20px; }
/* ── Chat panel ──────────────────────────────────────────────────────────── */
.chat-panel {
position: relative;
display: flex;
flex-direction: column;
/* Overwritten from JS with the space actually left below the group header,
which varies with the description and the buttons. These two are the value
used for the first paint and the fallback if that never runs. `dvh` rather
than `vh` so a phone's collapsing URL bar does not add a scrollbar. */
height: calc(100dvh - 260px);
min-height: 240px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.chat-empty {
text-align: center;
color: var(--text-dim);
font-style: italic;
padding: 40px 16px;
}
.chat-msg {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 75%;
}
.chat-msg-own {
align-self: flex-end;
align-items: flex-end;
}
.chat-sender {
font-size: 0.72em;
color: var(--text-dim);
margin-bottom: 2px;
padding-left: 10px;
font-weight: 600;
}
.chat-bubble {
background: var(--bg-raised);
padding: 8px 12px;
border-radius: 12px 12px 12px 4px;
font-size: 0.9em;
line-height: 1.45;
word-break: break-word;
display: inline-flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.chat-bubble-own {
background: var(--accent);
color: var(--accent-text);
border-radius: 12px 12px 4px 12px;
}
.chat-text { white-space: pre-wrap; }
/* Link unfurl card. Sits on its own line inside the bubble and carries its
own surface colours so it stays readable inside an own-message bubble too. */
.chat-link-preview {
flex: 0 0 100%;
display: flex;
gap: 10px;
margin-top: 4px;
padding: 8px;
max-width: 380px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 8px;
background: var(--bg-base);
color: var(--text);
text-decoration: none;
overflow: hidden;
}
.chat-link-preview:hover { background: var(--bg-surface); }
.clp-img {
width: 72px;
height: 72px;
flex-shrink: 0;
object-fit: cover;
border-radius: 6px;
background: var(--bg-surface);
}
.clp-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.clp-site {
font-size: 0.7em;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.clp-title {
font-weight: 600;
font-size: 0.9em;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.clp-desc {
font-size: 0.82em;
color: var(--text-secondary);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chat-time {
font-size: 0.65em;
color: var(--text-dim);
white-space: nowrap;
flex-shrink: 0;
}
.chat-bubble-own .chat-time { color: rgba(255, 255, 255, 0.6); }
.chat-input-row {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--border);
background: var(--bg-base);
}
.chat-input {
flex: 1;
resize: none;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-surface);
color: var(--text);
font-size: 0.9em;
font-family: inherit;
line-height: 1.4;
min-height: 38px;
max-height: 100px;
}
.chat-input:focus { outline: none; border-color: var(--border-focus); }
.chat-send {
align-self: flex-end;
padding: 8px 16px;
font-size: 0.85em;
border-radius: 8px;
white-space: nowrap;
}
/* ── File toolbar ────────────────────────────────────────────────────────── */
/* One bar, three groups: what you can add on the left, where you are in the
middle, what you can do with what is here on the right. It used to be four
controls of three different heights sitting in a row. */
.file-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding: 8px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.toolbar-group.right { margin-left: auto; }
.tb-btn {
display: inline-flex;
align-items: center;
gap: 6px; /* the icon and the word are not the same word */
height: 32px;
padding: 0 11px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-surface);
color: var(--text);
font-size: 0.83em;
white-space: nowrap;
cursor: pointer;
transition: border-color 0.12s, color 0.12s, background 0.12s;
}
.tb-btn:hover { border-color: var(--accent); color: var(--accent); }
.tb-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.tb-btn:disabled:hover { border-color: var(--border); color: var(--text); }
.tb-btn .icon { width: 15px; height: 15px; }
.tb-btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
.tb-btn.primary:hover { background: var(--accent-hover); color: var(--accent-text); }
.tb-btn.active {
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
border-color: var(--accent);
color: var(--accent);
}
/* The filter is a field, not a button, and should not pretend otherwise. */
.tb-search {
display: flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-surface);
color: var(--text-dim);
/* A flex item defaults to min-width:auto and refuses to shrink below its
content — which is how a 150px field pushed the toolbar off a phone. */
min-width: 0;
}
.tb-search:focus-within { border-color: var(--border-focus); }
.tb-search .icon { width: 14px; height: 14px; flex-shrink: 0; }
.tb-search input {
border: none;
background: none;
outline: none;
padding: 0;
width: 150px;
min-width: 0;
font-size: 0.83em;
color: var(--text);
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
min-width: 0;
font-size: 0.85em;
min-width: 0;
flex-wrap: wrap;
}
.crumb {
display: inline-flex;
align-items: center;
cursor: pointer;
color: var(--accent);
padding: 2px 4px;
border-radius: 4px;
}
.crumb:hover { background: var(--bg-raised); text-decoration: none; }
.crumb-sep { color: var(--text-dim); }
/* ── File table ──────────────────────────────────────────────────────────── */
.file-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.file-table th {
text-align: left;
padding: 8px 12px;
border-bottom: 2px solid var(--border);
color: var(--text-secondary);
font-size: 0.8em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
user-select: none;
}
.file-table th.sortable { cursor: pointer; }
.file-table th.sortable:hover { color: var(--text); }
.file-row td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.file-row:hover { background: var(--bg-raised); }
.dir-row { cursor: pointer; }
.dir-row td { font-weight: 500; }
.file-name {
word-break: break-word;
min-width: 0;
}
/* Where a search result lives, under its name; click to open that folder
and clear the filter. */
.file-loc {
display: block;
margin-top: 2px;
font-size: 0.78em;
color: var(--text-dim);
cursor: pointer;
}
.file-loc:hover { color: var(--accent); text-decoration: underline; }
.file-size { white-space: nowrap; color: var(--text-secondary); }
.file-type { color: var(--text-dim); }
.file-date { white-space: nowrap; color: var(--text-dim); }
.file-empty {
text-align: center;
padding: 24px 12px !important;
color: var(--text-dim);
font-style: italic;
}
/* ── Spinner ─────────────────────────────────────────────────────────────── */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top: 2px solid var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* ── File action menu ───────────────────────────────────────────────────── */
.file-actions-cell {
position: relative;
width: 40px;
}
.file-menu-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
width: 30px;
height: 30px;
border-radius: 6px;
font-size: 1.1em;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
line-height: 1;
}
.file-menu-btn:hover { background: var(--bg-raised); border-color: var(--accent); color: var(--accent); }
.file-menu {
position: absolute;
right: 0;
top: 100%;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow-lg);
z-index: 30;
min-width: 130px;
padding: 4px 0;
overflow: hidden;
}
.file-menu button {
display: block;
width: 100%;
padding: 8px 14px;
background: none;
border: none;
color: var(--text);
font-size: 0.85em;
text-align: left;
cursor: pointer;
border-radius: 0;
}
.file-menu button:hover { background: var(--bg-raised); }
/* ── File link (clickable name) ─────────────────────────────────────────── */
.file-link {
color: var(--accent);
cursor: pointer;
text-decoration: none;
}
.file-link:hover { text-decoration: underline; }
/* ── File preview overlay ───────────────────────────────────────────────── */
.preview-image-wrap {
display: flex;
align-items: center;
justify-content: center;
max-width: 90vw;
max-height: calc(100vh - 100px);
padding: 40px 20px 20px;
}
.preview-image {
max-width: 100%;
max-height: calc(100vh - 120px);
object-fit: contain;
border-radius: 4px;
}
.preview-text-wrap {
width: min(90vw, 900px);
max-height: calc(100vh - 100px);
overflow: auto;
padding: 60px 20px 20px;
}
.preview-text {
background: var(--bg-surface);
color: var(--text);
padding: 20px;
border-radius: 8px;
font-size: 0.85em;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
margin: 0;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
tab-size: 4;
}
/* ── Chat attachment ────────────────────────────────────────────────────── */
.chat-attach {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
font-size: 1.2em;
cursor: pointer;
border-radius: 8px;
flex-shrink: 0;
color: var(--text-secondary);
}
.chat-attach:hover { background: var(--bg-raised); color: var(--accent); }
.chat-attachment {
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-att-img, .chat-att-file {
font-weight: 500;
font-size: 0.88em;
}
.chat-att-thumb {
max-width: 200px;
max-height: 200px;
border-radius: 6px;
object-fit: contain;
display: block;
cursor: pointer;
}
.chat-att-size {
font-size: 0.75em;
color: var(--text-dim);
}
.chat-bubble-own .chat-att-size { color: rgba(255, 255, 255, 0.6); }
.file-menu button { display: flex; align-items: center; gap: 8px; }
.file-menu button.danger { color: var(--error); }
.file-menu button.danger:hover { background: var(--error-bg); }
.fmi { font-size: 0.9em; width: 16px; text-align: center; flex-shrink: 0; }
/* ── Download button + progress ──────────────────────────────────────────── */
.dl-btn {
background: none;
border: 1px solid var(--border);
color: var(--accent);
width: 30px;
height: 30px;
border-radius: 6px;
font-size: 0.9em;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.dl-btn:hover { background: var(--bg-raised); border-color: var(--accent); }
.dl-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.dl-progress {
flex: 1;
height: 6px;
background: var(--bg-raised);
border-radius: 3px;
overflow: hidden;
}
.dl-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.2s;
}
/* ── Settings page ───────────────────────────────────────────────────────── */
.settings-section {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.settings-heading {
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
font-weight: 600;
margin-bottom: 12px;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 8px 0;
}
.settings-row + .settings-row {
border-top: 1px solid var(--border);
}
.settings-label {
font-size: 0.9em;
color: var(--text);
}
.settings-label input[type="number"] {
display: block;
width: 90px;
margin-top: 4px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-base);
color: var(--text);
font-size: 0.95em;
}
.settings-label input[type="password"],
.settings-label input[type="text"],
.settings-label select {
display: block;
width: 100%;
max-width: 320px;
margin-top: 4px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-base);
color: var(--text);
font-size: 0.95em;
}
.settings-value {
font-size: 0.9em;
color: var(--text-secondary);
}
/* Collapsible settings sections (2026-08-24) — every section but a form
someone might be mid-typing into (invite, pair-operator, approve-device,
left as plain .settings-section blocks). Reuses .video-flat-chevron for
the rotate-on-open animation rather than declaring a near-duplicate. */
.settings-collapsible-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: inherit;
font: inherit;
text-align: left;
}
.settings-collapsible-header .settings-heading { margin-bottom: 0; }
.settings-collapsible-body { margin-top: 12px; }
.settings-meta-title {
display: flex;
align-items: center;
gap: 8px;
text-transform: none;
letter-spacing: normal;
font-weight: 600;
font-size: 1.1em;
color: var(--text);
}
.settings-meta-title .icon { width: 16px; height: 16px; }
.settings-meta-badge {
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 2px 8px;
border-radius: 999px;
background: var(--bg-raised);
color: var(--text-dim);
}
.settings-meta-badge.on { background: var(--accent); color: #fff; }
/* A modern on/off switch — replaces a checkbox or a "Turn on/off" button
wherever the setting is a straight binary. */
.toggle-switch {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 0.9em;
color: var(--text);
}
.toggle-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch-track {
position: relative;
width: 38px;
height: 21px;
border-radius: 11px;
background: var(--border);
flex-shrink: 0;
transition: background 0.15s;
}
.toggle-switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 17px;
height: 17px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
transition: transform 0.15s;
}
.toggle-switch input:checked + .toggle-switch-track { background: var(--accent); }
.toggle-switch input:checked + .toggle-switch-track .toggle-switch-thumb {
transform: translateX(17px);
}
.toggle-switch input:focus-visible + .toggle-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.toggle-switch-disabled { opacity: 0.5; cursor: default; }
/* A sub-block within a merged settings section (Directories: shared roots,
Videos root, Music root, all under one CollapsibleSection). */
.settings-root-row { padding: 10px 0; }
.settings-root-row + .settings-root-row { border-top: 1px solid var(--border); }
.settings-root-row-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.settings-root-row-title .icon { width: 15px; height: 15px; color: var(--text-dim); }
.settings-root-row-title h4 {
font-size: 0.85em;
font-weight: 600;
color: var(--text);
margin: 0;
}
/* Photos app's root add/remove list (docs/photos.md §2.2) — a set, unlike
the Videos/Music single-value picker above. */
.settings-root-list { list-style: none; margin: 4px 0 8px; padding: 0; }
.settings-root-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 0;
font-size: 0.88em;
}
.settings-root-list-item .link-btn { padding: 2px; }
.settings-root-list-item .icon { width: 14px; height: 14px; }
.settings-select {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-base);
color: var(--text);
font-size: 0.9em;
cursor: pointer;
min-width: 180px;
}
.settings-select:focus { outline: none; border-color: var(--border-focus); }
/* ── Video player overlay ─────────────────────────────────────────────────── */
.video-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.92);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.video-top-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 20px;
z-index: 210;
}
.video-title {
color: #e2e8f0;
font-size: 0.9em;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.video-close {
background: rgba(255, 255, 255, 0.12);
border: none;
color: #e2e8f0;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1.2em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.video-close:hover { background: rgba(255, 255, 255, 0.25); }
.video-close.dl-active { background: rgba(34, 197, 94, 0.25); pointer-events: none; }
.video-close.cast-active { background: rgba(59, 130, 246, 0.35); }
.video-close.cast-active:hover { background: rgba(59, 130, 246, 0.5); }
.cast-url-input {
background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px; color: #e2e8f0; font-size: 0.75em; font-family: inherit;
padding: 4px 8px; min-width: 0; flex: 1; max-width: 420px;
cursor: text; outline: none;
}
.cast-url-input:focus { border-color: rgba(59, 130, 246, 0.6); }
.cast-wrapper { display: inline-block; }
.cast-picker {
position: absolute; top: calc(100% + 4px); right: 0; z-index: 100;
background: rgba(28, 28, 32, 0.97); border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px; padding: 4px 0; min-width: 230px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
}
.cast-picker-item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 9px 14px; background: none; border: none;
color: #e2e8f0; font-size: 0.82em; cursor: pointer; text-align: left;
font-family: inherit;
}
.cast-picker-item:hover { background: rgba(255, 255, 255, 0.09); }
.cast-picker-item svg { width: 16px; height: 16px; flex-shrink: 0; }
.cast-picker-scanning { color: #94a3b8; cursor: default; }
.cast-picker-scanning:hover { background: none; }
.cast-picker-empty { color: #64748b; cursor: default; font-style: italic; }
.cast-picker-empty:hover { background: none; }
.cast-picker-sep { height: 1px; margin: 4px 10px; background: rgba(255,255,255,0.1); }
.cast-status-label {
color: #94a3b8; font-size: 0.78em; display: flex; align-items: center;
max-width: 420px; overflow: hidden; white-space: nowrap;
}
.video-container {
width: 100%;
max-width: min(90vw, 1280px);
max-height: calc(100vh - 120px);
display: flex;
align-items: center;
justify-content: center;
}
.video-container video {
width: 100%;
max-height: calc(100vh - 120px);
border-radius: 4px;
outline: none;
}
/* Where the film was picked up again. Sits over the picture rather than
pushing it down, and clears itself once the viewer is watching. */
.video-container { position: relative; }
.video-resumed {
position: absolute;
left: 12px;
top: 12px;
display: flex;
gap: 10px;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.82);
color: #e2e8f0;
font-size: 0.85rem;
pointer-events: auto;
animation: video-resumed-fade 6s forwards;
}
@keyframes video-resumed-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; visibility: hidden; }
}
.video-resumed .linklike {
background: none;
border: 0;
padding: 0;
color: #7dd3fc;
cursor: pointer;
font: inherit;
text-decoration: underline;
}
.video-loading {
text-align: center;
color: #94a3b8;
}
.video-loading-label {
margin-bottom: 16px;
font-size: 0.95em;
}
.video-progress-bar {
width: min(400px, 80vw);
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
.video-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.15s;
}
.video-progress-text {
font-size: 0.8em;
color: #64748b;
}
.video-error {
color: var(--error);
font-size: 0.9em;
text-align: center;
max-width: 400px;
}
.play-btn {
background: none;
border: 1px solid var(--border);
color: var(--success);
width: 30px;
height: 30px;
border-radius: 6px;
font-size: 0.85em;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 4px;
}
.play-btn:hover { background: var(--bg-raised); border-color: var(--success); }
.play-btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* ── Admin panel ─────────────────────────────────────────────────────────── */
.admin-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 2px solid var(--border);
flex-wrap: wrap;
}
.admin-tab {
padding: 8px 16px;
border: none;
background: none;
color: var(--text-secondary);
font-size: 0.9em;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
}
.admin-tab:hover { color: var(--text); }
.admin-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.admin-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
text-align: center;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
}
.stat-card .stat-label {
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
margin-top: 4px;
}
.admin-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.admin-search {
flex: 1;
min-width: 180px;
max-width: 300px;
padding: 7px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-base);
color: var(--text);
font-size: 0.9em;
}
.admin-search:focus { outline: none; border-color: var(--border-focus); }
.admin-select {
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-base);
color: var(--text);
font-size: 0.85em;
cursor: pointer;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88em;
}
.admin-table th {
text-align: left;
padding: 8px 10px;
border-bottom: 2px solid var(--border);
color: var(--text-secondary);
font-size: 0.8em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.admin-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.admin-table tr:hover { background: var(--bg-raised); }
.admin-table .admin-empty {
text-align: center;
padding: 24px;
color: var(--text-dim);
font-style: italic;
}
/* Two things this cell has to do: hold a row of buttons, and stay as tall as
the rest of its row even when it is empty — the group owner cannot be
removed, and neither can your own account in the admin table.
`display: flex` did the second one and broke the first. **A flex
stops
being a table cell**: it no longer stretches to the height of its row, so
its bottom border is drawn wherever its own content ends. Measured, in a row
whose other cells were `top 76, height 40`, the actions cell came out
`top 77, height 30` — its rule nine pixels above the rest, which is the
staggered lines across the members table.
So the cell goes back to being a table cell, and an empty one is held open
by a zero-width strut the height of a button. Reported as "un décalage sur
les lignes du tableau listant les membres". */
.admin-actions {
white-space: nowrap;
}
.admin-actions::before {
content: '';
display: inline-block;
width: 0;
height: 30px;
vertical-align: middle;
}
.admin-actions > * { vertical-align: middle; }
.admin-actions > * + * { margin-left: 6px; }
/* ── Group settings sections ──────────────────────────────────────────────
The group tab was three forms and a table, each laid out with inline styles
on the element that needed it, which is why no two of them lined up. These
are the pieces they all wanted. */
/* An input and its button. Wraps rather than overflowing on a phone, and the
input takes the slack so the button keeps its size. */
.form-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.form-row input {
flex: 1;
min-width: 160px;
}
/* A pairing or invitation code: typed one character at a time, off a screen or
a piece of paper, so it is spaced and unambiguous. */
.code-input,
.code-display {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.code-display {
font-size: 1.4em;
letter-spacing: 2px;
margin: 6px 0;
}
.settings-subform {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.device-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
}
.device-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.device-row:last-child { border-bottom: none; }
.device-key {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
.device-meta {
color: var(--text-secondary);
font-size: 0.85em;
flex: 1;
min-width: 0;
}
.badge-owner {
background: var(--accent);
color: var(--accent-text);
}
.admin-btn {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-base);
color: var(--text);
font-size: 0.82em;
cursor: pointer;
white-space: nowrap;
}
.admin-btn:hover { border-color: var(--accent); color: var(--accent); }
.admin-btn.danger { color: var(--error); }
.admin-btn.danger:hover { border-color: var(--error); }
.admin-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* Used in four places and never defined, so it rendered as a bare browser
button next to styled ones. Same shape as .admin-btn, quieter. */
.btn-secondary {
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 5px;
background: none;
color: var(--text-secondary);
font-size: 0.82em;
cursor: pointer;
white-space: nowrap;
}
.btn-secondary:hover { border-color: var(--border); color: var(--text); }
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
/* Text that acts as a button: the "add a description" affordance should not
compete with the group's name for attention. */
.link-btn {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 0;
border: none;
background: none;
color: var(--text-dim);
font-size: 0.8em;
cursor: pointer;
}
.link-btn:hover { color: var(--accent); }
/* The header is a flex row, so a textarea in it collapses to its content
width unless told otherwise. */
.group-desc-edit { margin: 4px 0 6px; width: min(460px, 60vw); min-width: 260px; }
.group-desc-edit textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-surface);
color: var(--text);
font: inherit;
font-size: 0.85em;
resize: vertical;
}
.group-desc-edit textarea:focus { outline: none; border-color: var(--border-focus); }
.group-desc-edit div { display: flex; gap: 6px; margin-top: 4px; }
.settings-choice {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 7px 0;
cursor: pointer;
}
.settings-choice input { margin-top: 3px; flex-shrink: 0; }
.settings-choice strong { display: block; font-size: 0.88em; color: var(--text); }
.settings-choice .settings-hint { display: block; margin-top: 2px; }
/* A pasted address can be longer than the bubble; breaking it is better than
a message that scrolls sideways. */
.chat-link {
color: var(--accent);
text-decoration: underline;
word-break: break-all;
}
.chat-bubble-own .chat-link { color: inherit; }
/* ── Transfers widget ────────────────────────────────────────────────────── */
/* Green while transfers are running: the count in the badge says how many, but
the colour is what is readable without looking at it. Back to the ordinary
nav colour the moment the last one finishes. */
@keyframes pulse-green {
0%, 100% { color: #86efac; }
50% { color: #15803d; }
}
.transfer-btn.active .icon { animation: pulse-green 2s ease-in-out infinite; }
.transfer-wrap { position: relative; display: flex; align-items: center; }
.transfer-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
}
.transfer-panel {
position: absolute;
top: calc(100% + 10px);
right: -8px;
width: 330px;
max-height: 60vh;
overflow-y: auto;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow-lg);
z-index: 60;
padding: 6px;
}
.transfer-head {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 0.85em;
color: var(--text);
padding: 4px 6px 6px;
border-bottom: 1px solid var(--border);
}
.transfer-item { padding: 8px 6px; border-bottom: 1px solid var(--border); }
.transfer-item:last-child { border-bottom: none; }
.transfer-line { display: flex; align-items: center; gap: 6px; }
.transfer-kind { color: var(--text-dim); display: flex; }
.transfer-name {
flex: 1;
font-size: 0.82em;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.transfer-name {
color: var(--link);
text-decoration: underline;
cursor: pointer;
}
.transfer-cancel {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 0 2px;
display: flex;
}
.transfer-cancel:hover { color: var(--error); }
.transfer-meta {
display: flex;
justify-content: space-between;
font-size: 0.72em;
color: var(--text-dim);
margin-top: 3px;
}
.transfer-failed { color: var(--error); }
/* ── File selection ──────────────────────────────────────────────────────── */
.sel-cell { width: 28px; text-align: center; }
.sel-cell input { cursor: pointer; }
.actions-wrap { position: relative; display: inline-flex; margin-left: 8px; }
/* Anchored to the right edge: the button sits at the end of the toolbar, so a
menu opening leftwards is a menu half off the screen. */
.actions-wrap .file-menu { top: calc(100% + 4px); right: 0; left: auto; }
.actions-wrap .file-menu button { white-space: nowrap; }
.admin-btn.active { border-color: var(--accent); color: var(--accent); }
.admin-role-select {
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-base);
color: var(--text);
font-size: 0.85em;
cursor: pointer;
}
.admin-detail-overlay {
position: fixed;
inset: 0;
z-index: 150;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.admin-detail-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
min-width: 340px;
max-width: 90vw;
box-shadow: var(--shadow-lg);
}
.admin-detail-card h3 {
margin-bottom: 16px;
font-size: 1.1em;
}
.admin-detail-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 0.9em;
}
.admin-detail-row + .admin-detail-row {
border-top: 1px solid var(--border);
}
.admin-detail-label { color: var(--text-secondary); }
.admin-detail-value { font-weight: 500; }
.admin-load-more {
display: block;
margin: 16px auto;
padding: 8px 24px;
}
.blocklist-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.blocklist-form input {
flex: 1;
min-width: 180px;
padding: 7px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-base);
color: var(--text);
font-size: 0.9em;
}
.blocklist-form input:focus { outline: none; border-color: var(--border-focus); }
/* ── Overlay (mobile sidebar backdrop) ────────────────────────────────────── */
.overlay {
display: none;
position: fixed;
inset: 0;
top: 52px;
background: rgba(0, 0, 0, 0.4);
z-index: 40;
}
.overlay.visible { display: block; }
/* ── Responsive ───────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.nav-hamburger { display: flex; }
.sidebar {
position: fixed;
left: -240px;
top: 52px;
height: calc(100vh - 52px);
z-index: 50;
transition: left 0.2s;
box-shadow: none;
}
.sidebar.open { left: 0; box-shadow: var(--shadow-lg); }
.main { padding: 16px; }
.page-center { padding: 16px; }
.th-type, .td-type { display: none; }
.th-date, .td-date { display: none; }
/* The toolbar's three groups each take a line rather than competing for one.
`margin-left: auto` on the right-hand group is what pushed it off the
screen once it no longer fitted. */
.toolbar-group,
.toolbar-group.right { width: 100%; margin-left: 0; }
.tb-search { flex: 1 1 120px; }
.tb-search input { width: 100%; }
/* The actions sit on their own line under the toggle, and wrap within it. */
.tb-actions { width: 100%; }
/* video-app.js's toolbar isn't built from .toolbar-group (nothing else in
it needs a right-hand group), so it never got the row-per-group
treatment above — mode buttons + the All/Movies/Series control left no
room at all for the filter field once both were on the same line. */
.video-toolbar { flex-wrap: wrap; }
.video-toolbar .tb-search { flex-basis: 100%; margin-left: 0; }
/* Every pixel here is one the conversation does not get. */
.group-header { margin-bottom: 10px; gap: 8px; }
.group-tabs { margin-bottom: 10px; }
.chat-messages { padding: 12px; }
/* The transfers panel stops hanging off its button.
It is 330 px wide and anchored to the button's right edge, but that button
is not at the right edge of the screen — the bell and the user menu come
after it. So the panel extended past the left of the viewport: measured at
138 px lost on a 320 px screen, 98 px at 360. What is cut is the left-hand
side, which is where the file names are, so what remained was a strip of
progress bars belonging to nothing.
Anchoring to the viewport instead of to the button is the only thing that
helps: capping the width does not, since the overflow comes from where the
right edge is pinned. */
.transfer-panel {
position: fixed;
top: 52px;
right: 8px;
/* Full width on a phone, and no wider than the desktop panel on a tablet,
where stretching it across 768 px would be silly. Both edges are pinned
to the viewport, so the width follows from the screen rather than from
where the button happens to sit. */
left: max(8px, calc(100vw - 428px));
width: auto;
max-height: calc(100vh - 68px);
}
}
/* ── Notification bell ───────────────────────────────────────────────────── */
.nav-notif {
position: relative;
/* --text is the page's text colour and the nav is dark: the emoji bell was
coloured so it showed anyway, a stroked one would have been invisible. */
color: var(--nav-text);
text-decoration: none;
font-size: 1.25rem;
margin-right: 4px;
}
.notif-badge {
position: absolute;
top: -6px;
right: -8px;
background: var(--danger);
color: #fff;
font-size: 0.65rem;
padding: 1px 5px;
border-radius: 10px;
font-weight: 700;
}
/* ── Notification feed ───────────────────────────────────────────────────── */
.notif-feed {
margin-bottom: 24px;
}
.notif-feed h3 {
margin: 0 0 8px;
font-size: 1rem;
}
.notif-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: var(--radius);
background: var(--bg-surface);
margin-bottom: 4px;
cursor: pointer;
border: 1px solid var(--border);
}
.notif-item:hover {
background: var(--bg-raised);
}
.notif-unread {
border-left: 3px solid var(--primary);
font-weight: 600;
}
.notif-kind {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
white-space: nowrap;
}
.notif-text {
flex: 1;
font-size: 0.9rem;
}
.notif-time {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
}
/* ── Create Group page ───────────────────────────────────────────────────── */
.form-field { margin-bottom: 20px; }
.form-label {
display: block;
font-size: 0.82em;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.form-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-base);
color: var(--text);
font-size: 0.95em;
font-family: inherit;
resize: vertical;
min-height: 60px;
line-height: 1.5;
}
.form-textarea:focus { outline: none; border-color: var(--border-focus); }
.form-char-count { text-align: right; font-size: 0.75em; color: var(--text-dim); margin-top: 4px; }
.group-desc {
font-size: 0.85em;
color: var(--text-secondary);
line-height: 1.4;
margin-bottom: 4px;
}
.group-card-desc {
font-size: 0.8em;
color: var(--text-dim);
line-height: 1.3;
margin: 4px 0 6px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* PDF preview: fills the overlay, scrolls in its own viewer. */
.preview-pdf {
width: min(1000px, 92vw);
height: 82vh;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-surface);
}
/* Irreversible actions look like it. */
.btn-danger {
background: var(--error);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
}
.btn-danger:hover { filter: brightness(1.1); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
.notif-feed h3 { display: flex; align-items: center; justify-content: space-between; }
.notif-purge { font-size: 0.8em; padding: 4px 10px; }
/* ── Presence ──────────────────────────────────────────────────────────────
Three states, and the third is deliberately not a colour: "unknown" must
not read as a verdict. Colour alone never carries the meaning — each dot
also has a title and an aria-label, since red and green are exactly the
pair most colour-blind readers cannot separate. */
.presence {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
border: 1px solid transparent;
transition: background 0.2s, border-color 0.2s;
}
.presence-online { background: var(--success); }
.presence-offline { background: var(--error); }
.presence-unknown { background: transparent; border-color: var(--text-dim); }
/* The node is scanning this group's files right now (initial import, or a
directory just added) — a state, not a verdict, so it pulses rather than
sitting on a fixed color; green at rest, green again once scanning stops
(driven by IndexProgress.scanning, guaranteed to turn back off — see
daemon.py _progress_pusher and webrtc_server.py's handshake ack). */
.presence-indexing {
background: var(--success);
animation: presence-indexing-pulse 1.6s ease-in-out infinite;
}
@keyframes presence-indexing-pulse {
0%, 100% { background: var(--success); }
50% { background: #f59e0b; }
}
.sidebar-item-name {
overflow: hidden;
min-width: 0;
}
/* Group name + its @owner handle, stacked. Names are unique only per owner
account, so the handle is what tells two "photos" groups apart. */
.gn { display: block; min-width: 0; }
.gn-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gn-owner {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8em;
font-weight: 400;
color: var(--text-dim);
}
h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
/* ── Chat: paging and orientation ─────────────────────────────────────────── */
.chat-older-row { display: flex; justify-content: center; padding: 4px 0 10px; }
.chat-older-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 0.8em;
cursor: pointer;
}
.chat-older-btn:hover:not(:disabled) { background: var(--bg-surface); color: var(--text); }
.chat-older-btn:disabled { opacity: 0.6; cursor: default; }
.chat-older-icon { width: 13px; height: 13px; transform: rotate(180deg); }
.chat-start {
text-align: center;
color: var(--text-dim);
font-size: 0.75em;
padding: 2px 0 12px;
}
/* A rule with the date sitting on it — cheaper to scan than a date on every
bubble, which is why the per-message stamp stays as just a time. */
.chat-day, .chat-unread {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0 6px;
font-size: 0.72em;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.chat-day::before, .chat-day::after,
.chat-unread::before, .chat-unread::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.chat-day { color: var(--text-dim); }
.chat-day span, .chat-unread span { white-space: nowrap; }
.chat-unread { color: var(--accent); }
.chat-unread::before, .chat-unread::after { background: var(--accent); opacity: 0.4; }
/* Consecutive messages from one person: tighten the gap instead of repeating
the name, so a burst reads as one turn in the conversation. */
.chat-msg-tight { margin-top: -2px; }
.chat-jump {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 72px;
z-index: 5;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 16px;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
font-size: 0.8em;
cursor: pointer;
box-shadow: var(--shadow-lg);
}
.chat-jump:hover { color: var(--text); }
.chat-jump.unread {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.chat-jump-icon { width: 13px; height: 13px; }
/* ── File toolbar actions ─────────────────────────────────────────────────────
These replaced a dropdown labelled "Actions (n)". With at most four verbs,
hiding them behind a menu cost a click and a guess about what was inside. */
.tb-actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.tb-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.tb-icon-btn:hover:not(:disabled) {
background: var(--bg-raised);
color: var(--text);
border-color: var(--accent);
}
/* Present but inert: the bar shows what exists here, and greys out what this
selection cannot do. `not-allowed` rather than `default` so the reason is
legible before the click. */
.tb-icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tb-icon-btn .icon { width: 16px; height: 16px; }
.tb-icon-btn.danger { color: var(--error); }
.tb-icon-btn.danger:hover:not(:disabled) {
background: var(--error-bg);
border-color: var(--error);
color: var(--error);
}
/* ── Choice lists ─────────────────────────────────────────────────────────────
Replaces two side-by-side |