atagen

malice

public
Star0
Watch0

Commit e15025643a20

tarball

atagen <boss@atagen.co>2026-10-24 06:51 UTC

3 files changed+168-51
web: move room actions into a menu and animate the side panes

Security, members and leave buttons crowded the header, so members is now an icon and the rest sit behind a ⋯ menu that has room for more actions. Panes now resize via grid-template-columns transitions, which interpolate because the track count stays fixed at four. The spaces rail and channels pane fold independently.
Modified file: web/src/App.svelte+92 additions−41 deletions
@@ -59,7 +59,7 @@
5959 } from './lib/link-previews';
6060 import { notifyTyping, stopTyping } from './lib/typing';
6161 import { formatDay } from './lib/format';
62- import { lastSpaceRooms, lineHeight, messageSpacing, navFolded } from './lib/appearance';
62+ import { lastSpaceRooms, lineHeight, messageSpacing, navFolded, railFolded } from './lib/appearance';
6363 import SettingsPanel from './lib/SettingsPanel.svelte';
6464 import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
6565 import type { MemberSummary, OwnProfile, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
@@ -970,16 +970,33 @@
970970 return 'dot-off';
971971 }
972972
973+ let roomMenuOpen = false;
974+ let roomMenuEl: HTMLDivElement | null = null;
975+
976+ function roomMenuAction(action: () => void): void {
977+ roomMenuOpen = false;
978+ action();
979+ }
980+
981+ function closeRoomMenuOutside(ev: PointerEvent): void {
982+ if (roomMenuOpen && !roomMenuEl?.contains(ev.target as Node)) roomMenuOpen = false;
983+ }
984+
973985 function onKeydown(ev: KeyboardEvent): void {
974986 if (ev.key === 'Escape') {
975- if (dialog) dialog = null;
987+ if (roomMenuOpen) roomMenuOpen = false;
988+ else if (dialog) dialog = null;
976989 else if (membersOpen && window.innerWidth < 1100) membersOpen = false;
977990 else if (sidebarOpen) sidebarOpen = false;
978991 }
979992 }
980993 </script>
981994
982-<svelte:window on:keydown={onKeydown} on:pointerup={() => (draggingScrollbar = false)} />
995+<svelte:window
996+ on:keydown={onKeydown}
997+ on:pointerdown={closeRoomMenuOutside}
998+ on:pointerup={() => (draggingScrollbar = false)}
999+/>
9831000
9841001 {#if !booted}
9851002 <div class="splash" role="status" aria-label="Loading Malice">
@@ -994,7 +1011,14 @@
9941011 {:else if $sessionUser == null}
9951012 <LoginPanel softLogout={$softLogout} on:done={(ev) => afterLogin(ev.detail)} />
9961013 {:else}
997- <div class="app" class:nav-folded={$navFolded && selectedRoom} style:--msg-gap={`${$messageSpacing}px`} style:--msg-line-height={$lineHeight}>
1014+ <div
1015+ class="app"
1016+ class:nav-folded={$navFolded && selectedRoom}
1017+ class:rail-folded={$railFolded && selectedRoom}
1018+ class:members-open={selectedRoom && membersOpen}
1019+ style:--msg-gap={`${$messageSpacing}px`}
1020+ style:--msg-line-height={$lineHeight}
1021+ >
9981022 <nav class="rail" aria-label="Servers and spaces">
9991023 <button
10001024 type="button"
@@ -1307,13 +1331,31 @@
13071331 </button>
13081332 <button
13091333 type="button"
1310- class="icon-btn only-desktop"
1334+ class="icon-btn only-desktop pane-toggle"
1335+ aria-pressed={!$railFolded}
1336+ aria-label={$railFolded ? 'Show spaces' : 'Hide spaces'}
1337+ title={$railFolded ? 'Show spaces' : 'Hide spaces'}
1338+ on:click={() => railFolded.update((v) => !v)}
1339+ >
1340+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
1341+ <rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
1342+ <circle cx="7" cy="9" r="1.4" fill="currentColor" />
1343+ <circle cx="7" cy="13" r="1.4" fill="currentColor" />
1344+ <circle cx="7" cy="17" r="1.4" fill="currentColor" />
1345+ </svg>
1346+ </button>
1347+ <button
1348+ type="button"
1349+ class="icon-btn only-desktop pane-toggle"
13111350 aria-pressed={!$navFolded}
1312- aria-label={$navFolded ? 'Show spaces and rooms' : 'Hide spaces and rooms'}
1313- title={$navFolded ? 'Show spaces and rooms' : 'Hide spaces and rooms'}
1351+ aria-label={$navFolded ? 'Show channels' : 'Hide channels'}
1352+ title={$navFolded ? 'Show channels' : 'Hide channels'}
13141353 on:click={() => navFolded.update((v) => !v)}
13151354 >
1316- <span aria-hidden="true">{$navFolded ? '»' : '«'}</span>
1355+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
1356+ <rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
1357+ <path d="M10 4v16" stroke="currentColor" stroke-width="2" />
1358+ </svg>
13171359 </button>
13181360 <div class="head-title">
13191361 <div class="head-room">
@@ -1339,34 +1381,7 @@
13391381 </div>
13401382 </div>
13411383 <div class="head-actions">
1342- <button
1343- type="button"
1344- class="ghost-btn"
1345- aria-label="Encryption and verification"
1346- title="Encryption and verification"
1347- on:click={() => (dialog = 'security')}
1348- >
1349- 🛡 Security
1350- </button>
1351- <button
1352- type="button"
1353- class="ghost-btn"
1354- aria-pressed={membersOpen}
1355- aria-label={membersOpen ? 'Hide members' : 'Show members'}
1356- on:click={toggleMembers}
1357- >
1358- Members
1359- </button>
1360- {#if selectedRoom.membership === 'Joined'}
1361- <button
1362- type="button"
1363- class="ghost-btn danger"
1364- disabled={leaveBusy}
1365- on:click={() => void leave()}
1366- >
1367- {leaveBusy ? 'Leaving…' : 'Leave'}
1368- </button>
1369- {:else}
1384+ {#if selectedRoom.membership !== 'Joined'}
13701385 <button
13711386 type="button"
13721387 class="primary-btn"
@@ -1378,12 +1393,48 @@
13781393 {/if}
13791394 <button
13801395 type="button"
1381- class="icon-btn only-mobile"
1382- aria-label="Toggle members"
1396+ class="icon-btn"
1397+ aria-pressed={membersOpen}
1398+ aria-label={membersOpen ? 'Hide members' : 'Show members'}
1399+ title="Members"
13831400 on:click={toggleMembers}
13841401 >
1385- <span aria-hidden="true">ⓘ</span>
1402+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
1403+ <circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2" />
1404+ <path d="M4 20c0-4 3.6-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
1405+ </svg>
13861406 </button>
1407+ <div class="room-menu-wrap" bind:this={roomMenuEl}>
1408+ <button
1409+ type="button"
1410+ class="icon-btn"
1411+ aria-haspopup="menu"
1412+ aria-expanded={roomMenuOpen}
1413+ aria-label="Room options"
1414+ title="Room options"
1415+ on:click={() => (roomMenuOpen = !roomMenuOpen)}
1416+ >
1417+ <span aria-hidden="true">⋯</span>
1418+ </button>
1419+ {#if roomMenuOpen}
1420+ <div class="room-menu" role="menu">
1421+ <button type="button" role="menuitem" on:click={() => roomMenuAction(() => (dialog = 'security'))}>
1422+ Security
1423+ </button>
1424+ {#if selectedRoom.membership === 'Joined'}
1425+ <button
1426+ type="button"
1427+ role="menuitem"
1428+ class="danger"
1429+ disabled={leaveBusy}
1430+ on:click={() => roomMenuAction(() => void leave())}
1431+ >
1432+ {leaveBusy ? 'Leaving…' : 'Leave room'}
1433+ </button>
1434+ {/if}
1435+ </div>
1436+ {/if}
1437+ </div>
13871438 </div>
13881439 </header>
13891440
@@ -1623,8 +1674,8 @@
16231674 {/if}
16241675 </main>
16251676
1626- {#if selectedRoom && membersOpen}
1627- <aside class="members" aria-label={`Members of ${selectedRoom.name}`}>
1677+ {#if selectedRoom}
1678+ <aside class="members" aria-hidden={!membersOpen} aria-label={`Members of ${selectedRoom.name}`}>
16281679 <div class="members-head">
16291680 <h2>Members</h2>
16301681 <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
Modified file: web/src/app.css+75 additions−10 deletions
@@ -133,15 +133,38 @@ input, textarea { font: inherit; }
133133 }
134134
135135 .app {
136+ --rail-w: 68px;
137+ --side-w: 300px;
138+ --members-w: 0px;
139+ --pane-ease: 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
136140 display: grid;
137- grid-template-columns: 68px 300px 1fr;
141+ grid-template-columns: var(--rail-w) var(--side-w) minmax(0, 1fr) var(--members-w);
138142 height: 100dvh;
139143 overflow: hidden;
144+ transition: grid-template-columns var(--pane-ease);
145+}
146+.app.rail-folded { --rail-w: 0px; }
147+.app.nav-folded { --side-w: 0px; }
148+.app.members-open { --members-w: 260px; }
149+.app > .rail { grid-column: 1; }
150+.app > .sidebar { grid-column: 2; }
151+.app > .chat { grid-column: 3; }
152+.app > .members { grid-column: 4; }
153+.app > .rail, .app > .sidebar, .app > .members {
154+ min-width: 0;
155+ transition: visibility 0s linear 0s;
156+}
157+.app > .rail { overflow-x: hidden; }
158+.app > .sidebar, .app > .members { overflow: hidden; }
159+.app > .sidebar > * { min-width: 300px; }
160+.app > .members > * { min-width: 260px; }
161+.app.rail-folded > .rail, .app.nav-folded > .sidebar, .app:not(.members-open) > .members {
162+ visibility: hidden;
163+ transition: visibility 0s linear 0.22s;
164+}
165+@media (prefers-reduced-motion: reduce) {
166+ .app { --pane-ease: 0s; }
140167 }
141-.app:has(.members) { grid-template-columns: 68px 300px 1fr 260px; }
142-.app.nav-folded { grid-template-columns: 1fr; }
143-.app.nav-folded:has(.members) { grid-template-columns: 1fr 260px; }
144-.app.nav-folded > .rail, .app.nav-folded > .sidebar { display: none; }
145168
146169 /* ——— Rail ——— */
147170 .rail {
@@ -397,7 +420,38 @@ input, textarea { font: inherit; }
397420 .dot-online { background: var(--ok); }
398421 .dot-busy { background: var(--warn); }
399422 .dot-off { background: var(--danger); }
400-.head-actions { display: flex; align-items: center; gap: 8px; }
423+.head-actions { display: flex; align-items: center; gap: 4px; }
424+.head-actions .icon-btn { display: grid; place-items: center; width: 34px; height: 34px; padding: 0; }
425+.head-actions .icon-btn[aria-pressed='true'], .head-actions .icon-btn[aria-expanded='true'] { background: var(--panel-2); color: var(--text); }
426+.room-menu-wrap { position: relative; }
427+.room-menu {
428+ position: absolute;
429+ top: calc(100% + 6px);
430+ right: 0;
431+ z-index: 40;
432+ min-width: 180px;
433+ display: flex;
434+ flex-direction: column;
435+ padding: 4px;
436+ background: var(--panel-2);
437+ border: 1px solid var(--border);
438+ border-radius: 8px;
439+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
440+}
441+.room-menu button {
442+ background: transparent;
443+ border: none;
444+ border-radius: 6px;
445+ color: var(--text);
446+ font: inherit;
447+ font-size: 13.5px;
448+ text-align: left;
449+ padding: 7px 10px;
450+ cursor: pointer;
451+}
452+.room-menu button:hover:not(:disabled) { background: var(--hover); }
453+.room-menu button:disabled { opacity: 0.5; cursor: default; }
454+.room-menu button.danger { color: var(--danger); }
401455
402456 .primary-btn {
403457 background: var(--accent);
@@ -675,15 +729,26 @@ input, textarea { font: inherit; }
675729 .only-mobile { display: none; }
676730
677731 @media (max-width: 1100px) {
678- .app, .app:has(.members), .app.nav-folded, .app.nav-folded:has(.members) { grid-template-columns: 60px 1fr; }
679- .app.nav-folded > .rail, .app.nav-folded > .sidebar { display: flex; }
732+ .app {
733+ --rail-w: 60px !important;
734+ --side-w: 0px !important;
735+ --members-w: 0px !important;
736+ transition: none;
737+ }
738+ .app > .rail, .app > .sidebar { visibility: visible !important; }
739+ .app > .sidebar { transition: transform var(--pane-ease); }
740+ .app > .sidebar > *, .app > .members > * { min-width: 0; }
741+ .app > .members { transition: transform var(--pane-ease), visibility 0s linear 0s; }
742+ .app:not(.members-open) > .members {
743+ transform: translateX(100%);
744+ transition: transform var(--pane-ease), visibility 0s linear 0.22s;
745+ }
680746 .sidebar {
681747 position: fixed;
682748 top: 0; bottom: 0; left: 60px;
683749 width: min(320px, 85vw);
684750 z-index: 40;
685751 transform: translateX(calc(-100% - 60px));
686- transition: transform 0.18s ease;
687752 box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5);
688753 }
689754 .sidebar.open { transform: none; }
@@ -713,7 +778,7 @@ input, textarea { font: inherit; }
713778 }
714779
715780 @media (max-width: 640px) {
716- .app, .app:has(.members), .app.nav-folded, .app.nav-folded:has(.members) { grid-template-columns: 52px 1fr; }
781+ .app { --rail-w: 52px !important; }
717782 .sidebar { left: 52px; transform: translateX(calc(-100% - 52px)); }
718783 .scrim { left: 52px; }
719784 .rail-btn { width: 40px; height: 40px; font-size: 16px; }
Modified file: web/src/lib/appearance.ts+1 additions
@@ -27,6 +27,7 @@ const isSpacing = (v: unknown): v is number =>
2727 typeof v === 'number' && Number.isFinite(v) && v >= MESSAGE_SPACING_MIN && v <= MESSAGE_SPACING_MAX;
2828
2929 export const navFolded = persisted('malice.navFolded', false, isBool);
30+export const railFolded = persisted('malice.railFolded', false, isBool);
3031 export const messageSpacing = persisted('malice.messageSpacing', MESSAGE_SPACING_DEFAULT, isSpacing);
3132
3233 export const LINE_HEIGHT_MIN = 1.1;