Add Stationery and Stationery Dark themes with notebook chrome.

Manila folders, ruled chat, post-it nav, and folder tabs; dark variant uses Catppuccin Mocha colors with muted stickies and soft glows.
This commit is contained in:
2026-07-23 00:24:10 +10:00
parent e7777f42d8
commit 61d41900cc
43 changed files with 2765 additions and 421 deletions

View File

@@ -29,7 +29,20 @@ export function SidebarNav() {
<Sidebar>
<SidebarContent
scrollable={
<Scroll ref={scrollRef} variant="Background" size="0">
<Scroll
ref={scrollRef}
variant="Background"
size="0"
data-sidebar-scroll=""
style={{
marginRight: -25,
paddingRight: 25,
width: 'calc(100% + 25px)',
boxSizing: 'border-box',
overflowX: 'hidden',
overflowY: 'auto',
}}
>
<SidebarStack>
{!homeHidden && <HomeTab />}
<DirectTab />

View File

@@ -249,7 +249,7 @@ export function Direct() {
const virtualizer = useVirtualizer({
count: sortedDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
estimateSize: () => 36,
overscan: 10,
});
@@ -288,7 +288,7 @@ export function Direct() {
<NotificationsNavItem />
<InvitesNavItem />
</NavCategory>
<NavCategory>
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
@@ -298,13 +298,16 @@ export function Direct() {
Chats
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
ref={animationRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{sortedDirects.length > 0 && (
<div data-nav-rooms="">
<div
ref={animationRef}
data-nav-room-list=""
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const room = mx.getRoom(roomId);
@@ -332,7 +335,9 @@ export function Direct() {
</VirtualTile>
);
})}
</div>
</div>
)}
</NavCategory>
</Box>
</PageNavContent>

View File

@@ -309,7 +309,7 @@ export function Home() {
const virtualizer = useVirtualizer({
count: listItems.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
estimateSize: () => 36,
overscan: 10,
});
@@ -399,7 +399,7 @@ export function Home() {
</NavItem>
<PluginNavSlot location="channel-list" />
</NavCategory>
<NavCategory>
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
@@ -410,12 +410,15 @@ export function Home() {
</RoomNavCategoryButton>
</NavCategoryHeader>
<PluginNavSlot location="home-section" />
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{listItems.length > 0 && (
<div data-nav-rooms="">
<div
data-nav-room-list=""
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const item = listItems[vItem.index];
if (!item) return null;
@@ -446,7 +449,16 @@ export function Home() {
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: item.depth > 0 ? '1.5rem' : undefined }}>
<div
data-nav-depth={item.depth}
style={{
paddingLeft,
display: 'flex',
alignItems: 'center',
minHeight: item.depth > 0 ? '1.5rem' : undefined,
position: 'relative',
}}
>
{treeIcon && (
<span style={{
paddingRight: '2px',
@@ -475,7 +487,9 @@ export function Home() {
</VirtualTile>
);
})}
</div>
</div>
)}
</NavCategory>
</Box>
</PageNavContent>

View File

@@ -154,7 +154,7 @@ export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
if (threads.length === 0) return null;
return (
<NavCategory>
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(THREADS_CATEGORY_ID)}
@@ -164,15 +164,18 @@ export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
My Threads
</RoomNavCategoryButton>
</NavCategoryHeader>
{!closedCategories.has(THREADS_CATEGORY_ID) &&
threads.map(({ room, thread }) => (
<ThreadNavItem
key={`${room.roomId}:${thread.id}`}
room={room}
thread={thread}
selected={false}
/>
))}
{!closedCategories.has(THREADS_CATEGORY_ID) && (
<div data-nav-rooms="">
{threads.map(({ room, thread }) => (
<ThreadNavItem
key={`${room.roomId}:${thread.id}`}
room={room}
thread={thread}
selected={false}
/>
))}
</div>
)}
</NavCategory>
);
}

View File

@@ -9,7 +9,6 @@ import React, {
import { useAtom, useAtomValue } from 'jotai';
import { Avatar, Box, Button, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Spinner, Text, color, config, toRem } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { useVirtualizer } from '@tanstack/react-virtual';
import { JoinRule, Room, Thread, ThreadEvent } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
@@ -30,7 +29,6 @@ import {
useSpaceSearchSelected,
} from '../../../hooks/router/useSelectedSpace';
import { useSpace } from '../../../hooks/useSpace';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem, UnjoinedSubRoomItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
@@ -74,6 +72,119 @@ import { SpaceOptionsMenu } from '../../../features/space/SpaceOptionsMenu';
import { ForumFeedSidebar } from '../../../features/forum/ForumFeedSidebar';
import * as forumBoardCss from '../../../features/forum/ForumBoardView.css';
type SpaceHierarchyItem =
| { type: 'hierarchy'; roomId: string }
| { type: 'subroom'; roomId: string; room: Room; depth: number; isLast?: boolean }
| { type: 'unjoined-subroom'; roomId: string; depth: number; isLast?: boolean };
type SpaceNavSectionData = {
spaceRoomId: string;
categoryId: string;
label: string;
items: SpaceHierarchyItem[];
};
type SpaceNavSectionProps = {
section: SpaceNavSectionData;
closed: boolean;
onCategoryClick: MouseEventHandler<HTMLButtonElement>;
selectedRoomId?: string;
mDirects: Set<string>;
notificationPreferences: ReturnType<typeof useRoomsNotificationPreferencesContext>;
getToLink: (roomId: string) => string;
};
function SpaceNavSection({
section,
closed,
onCategoryClick,
selectedRoomId,
mDirects,
notificationPreferences,
getToLink,
}: SpaceNavSectionProps) {
const mx = useMatrixClient();
return (
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={section.categoryId}
onClick={onCategoryClick}
closed={closed}
>
{section.label}
</RoomNavCategoryButton>
</NavCategoryHeader>
{section.items.length > 0 && (
<div data-nav-rooms="">
<div data-nav-room-list="">
{section.items.map((item, index) => {
if (item.type === 'unjoined-subroom') {
return (
<UnjoinedSubRoomItem
key={`${item.roomId}:${index}`}
roomId={item.roomId}
depth={item.depth}
isLast={item.isLast}
/>
);
}
if (item.type === 'subroom') {
return (
<div
key={`${item.roomId}:${index}`}
data-nav-depth={item.depth}
style={{
display: 'flex',
alignItems: 'center',
position: 'relative',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<RoomNavItem
room={item.room}
selected={selectedRoomId === item.roomId}
hideIcon={item.depth > 0}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(
notificationPreferences,
item.roomId
)}
/>
</div>
</div>
);
}
const room = mx.getRoom(item.roomId);
if (!room || isSpace(room)) return null;
return (
<RoomNavItem
key={`${item.roomId}:${index}`}
room={room}
selected={selectedRoomId === item.roomId}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
);
})}
</div>
</div>
)}
</NavCategory>
);
}
function SpaceHeader() {
const space = useSpace();
const spaceName = useRoomName(space);
@@ -272,61 +383,74 @@ export function Space() {
return hierarchy.filter((entry) => !globalSubRoomIds.has(entry.roomId));
}, [hierarchy, globalSubRoomIds]);
// Flatten hierarchy to include sub-rooms
type HierarchyItem =
| { type: 'hierarchy'; roomId: string }
| { type: 'subroom'; roomId: string; room: Room; depth: number; isLast?: boolean }
| { type: 'unjoined-subroom'; roomId: string; depth: number; isLast?: boolean };
const flattenedHierarchy = useMemo(() => {
const items: HierarchyItem[] = [];
// One paper dropdown per space: space header + its rooms/sub-rooms
const navSections = useMemo(() => {
const sections: SpaceNavSectionData[] = [];
let current: SpaceNavSectionData | null = null;
const processedSubRooms = new Set<string>();
const addSubRooms = (parentRoomId: string, baseDepth: number) => {
const addSubRooms = (parentRoomId: string, baseDepth: number, into: SpaceHierarchyItem[]) => {
const room = mx.getRoom(parentRoomId);
if (!room || isSpace(room)) return;
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
// Filter to only joined sub-rooms that haven't been processed
const joinedSubRooms = subRooms.filter(subRoomId =>
!processedSubRooms.has(subRoomId) && mx.getRoom(subRoomId)
const joinedSubRooms = subRooms.filter(
(subRoomId) => !processedSubRooms.has(subRoomId) && mx.getRoom(subRoomId)
);
joinedSubRooms.forEach((subRoomId, index) => {
processedSubRooms.add(subRoomId);
const isLast = index === joinedSubRooms.length - 1;
const subRoom = mx.getRoom(subRoomId);
if (subRoom) {
// Joined sub-room
items.push({ type: 'subroom', roomId: subRoomId, room: subRoom, depth: baseDepth + 1, isLast });
// Recursively add sub-rooms of sub-rooms
addSubRooms(subRoomId, baseDepth + 1);
}
if (!subRoom) return;
into.push({
type: 'subroom',
roomId: subRoomId,
room: subRoom,
depth: baseDepth + 1,
isLast,
});
addSubRooms(subRoomId, baseDepth + 1, into);
});
};
const startSection = (spaceRoomId: string, label: string) => {
current = {
spaceRoomId,
categoryId: makeNavCategoryId(space.roomId, spaceRoomId),
label,
items: [],
};
sections.push(current);
return current;
};
filteredHierarchy.forEach((entry) => {
items.push({ type: 'hierarchy', roomId: entry.roomId });
// Add sub-rooms after each non-space room
const isSpaceEntry = 'space' in entry && entry.space === true;
const room = mx.getRoom(entry.roomId);
if (isSpaceEntry || (room && isSpace(room))) {
startSection(
entry.roomId,
entry.roomId === space.roomId ? 'Rooms' : room?.name ?? entry.roomId
);
return;
}
if (!current) {
startSection(space.roomId, 'Rooms');
}
current!.items.push({ type: 'hierarchy', roomId: entry.roomId });
if (room && !isSpace(room)) {
addSubRooms(entry.roomId, 0);
addSubRooms(entry.roomId, 0, current!.items);
}
});
return items;
}, [mx, filteredHierarchy]);
const virtualizer = useVirtualizer({
count: flattenedHierarchy.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
overscan: 10,
});
return sections;
}, [mx, filteredHierarchy, space.roomId]);
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
@@ -410,110 +534,20 @@ export function Space() {
</NavItem>
</>
)}
<PluginNavSlot location="channel-list" />
</NavCategory>
<NavCategory
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const item = flattenedHierarchy[vItem.index];
if (!item) return null;
// Unjoined sub-room item
if (item.type === 'unjoined-subroom') {
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} isLast={item.isLast} />
</VirtualTile>
);
}
// Sub-room item (nested under parent)
if (item.type === 'subroom') {
const paddingLeft = '30px';
let treeIcon = '';
if (item.depth > 0) {
treeIcon = item.isLast ? '╰' : '├';
}
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: '1.5rem' }}>
{treeIcon && (
<span style={{
paddingRight: '2px',
opacity: 0.5,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: '1',
userSelect: 'none'
}}>
{treeIcon}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<RoomNavItem
room={item.room}
selected={selectedRoomId === item.roomId}
hideIcon={item.depth > 0}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)}
/>
</div>
</div>
</VirtualTile>
);
}
// Hierarchy item (room or space)
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
if (isSpace(room)) {
const categoryId = makeNavCategoryId(space.roomId, roomId);
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={categoryId}
onClick={handleCategoryClick}
closed={closedCategories.has(categoryId)}
>
{roomId === space.roomId ? 'Rooms' : room?.name}
</RoomNavCategoryButton>
</NavCategoryHeader>
</div>
</VirtualTile>
);
}
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selectedRoomId === roomId}
showAvatar={mDirects.has(roomId)}
direct={mDirects.has(roomId)}
linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
</VirtualTile>
);
})}
<PluginNavSlot location="channel-list" />
</NavCategory>
{navSections.map((section) => (
<SpaceNavSection
key={section.categoryId}
section={section}
closed={closedCategories.has(section.categoryId)}
onCategoryClick={handleCategoryClick}
selectedRoomId={selectedRoomId}
mDirects={mDirects}
notificationPreferences={notificationPreferences}
getToLink={getToLink}
/>
))}
</Box>
</PageNavContent>
</PageNav>