Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/shell-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ semantic tokens, UI authoring rules and the local component reference.
Channels is presented as Messages. Legacy tone props are retained for
compatibility; all pages share the supplied gradient and repeating CSS dots.
Add recognized page presentation here without changing plugin contracts.
- `AppShell.tsx` owns the 56px header, vertical page navigation, contributed panel
- `AppShell.tsx` owns the 48px header, vertical page navigation, contributed panel
launchers, Settings access, community rail, and page frames. Page navigation sits
above the channel list outside Settings, using its saved sidebar width
and resize behavior. Settings replaces that card with `SettingsSidebar.tsx`,
Expand All @@ -45,7 +45,9 @@ semantic tokens, UI authoring rules and the local component reference.
220px disclosure overlays content, supports Escape, and keeps sidebar state
mounted. A navigation selection closes the phone drawer and hands focus to the
main content; this includes conversation and Settings-section selections.
Desktop layouts retain the visible sidebar and saved width.
Messages, Agents, and desktop Settings share an animated header toggle; hiding
the sidebar preserves its mounted state and saved width. Reduced motion disables
the transition. Other desktop pages retain the visible sidebar.
The header keeps history and account/search actions, with no second navigation row.
Full-height pages get a 16px outer gutter (8px on narrow screens) and own their
card surfaces. The shell adds no white backing behind them. Document pages
Expand Down
11 changes: 10 additions & 1 deletion src/app/entity-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { finalizeEvent, getPublicKey } from "nostr-tools";
import { App } from "./App";
import { createServices, type AppServices } from "./services";
Expand Down Expand Up @@ -55,6 +55,15 @@ const project = finalizeEvent(
},
key,
);
// jsdom has no media queries; responsive geometry is covered in browser tests.
beforeEach(() => {
vi.stubGlobal("matchMedia", (media: string) => ({
media,
matches: false,
addEventListener() {},
removeEventListener() {},
}));
});
let services: AppServices | undefined;
let stop = () => {};
afterEach(async () => {
Expand Down
11 changes: 10 additions & 1 deletion src/app/navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { keypair, signed } from "../features/relay/testing";
import { stubAvatarBrowserApis } from "../features/agents/avatar-testing";
import type { RelayEvent } from "../features/relay/events";
Expand Down Expand Up @@ -46,6 +46,15 @@ vi.mock("../bundled", async () => ({
},
],
}));
// jsdom has no media queries; responsive geometry is covered in browser tests.
beforeEach(() => {
vi.stubGlobal("matchMedia", (media: string) => ({
media,
matches: false,
addEventListener() {},
removeEventListener() {},
}));
});
let services: AppServices | undefined;
afterEach(async () => {
activation.release();
Expand Down
55 changes: 44 additions & 11 deletions src/app/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,31 @@ export function AppShell({
children: ReactNode;
}) {
const fillsWorkspace = workspace || selected === "settings";
const channelsNavigation =
selected === "buzz.channels/channels" || selected === "buzz.agents/agents";
const [narrow, setNarrow] = useState(
() => window.matchMedia("(max-width: 650px)").matches,
);
useEffect(() => {
const query = window.matchMedia("(max-width: 650px)");
const update = () => setNarrow(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
const collapsibleSidebar = channelsNavigation || selected === "settings";
const [sidebarOpen, setSidebarOpen] = useState(true);
const [navigationOpen, setNavigationOpen] = useState(false);
const navigationToggle = useRef<HTMLButtonElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: Only a new navigation attempt closes the drawer; opening the disclosure must not retrigger this effect.
const visibleSidebar = narrow ? navigationOpen : sidebarOpen;
const toggleLabel = narrow
? navigationOpen
? "Hide navigation"
: "Show navigation"
: sidebarOpen
? "Hide Channel sidebar"
: "Show Channel sidebar";
// biome-ignore lint/correctness/useExhaustiveDependencies: Only a navigation attempt closes the drawer.
useEffect(() => {
if (navigationOpen && navigationToggle.current?.getClientRects().length) {
document.getElementById("main-content")?.focus({ preventScroll: true });
Expand Down Expand Up @@ -109,19 +131,25 @@ export function AppShell({
data-tauri-drag-region={macDesktop ? undefined : true}
{...titleBarDragProps}
>
{navigationControls}
<span className="shell-navigation-toggle">
{(collapsibleSidebar || narrow) && (
<IconButton
ref={navigationToggle}
aria-label={
navigationOpen ? "Hide navigation" : "Show navigation"
}
aria-expanded={navigationOpen}
data-shell-sidebar-toggle=""
type="button"
variant="chrome"
shape="round"
aria-label={toggleLabel}
aria-expanded={visibleSidebar}
aria-controls="shell-navigation"
onClick={() => setNavigationOpen((open) => !open)}
icon={<SidebarIcon aria-hidden="true" size={20} />}
title={toggleLabel}
onClick={() => {
if (narrow) setNavigationOpen((open) => !open);
else setSidebarOpen((open) => !open);
}}
icon={<SidebarIcon aria-hidden="true" size={16} />}
/>
</span>
)}
{navigationControls}
</div>
<div
className="shell-actions"
Expand All @@ -148,10 +176,15 @@ export function AppShell({
<div
className={`shell-body ${selected === "settings" ? "shell-body-settings" : ""}`}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: Delegated Escape from descendant controls closes the disclosure; the layout wrapper is not itself interactive. */}
<div
id="shell-navigation"
className="shell-navigation"
data-sidebar-collapsible={
(collapsibleSidebar && !narrow) || undefined
}
data-sidebar-open={visibleSidebar || undefined}
aria-hidden={(collapsibleSidebar || narrow) && !visibleSidebar}
inert={(collapsibleSidebar || narrow) && !visibleSidebar}
data-expanded={navigationOpen}
onKeyDown={(event) => {
if (
Expand Down
18 changes: 3 additions & 15 deletions src/app/shell/NavigationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function NavigationControls({ navigation }: { navigation: Navigation }) {
const state = useSyncExternalStore(navigation.subscribe, navigation.snapshot);
return (
<nav
className="mr-2 flex shrink-0 items-center gap-0.5"
className="flex shrink-0 items-center gap-1"
aria-label="Navigation history"
>
<IconButton
Expand All @@ -20,13 +20,7 @@ export function NavigationControls({ navigation }: { navigation: Navigation }) {
title="Go back"
disabled={!state.canGoBack}
onClick={navigation.back}
icon={
<ArrowLeftIcon
size={20}
style={{ width: "var(--space-5)", height: "var(--space-5)" }}
aria-hidden="true"
/>
}
icon={<ArrowLeftIcon size={16} aria-hidden="true" />}
/>
<IconButton
type="button"
Expand All @@ -36,13 +30,7 @@ export function NavigationControls({ navigation }: { navigation: Navigation }) {
title="Go forward"
disabled={!state.canGoForward}
onClick={navigation.forward}
icon={
<ArrowRightIcon
size={20}
style={{ width: "var(--space-5)", height: "var(--space-5)" }}
aria-hidden="true"
/>
}
icon={<ArrowRightIcon size={16} aria-hidden="true" />}
/>
</nav>
);
Expand Down
8 changes: 1 addition & 7 deletions src/app/shell/PageSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,7 @@ export function PageSearch({
trigger.current?.focus();
begin();
}}
icon={
<MagnifyingGlassIcon
size={20}
style={{ width: "var(--space-5)", height: "var(--space-5)" }}
aria-hidden="true"
/>
}
icon={<MagnifyingGlassIcon size={16} aria-hidden="true" />}
/>
<Dialog
open={open}
Expand Down
4 changes: 2 additions & 2 deletions src/app/shell/PanelLaunchers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ export function PanelLaunchers({
function LauncherIcon({ src }: { src: string }) {
const [failed, setFailed] = useState(false);
return failed ? (
<SidebarIcon size={18} aria-hidden="true" />
<SidebarIcon size={16} aria-hidden="true" />
) : (
<img
src={src}
alt=""
className="size-4 object-contain"
className="object-contain"
onError={() => setFailed(true)}
/>
);
Expand Down
16 changes: 16 additions & 0 deletions src/app/shell/ProfileButton.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
.triggerAvatar {
pointer-events: none;
position: relative;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
border-radius: 50%;
}

/* biome-ignore lint/correctness/noUnknownPseudoClass: CSS Modules global selector targets the shared Avatar element. */
.triggerAvatar :global(.buzz-avatar) {
background: transparent;
}

.profileHeader {
display: flex;
align-items: center;
Expand Down
2 changes: 1 addition & 1 deletion src/app/shell/ProfileButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export function ProfileButton({
shape="round"
icon={
<span
className="pointer-events-none relative flex size-full items-center justify-center rounded-full"
className={styles.triggerAvatar}
role="img"
aria-label={
viewer
Expand Down
80 changes: 71 additions & 9 deletions src/shared/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -185,16 +185,28 @@
}

.shell-header {
@apply flex h-14 shrink-0 items-center justify-between gap-3 pl-4 pr-2 sm:pr-4;
@apply flex shrink-0 items-center justify-between;
height: 48px;
padding-left: var(--space-4);
padding-right: var(--space-2);
gap: var(--space-2);
background: color-mix(in srgb, var(--bg-app) 72%, var(--surface-panel));
}
@media (min-width: 640px) {
.shell-header {
padding-right: var(--space-4);
}
}
.shell-communities {
@apply flex min-w-0 items-center justify-start;
gap: var(--space-1);
padding-left: var(--space-4);
}
.shell-communities > .navigation-item {
flex-shrink: 1;
}
.shell-header-mac .shell-communities {
padding-left: var(--space-16);
padding-left: calc(var(--space-16) + var(--space-4));
}
.shell-pages {
@apply flex min-h-0 min-w-0 flex-col gap-1 overflow-y-auto;
Expand All @@ -207,8 +219,28 @@
.shell-navigation {
display: contents;
}
.shell-navigation-toggle {
display: none;
.shell-navigation[data-sidebar-collapsible] {
display: flex;
min-width: 0;
flex: none;
width: max-content;
max-width: max(calc(220px + var(--space-panel-gap)), calc(100% - 336px));
overflow: visible;
clip-path: none;
/* Release resize help only after expansion; contain content while moving. */
transition:
max-width var(--duration-settle) ease-in-out,
clip-path var(--duration-settle) step-end allow-discrete;
}
.shell-navigation[data-sidebar-collapsible][aria-hidden="true"] {
max-width: 0;
clip-path: inset(0);
transition-timing-function: ease-in-out, step-start;
}
@media (prefers-reduced-motion: reduce) {
.shell-navigation[data-sidebar-collapsible] {
transition: none;
}
}
.shell-body {
@apply relative flex min-h-0 min-w-0 flex-1 pl-0 pr-2 pb-2 sm:pl-0 sm:pr-4 sm:pb-4;
Expand All @@ -227,12 +259,45 @@
.shell-sidebar-default > * {
@apply min-w-0 flex-1;
}
.shell-navigation[data-sidebar-collapsible] > .shell-sidebar,
.shell-navigation[data-sidebar-collapsible] > .shell-sidebar-default {
flex: none;
max-width: calc(100% - var(--space-panel-gap));
}
.shell-actions {
@apply flex items-center justify-end gap-2;
@apply flex items-center justify-end;
gap: var(--space-2);
}
.shell-header .buzz-button[data-icon-variant] {
width: 28px;
height: 28px;
}
.shell-header .buzz-button[data-shell-sidebar-toggle] {
background: var(--surface-panel);
box-shadow: none;
}
.shell-header .buzz-button[data-icon-variant] svg,
.shell-header .buzz-button[data-icon-variant="chrome"] img {
width: 16px;
height: 16px;
}
.shell-header
.buzz-button[data-icon-variant="chrome"]
img[src="/bestie.png"] {
width: 18px;
height: 18px;
}
.shell-header
.buzz-button[data-shell-sidebar-toggle]:hover:not([data-disabled]) {
background: var(--affordance-subtle-hover);
}
@media (max-width: 700px) {
.shell-header {
@apply gap-2 pl-2;
padding-left: var(--space-2);
gap: var(--space-2);
}
.shell-communities {
padding-left: var(--space-4);
}
}
@media (max-width: 650px) {
Expand All @@ -244,9 +309,6 @@
.shell-sidebar-default {
margin-right: var(--space-1);
}
.shell-navigation-toggle {
display: inline-flex;
}
.shell-navigation {
display: none;
}
Expand Down
Loading
Loading