aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/style.css
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
commit84e778d5cdd1bb5cded8a7c0238797c17d48666c (patch)
treed26f1d20735ba2db81da812371c92d257179882c /packages/meshbay-hub/src/meshbay_hub/static/style.css
parent188a76f52d2f30609147b1beee7754f2cbd1e778 (diff)
downloadmeshbay-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.css306
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; }