/* 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