diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-16 15:29:11 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-16 15:29:11 +0200 |
| commit | 84e778d5cdd1bb5cded8a7c0238797c17d48666c (patch) | |
| tree | d26f1d20735ba2db81da812371c92d257179882c /packages/meshbay-hub/src/meshbay_hub/static/style.css | |
| parent | 188a76f52d2f30609147b1beee7754f2cbd1e778 (diff) | |
| download | meshbay-84e778d5cdd1bb5cded8a7c0238797c17d48666c.tar.gz | |
feat(hub): chat, presence, a Profile page, and downloads that do not freeze
Chat opens on the newest hundred messages, loads fifty older on demand with the
reading position anchored — the distance from the *bottom*, since everything
above the viewport just grew — and follows new messages only when the reader was
already at the end. Day separators, sender grouping, an unread marker, and a
jump-to-latest pill. Messages are keyed by id: index keys plus prepending makes
Preact reuse the wrong bubbles.
A presence dot per group in the sidebar, three states, each backed by something:
the hub's registry, or a connection this browser made or failed to make. Never
colour alone — red and green are the pair colour-blind readers cannot separate —
so each dot carries a title and an aria-label.
Profile is split out of Settings: identity, node link, pinned node identities and
account deletion. Mixing them put an irreversible button two scrolls under a
theme picker. The create-group page loses its centred 520 px card, which left
190 px of margin either side, and its two button panels become a radio group —
a button conveys no chosen state to a screen reader, and side by side they read
as two independent actions rather than one either/or.
The Files toolbar shows its actions as icon buttons the moment Select is on,
disabled when they do not apply rather than appearing and vanishing. On a phone
the right-hand group could not wrap and ran 130 px off the screen.
Streamed downloads no longer freeze after one chunk. `registration.active` says
a worker exists, not that this page is controlled by it — and an uncontrolled
page's requests never reach its fetch handler, so the worker took the stream and
was never asked for it, leaving `writer.write()` waiting on backpressure that
would never lift. The page now requires control and the worker confirms it
actually served the request before the sink is trusted.
Fixed on the way: `setActionsOpen` outlived the state it belonged to and threw
on every Files action; the chat scrollbar stopped short of the bottom; the
owner's row sat lower than the rest; About showed a version hardcoded two
releases ago.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/style.css')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 306 |
1 files changed, 252 insertions, 54 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 11c108b..a041f66 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -232,7 +232,9 @@ a:hover { text-decoration: underline; } } .sidebar-item { - display: block; + display: flex; + align-items: center; + gap: 8px; padding: 7px 12px; border-radius: 6px; color: var(--text); @@ -442,10 +444,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } /* ── Chat panel ──────────────────────────────────────────────────────────── */ .chat-panel { + position: relative; display: flex; flex-direction: column; - height: calc(100vh - 220px); - min-height: 300px; + /* 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; @@ -565,7 +572,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } border-radius: 8px; flex-wrap: wrap; } -.toolbar-group { display: flex; align-items: center; gap: 6px; } +.toolbar-group { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + min-width: 0; +} .toolbar-group.right { margin-left: auto; } .tb-btn { @@ -610,6 +623,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } 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; } @@ -619,6 +635,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } outline: none; padding: 0; width: 150px; + min-width: 0; font-size: 0.83em; color: var(--text); } @@ -627,6 +644,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } display: flex; align-items: center; gap: 2px; + flex-wrap: wrap; + min-width: 0; font-size: 0.85em; min-width: 0; flex-wrap: wrap; @@ -1180,7 +1199,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } font-style: italic; } -.admin-actions { display: flex; gap: 6px; } +/* A row whose actions cell is empty — the group owner, who cannot be removed, + or your own account in the admin table — used to sit lower than the rest: + `display: flex` makes the cell the height of its button, and nothing when + there is no button. Reserving the height keeps every row aligned without + rendering a disabled button nobody can use. */ +.admin-actions { + display: flex; + gap: 6px; + align-items: center; + min-height: 30px; +} .admin-btn { padding: 4px 10px; @@ -1443,6 +1472,21 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .page-center { padding: 16px; } .th-type, .td-type { display: none; } .th-date, .td-date { display: none; } + + /* The toolbar's three groups each take a line rather than competing for one. + `margin-left: auto` on the right-hand group is what pushed it off the + screen once it no longer fitted. */ + .toolbar-group, + .toolbar-group.right { width: 100%; margin-left: 0; } + .tb-search { flex: 1 1 120px; } + .tb-search input { width: 100%; } + /* The actions sit on their own line under the toggle, and wrap within it. */ + .tb-actions { width: 100%; } + + /* Every pixel here is one the conversation does not get. */ + .group-header { margin-bottom: 10px; gap: 8px; } + .group-tabs { margin-bottom: 10px; } + .chat-messages { padding: 12px; } } /* ── Notification bell ───────────────────────────────────────────────────── */ @@ -1513,29 +1557,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } /* ── Create Group page ───────────────────────────────────────────────────── */ -.create-group-page { - display: flex; - justify-content: center; - padding-top: 8px; -} -.create-group-card { - width: 100%; - max-width: 520px; - background: var(--bg-surface); - border: 1px solid var(--border); - border-radius: 12px; - padding: 32px; - box-shadow: var(--shadow); -} -.create-group-header { margin-bottom: 24px; } -.create-group-header h2 { margin-bottom: 8px; font-size: 1.3em; } -.create-group-hint { - color: var(--text-secondary); - font-size: 0.88em; - line-height: 1.5; -} .form-field { margin-bottom: 20px; } .form-label { @@ -1548,37 +1571,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } margin-bottom: 8px; } -.option-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } -.option-card { - display: flex; flex-direction: column; align-items: center; gap: 4px; - padding: 14px 10px; - background: var(--bg-base); - border: 2px solid var(--border); - border-radius: 10px; - cursor: pointer; - text-align: center; - transition: border-color 0.12s, background 0.12s; -} -.option-card:hover { border-color: var(--accent); background: var(--bg-base); } -.option-card.selected .option-icon { color: var(--accent); } -.option-card.selected { - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 8%, var(--bg-base)); -} -.option-icon { width: 26px; height: 26px; margin-bottom: 2px; color: var(--text-secondary); } -.option-title { font-weight: 600; font-size: 0.9em; color: var(--text); } -.option-desc { font-size: 0.75em; color: var(--text-dim); line-height: 1.3; } -.create-group-submit { - width: 100%; - padding: 12px; - font-size: 1em; - font-weight: 600; - margin-top: 8px; - border-radius: 8px; -} .form-textarea { width: 100%; @@ -1638,3 +1633,206 @@ button: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); } + +.sidebar-item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── 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 <button> panels. They carried the selection in a + border colour and nothing else: a button conveys no chosen/not-chosen state + to a screen reader, and side by side they read as two independent actions + rather than one either/or. A radio group says exactly one of these, is + navigable with the arrow keys, and can be labelled. */ + +.choice-list { display: flex; flex-direction: column; gap: 8px; } + +.choice { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-base); + cursor: pointer; + transition: border-color 0.12s, background 0.12s; +} +.choice:hover { border-color: var(--accent); } +.choice.selected { border-color: var(--accent); background: var(--bg-raised); } +.choice:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } + +.choice input[type="radio"] { + margin: 3px 0 0; + accent-color: var(--accent); + flex-shrink: 0; +} + +.choice-icon { + width: 18px; + height: 18px; + margin-top: 1px; + flex-shrink: 0; + color: var(--text-secondary); +} +.choice.selected .choice-icon { color: var(--accent); } + +.choice-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.choice-title { font-size: 0.9em; font-weight: 600; color: var(--text); } +.choice-desc { font-size: 0.82em; color: var(--text-secondary); line-height: 1.4; } + +.btn-primary { + padding: 10px 22px; + border: none; + border-radius: 8px; + background: var(--accent); + color: var(--accent-text); + font-size: 0.95em; + font-weight: 600; + cursor: pointer; +} +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } |