/* 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;
/* Used with a literal fallback in a handful of rules before they were
defined here — same values, so nothing moves; now themeable, and the
names in those rules are real. */
--warn: #d97706;
--accent-bg: rgba(59, 130, 246, 0.1);
--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;
--warn: #f59e0b;
--accent-bg: rgba(59, 130, 246, 0.18);
--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);
}
/* ── Metrics ──────────────────────────────────────────────────────────────── */
/* Not a palette: these are the layout's own numbers, so they sit outside the
theme blocks and neither theme overrides them.
`--nav-h` is the height of `.nav`, which is `position: sticky` at the top of
every page — the offset everything else pins beneath. The other two are
*measured* at runtime by sticky.js and published onto the page's root
element, which shadows the value declared here. They are declared anyway, and
as `0px` rather than as nothing: an undefined custom property invalidates the
whole `calc()` that names it (test_css_variables.py has the story), so
without these a toolbar would silently lose its `top` — and lose it exactly
in the window between the first paint and the first measurement, which is
also the window a screenshot is most likely to catch. At `0px` the band pins
directly under the navigation bar instead, which is where it belongs when
there is nothing above it. */
:root {
--nav-h: 52px;
--chrome-h: 0px;
--toolbar-h: 0px;
/* Published by the music bar while it is on screen (sticky.js). */
--music-bar-h: 0px;
}
/* ── 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);
/* A pinned band keeps this gap: `--band-margin` is both the margin in
the flow and the width of the opaque ring the band paints around
itself once pinned, so the two can never disagree (style.css,
"Sticky chrome"). */
--band-margin: 12px;
margin-bottom: var(--band-margin);
}
.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 ──────────────────────────────────────────────────────────────── */
/* Pinned under the navigation bar at the window's height, so the legal link at
its foot is at the bottom of the window whatever the page's length — on a
long file list it would otherwise be at the bottom of the page. It stops
above the music bar, which is pinned to the bottom of the window too. */
.sidebar {
position: sticky;
top: var(--nav-h);
align-self: flex-start;
display: flex;
flex-direction: column;
width: 240px;
height: calc(100vh - var(--nav-h) - var(--music-bar-h));
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
padding: 16px 0 0;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-legal {
margin-top: auto;
padding: 10px 20px 12px;
color: var(--text-dim);
font-size: 0.75em;
}
.sidebar-legal:hover { color: var(--accent); }
.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.
`max-width` goes too: signed out there is no sidebar, so the 960px column sat
at the left of the window and a "centred" form was centred in that — at
x=290 in a 1440px window. */
.main:has(> .page-center) { padding: 0; max-width: none; }
/* ── Sticky chrome ────────────────────────────────────────────────────────── */
/* Files, Videos, Music and Photos are read by scrolling, and until now the
controls that steer that reading went with the first screenful: which
application, where in the tree, which filter, which column sorts. They pin
under the navigation bar instead, in three bands, on the two pages that show
those applications — a group (`group-page.js`) and Search (`search-page.js`),
both of which mark their root `.sticky-chrome`:
.nav 52px, already sticky, --nav-h
.group-tabs / .search-bar which application / what is being searched
the application's toolbar where in the tree, the filter, the actions
.file-table th the column heads
The group's name and description are deliberately *not* in the stack. They
say nothing a reader needs while walking a directory, and the height they
would cost is height the list does not get.
Each band pins below the ones above it, so its `top` is their heights added
up — heights that change with the window (`.video-toolbar` wraps on a phone,
by design), with the state (naming a folder grows the toolbar; `hideFilter`
shrinks it) and with the theme's font. sticky.js measures them and publishes
`--chrome-h` and `--toolbar-h`; everything here reads those rather than
repeating a number that would be right in one file and wrong in another.
`>` throughout, and not by accident: a band publishes its height onto its
own parent, so these rules and sticky.js are asserting the same structural
fact — these elements are children of the page root. Nest one deeper and
both stop applying, rather than the stylesheet pinning something the script
is no longer measuring.
`.file-toolbar` is also the toolbar of the public directory
(`explore-page.js`), which is not one of these pages and is left alone. */
.sticky-chrome > .group-tabs,
.sticky-chrome > .search-bar {
position: sticky;
top: var(--nav-h);
z-index: 30;
}
.sticky-chrome > .file-toolbar,
.sticky-chrome > .video-toolbar,
.sticky-chrome > .photo-toolbar,
.sticky-chrome > .photo-album-bar {
position: sticky;
top: calc(var(--nav-h) + var(--chrome-h));
z-index: 20;
}
.sticky-chrome > .file-table th {
position: sticky;
top: calc(var(--nav-h) + var(--chrome-h) + var(--toolbar-h));
z-index: 10;
}
/* A pinned band has the list running underneath it and must be opaque, which
most of these were not: only `.search-bar` and `.file-toolbar` came with a
surface of their own. So the bare bands take the page colour outright. */
.sticky-chrome > .group-tabs,
.sticky-chrome > .video-toolbar,
.sticky-chrome > .photo-toolbar,
.sticky-chrome > .photo-album-bar,
.sticky-chrome > .file-table th {
background: var(--bg-base);
}
/* **The gap a band keeps when it pins.** In the flow every band is followed by
a margin — 16px under the tab bar, 12px under the search field and the file
toolbar, 14px under the media ones. Pinning absorbs it: the band below stops
at the bottom edge of the band above and the two come into contact. Reported
on both counts, and they are the same fault — two rounded panels flush
against each other in Files read as *encastrés*, and the row of controls in
Videos/Music/Photos reads as glued to the tab bar.
So each band paints its own margin as a ring of page colour around itself,
and the band below pins past it: `--chrome-h`/`--toolbar-h` are published by
sticky.js as **height plus that margin**, which is why the ring and the
offset can never disagree — they are the same number, declared once beside
the component's own `margin-bottom` and nowhere else. The pinned layout is
then pixel-identical to the flow layout, so nothing shifts at the moment a
band pins.
The ring must be exactly the margin, not more: it is painted at the band's
own z-index, above every band below it, so a ring wider than the gap would
paint page colour over the top of the next band down. It is also what covers
the four transparent corners a border radius leaves, where a row's rule
sliding past would read as a flicker nobody can reproduce. */
.sticky-chrome > .group-tabs,
.sticky-chrome > .search-bar,
.sticky-chrome > .file-toolbar,
.sticky-chrome > .video-toolbar,
.sticky-chrome > .photo-toolbar,
.sticky-chrome > .photo-album-bar {
box-shadow: 0 0 0 var(--band-margin) var(--bg-base);
}
/* **And the same gap above the first one.** The ring is painted on all four
sides, which is what the corners need — so a band has to have
`--band-margin` of clearance above it too, or it paints page colour over
whatever is there. Every band but the first has that for free: what sits
above it is the band it pins under, at a higher z-index, and a ring cannot
paint over that. The first has the page's own content above it, and nothing
was making the two agree — the join-code form under a group's title leaves
12px, the tab bar's ring is 16px, and the form was handed back with the
bottom 4px of its field and its button painted over. The same 4px went off
the bottom of the "could not reach this node" banner, which is the other
thing that stands between the title and the tabs.
Only the first band, and that is the whole of the rule rather than an
economy: the gap *between* two bands is the upper one's `--band-margin` and
nothing else — it is the number `--chrome-h` carries and the offset the
lower one pins at. A margin-top on a lower band would collapse to the
larger of the pair and win wherever it was bigger, which is most widths,
leaving the flow a couple of pixels wider than the pinned layout. Measured,
in every media view at every phone width, the moment this rule was written
for all six.
`* +`, so this is the gap between two elements and not a margin the band
always carries: as a first child — the Search page's field is one — a
margin-top would collapse through the page root and take the whole page
down with it. Between siblings the two margins collapse to the larger of
the pair, so every place that already leaves enough is untouched, and only
what was being painted over moves. */
.sticky-chrome > * + .group-tabs,
.sticky-chrome > * + .search-bar {
margin-top: var(--band-margin);
}
/* ── 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);
}
/* ── Welcome (sign-in page, browser only) ─────────────────────────────────── */
/* What MeshBay is on the left, the form on the right and level with the
title; stacked, form first, once the text would be narrower than the form.
The form comes first in the markup (first on a phone, first to the
keyboard) and `row-reverse` is what moves it right. Headings are
`.welcome-pitch .welcome-h` rather than a bare class so they outrank
`.main h2`. */
.welcome {
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
justify-content: center;
gap: 96px;
width: 100%;
max-width: 1180px;
}
.welcome > .login-card { flex: 0 0 380px; }
.welcome-pitch {
flex: 1 1 0;
min-width: 0;
max-width: 660px;
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
}
.welcome-pitch p { margin-bottom: 10px; }
.welcome-apps, .welcome-uses, .welcome-never { list-style: none; }
.welcome-title {
margin-bottom: 10px;
color: var(--text);
font-size: 1.9em;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
text-wrap: balance;
}
.welcome-lead { font-size: 1.05em; }
.welcome-apps { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 14px; }
.welcome-apps li {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-surface);
color: var(--text);
font-size: 0.88em;
}
.welcome-apps .icon { color: var(--accent); }
.welcome-e2e {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 14px;
border-left: 3px solid var(--accent);
border-radius: 0 6px 6px 0;
background: var(--accent-bg);
color: var(--text);
}
.welcome-e2e .icon { margin-top: 0.3em; color: var(--accent); }
.welcome-pitch .welcome-h {
margin: 18px 0 8px;
color: var(--text-dim);
font-size: 0.78em;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.welcome-uses { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 18px; }
.welcome-uses li {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--text);
font-size: 0.92em;
line-height: 1.45;
}
.welcome-use-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--accent-bg);
color: var(--accent);
font-size: 17px;
}
.welcome-hub {
margin-top: 18px;
padding: 12px 18px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-surface);
font-size: 0.92em;
}
.welcome-pitch .welcome-hub .welcome-h { margin-top: 0; }
.welcome-pitch .welcome-hub-never { margin-bottom: 6px; color: var(--text); font-weight: 600; }
.welcome-never { display: flex; flex-direction: column; gap: 4px; color: var(--text); }
.welcome-never li { display: flex; align-items: flex-start; gap: 8px; }
.welcome-never .icon { margin-top: 0.3em; color: var(--success); }
.welcome-pitch .welcome-hub-free {
margin: 10px 0 0;
padding-top: 8px;
border-top: 1px solid var(--border);
color: var(--text);
font-weight: 500;
}
.welcome-links {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
margin-top: 14px;
}
.welcome-cta {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: 6px;
background: var(--accent);
color: var(--accent-text);
font-size: 0.95em;
font-weight: 600;
}
.welcome-cta:hover { background: var(--accent-hover); text-decoration: none; }
.welcome-legal { color: var(--text-secondary); font-size: 0.9em; }
.welcome-legal:hover { color: var(--accent); }
@media (max-width: 1000px) {
.welcome { flex-direction: column; align-items: center; gap: 36px; }
.welcome > .login-card { flex: none; }
.welcome-pitch { flex: none; width: 100%; max-width: 520px; }
}
@media (max-width: 520px) {
.welcome-title { font-size: 1.6em; }
.welcome-uses { grid-template-columns: 1fr; }
}
/* ── 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;
}
/* The middle state: the hub is still sending what somebody is waiting on,
but not what a newcomer needs. `--warn` already exists in both themes. */
.warn-msg {
background: color-mix(in srgb, var(--warn) 12%, transparent);
color: var(--warn);
border: 1px solid var(--warn);
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;
/* A pinned band keeps this gap: `--band-margin` is both the margin in
the flow and the width of the opaque ring the band paints around
itself once pinned, so the two can never disagree (style.css,
"Sticky chrome"). */
--band-margin: 16px;
margin-bottom: var(--band-margin);
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;
/* A pinned band keeps this gap: `--band-margin` is both the margin in
the flow and the width of the opaque ring the band paints around
itself once pinned, so the two can never disagree (style.css,
"Sticky chrome"). */
--band-margin: 12px;
margin-bottom: var(--band-margin);
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; }
/* Square, for a toolbar button whose name is in its tooltip rather than
beside it. The horizontal padding goes; the height does not, so it lines up
with the labelled buttons next to it. */
.tb-btn-icon { padding: 0; width: 32px; justify-content: center; gap: 0; }
/* Naming a new folder, in the toolbar. Not `window.prompt`, which throws in
Electron — and an inline field can put the node's refusal next to the input
rather than after a dialog has closed. */
.tb-mkdir { display: inline-flex; align-items: center; gap: 6px; }
.tb-mkdir-input {
height: 32px; width: 170px; padding: 0 9px;
border: 1px solid var(--border-focus); border-radius: 6px;
background: var(--bg-surface); color: var(--text);
font-family: inherit; font-size: 0.83em;
}
.tb-mkdir-input:focus { outline: none; }
.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); }
.crumb-refresh {
background: none;
border: 0;
font: inherit;
color: var(--text-secondary);
}
.crumb-refresh:hover:not(:disabled) { color: var(--text); }
.crumb-refresh:disabled { cursor: default; }
.crumb-refresh.spinning .icon { animation: spin 0.8s linear infinite; }
/* ── File table ──────────────────────────────────────────────────────────── */
.file-table {
width: 100%;
/* Separated, with no spacing, rather than collapsed. A collapsed border is
shared between two cells and so belongs to the table rather than to either
of them — it stays where the table's flow put it while a `position:
sticky` column head moves away, so the head loses its rule the moment it
pins. Only the bottom edge of any cell here carries a border, so nothing
was being shared and the drawn result is the same single line; it now
travels with the head. */
border-collapse: separate;
border-spacing: 0;
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; }
/* Every cell whose text is a name somebody else chose.
A table column is never narrower than its widest cell's minimum, and a name
with no spaces in it — `Rage_Against_The_Machine_Discography_1992-2000_FLAC`,
`musique_de_la_maison_2024` — is one unbreakable word. Left to itself such a
cell sets the column's minimum to the whole string and the table grows past
the window: measured on a phone at 390px, a 527px table from a folder name
and a 442px one from a group name. That is not merely ugly. **On Android a
document wider than the screen takes everything `position: sticky` out of
the visible area with it** — the navigation bar included — so the reported
symptom was "the header does not stay", and the cause was a table column.
Both cells used to be bare `
`s: `.file-name` was on a file's name and on
nothing else, so a *folder* row and the Search page's group column had no
wrapping rule at all. `test_sticky_header.py::test_no_view_scrolls_sideways`
measures the page against its window in both engines and at three widths. */
.file-name,
.td-group {
word-break: break-word;
min-width: 0;
}
/* The column heads are `white-space: nowrap` like every other one; the group
head is two syllables and never needed the exception. */
.file-table th.td-group { white-space: normal; }
/* 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;
}
/* A number that sits beside its label rather than under it — the mail bounds
are a column of short values, and one input per line reads as a table. */
.settings-number {
width: 110px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-base);
color: var(--text);
font-size: 0.95em;
text-align: right;
}
.settings-number:disabled {
opacity: 0.6;
}
.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; }
/* The header button stretches; the action (an app's on/off switch) keeps its
own width at the end, and its own click. */
.settings-collapsible-bar { display: flex; align-items: center; gap: 12px; }
.settings-collapsible-bar .settings-collapsible-header { flex: 1 1 auto; min-width: 0; }
.settings-collapsible-action { flex: 0 0 auto; }
/* One app's settings pane, inside its section. */
.app-settings > .settings-row:first-child { margin-top: 0; }
/* The Save button of an app's settings pane.
*
* It was `btn btn-small btn-secondary`, and there is no `.btn` rule at all —
* so it inherited `.btn-secondary`: no background, a transparent border, dim
* grey text. Enabled, it already looked like a disabled control; disabled, it
* was the same thing at 40% opacity. "You cannot always click Save, you do not
* notice, and it does not work" was one sentence describing all of that.
*
* It looks like a button now, and a disabled one looks disabled. */
.app-save {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 14px; margin-top: 4px;
border: 1px solid var(--accent); border-radius: 6px;
background: var(--accent); color: var(--accent-text);
font-family: inherit; font-size: 0.83em; cursor: pointer;
}
.app-save:hover:not(:disabled) { background: var(--accent-hover); }
/* No transition on the enabled/disabled swap. It animates a colour change that
coincides with a text-colour change, which reads as a flicker — and it made
the state genuinely hard to observe: a measurement taken during it reports a
transparent background on a button that is not transparent. */
.app-save:disabled {
background: none; color: var(--text-dim);
border-color: var(--border); opacity: 1; cursor: default;
}
/* Why it is inert, beside it — a disabled control that explains itself is the
difference between "nothing changed" and "this is broken". */
.app-settings-sub {
margin: 18px 0 4px; font-size: 0.9em; font-weight: 600;
padding-top: 12px; border-top: 1px solid var(--border);
}
.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 {
position: relative;
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 {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #94a3b8;
pointer-events: none;
z-index: 1;
}
.video-loading-label {
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 {
/* `--link` is not defined here; the palette's link colour is `--accent`. */
color: var(--accent);
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); }
/* Same shape as cancel, and beside it: pausing and cancelling are the two
things a person does to a transfer, and one of them is not destructive. */
.transfer-pause {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 0 2px;
display: flex;
}
.transfer-pause:hover { color: var(--accent); }
/* Not a button: there is nothing to click. Dimmer than the cancel beside it,
and it carries its explanation in a tooltip rather than in the row, which
would be four lines of prose in a panel that has none. */
.transfer-nopause {
color: var(--text-dim);
opacity: .45;
padding: 0 2px;
display: flex;
cursor: help;
}
/* A paused bar keeps its fill -- what was written is still on disk -- but stops
looking like something in progress. */
.dl-fill.dl-paused { background: var(--text-dim); }
.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; }
/* No tray to minimise into on a narrow layout, and the nav has the
hamburger to fit here. The capability already keeps this out of any
browser; this is the app's own window dragged narrow. */
.nav-tray { display: none; }
.sidebar {
position: fixed;
left: -240px;
top: 52px;
height: calc(100vh - 52px - var(--music-bar-h));
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; }
/* And the group column, on the same grounds as those two: there is no room
for it, and the name column is what a phone is short of. Nothing is lost
with it. Search's Files view re-roots every result under a folder named
after its group, so you are always inside exactly one group and the
breadcrumb above the table already says which — the column repeated it on
every row. */
.td-group { 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; }
/* ── Shared directories: not a table on a phone ────────────────────────
Four columns — name, Writable, Removable, and the eject/remove pair —
have a combined minimum of about 440px, and none of it is padding that
can be squeezed: the two switches are 90px apiece by design and the
buttons are touch targets. On a 390px screen that is ~100px hanging off
the right, with the two buttons the first thing to go over the edge.
Reported from a phone, with short directory names, so no amount of
wrapping the *name* would have helped.
So the row stops being a row. The head goes (each switch carries its own
label instead, see `.sdt-col-toggle .toggle-switch-label`), the name
takes a line of its own, and the two switches sit under it with the
actions pushed to the right margin:
📁 Musique
[•] Writable [•] Removable ⏏ ✕
Everything that was on screen is still on screen, and nothing is
truncated. The rules are scoped to `.shared-directories-table` on
purpose: `folder-tree.js` borrows `.shared-dirs-tbl` for a two-column
list — a name and one button — which fits a phone as it is, and stacking
it would only make it taller. */
.shared-directories-table .shared-dirs-tbl,
.shared-directories-table .shared-dirs-tbl tbody,
.shared-directories-table .shared-dirs-tbl tr,
.shared-directories-table .shared-dirs-tbl td { display: block; }
.shared-directories-table .shared-dirs-tbl thead { display: none; }
/* Two rows, not three. Left to wrap, the eject/remove pair ends up alone on
a line of its own under the switches — legible, but a third of the height
for two buttons, and they read as belonging to nothing. A grid puts them
back beside the name they act on, which is where they were:
┌──────────────────────────┬────────┐
│ 📁 Musique │ ⏏ ✕ │
├──────────────────────────┴────────┤
│ [•] Writable [•] Removable │
└───────────────────────────────────┘
The switches are not placed: they fall into the second row on their own,
so a group with one switch (the create-group page has no Removable) or
none needs no rule of its own. */
.shared-directories-table .shared-dirs-tbl tr {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px 16px;
padding: 10px 0;
}
.shared-directories-table .shared-dirs-tbl td { padding: 0; }
/* The separator moves to the row: with the cells turned into blocks, a
border on each of them draws four lines instead of one. */
.shared-directories-table .shared-dirs-tbl tbody tr + tr td { border-top: none; }
.shared-directories-table .shared-dirs-tbl tbody tr + tr {
border-top: 1px solid var(--border);
}
.shared-directories-table .sdt-col-dir {
grid-column: 1;
grid-row: 1;
min-width: 0;
}
/* No longer a single truncated line: it has the width of the row to itself
and there is no column left to protect from it. */
.shared-directories-table .sdt-col-path {
grid-column: 1 / -1;
max-width: none;
white-space: normal;
overflow-wrap: anywhere;
}
.shared-directories-table .sdt-col-toggle {
width: auto;
min-width: 0;
text-align: left;
}
.shared-directories-table .sdt-col-toggle .toggle-switch {
justify-content: flex-start;
gap: 8px;
}
.shared-directories-table .sdt-col-toggle .toggle-switch-label { display: inline; }
/* Beside the name, against the right margin, exactly where the wide layout
puts them. */
.shared-directories-table .sdt-col-actions {
grid-column: 2;
grid-row: 1;
justify-self: end;
}
/* Every pixel here is one the conversation does not get. */
.group-header { margin-bottom: 10px; gap: 8px; }
.group-tabs { --band-margin: 10px; }
/* Six tabs at 18px of padding a side come to 380px, plus the main column's
own 32px — wider than a 360px phone, and a page wider than the screen is
what takes every pinned band out of the visible area on Android. They
share the row instead of each claiming a fixed width, which also means a
seventh application costs nothing: the icon is what identifies a tab, the
padding around it never did. */
.group-tab { flex: 1 1 0; min-width: 0; padding: 10px 0; }
.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);
}
}
/* ── Minimise to tray ────────────────────────────────────────────────────── */
/* Monochrome on purpose: it sits next to the bell, and the bell's badge is the
only thing in this corner allowed to pull the eye. Inherits --nav-text like
every other nav control, so it follows the theme rather than carrying its own
colour, and the glyph is a stroked `currentColor` icon with nothing to tint. */
.nav-tray {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: 0;
padding: 4px;
margin-right: 2px;
color: var(--nav-text);
cursor: pointer;
border-radius: 6px;
opacity: 0.75;
}
.nav-tray:hover,
.nav-tray:focus-visible {
opacity: 1;
background: rgba(255, 255, 255, 0.1);
}
/* ── 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;
/* `--danger` is not one of this stylesheet's variables — the palette calls
it `--error`. The declaration was invalid, so the unread-count badge had
white text on no background at all. Pre-existing; found while fixing four
more of the same in the folder picker. */
background: var(--error);
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;
/* `--radius` was never defined — 8px is what the other cards use. */
border-radius: 8px;
background: var(--bg-surface);
margin-bottom: 4px;
cursor: pointer;
border: 1px solid var(--border);
}
.notif-item:hover {
background: var(--bg-raised);
}
.notif-unread {
/* `--primary` is not defined here either; it is `--accent`. */
border-left: 3px solid var(--accent);
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, on one line. Names are unique only per owner
account, so the handle is what tells two "photos" groups apart. */
.gn { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
.gn-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gn-owner {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8em;
font-weight: 400;
color: var(--text-dim);
flex-shrink: 0;
}
h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
/* A card's h3 is reset to 1em (unlike a page heading, which is much larger),
so the 0.55em above would render the @host at about half the body size —
smaller than the name, as intended, but past the point of being readable.
0.8em is the same ratio the standalone .gn-owner uses. */
.group-card h3 .gn-owner { font-size: 0.8em; }
/* ── 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