From 3c92696507b4b9b9bfc6a46ea7e5cdea3d7014e0 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:15:47 +0000 Subject: [PATCH 1/2] Room list: groups of unjoined project rooms wait behind one Browse entry A steward group with no joined or invited room collapses into "Browse N more rooms" per tier, closed on every screen and remembered per device; a group the user is in anything of, loose estate rooms, and joined rooms stay up front. splitGroups is the pure rule, tested. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 52 ++++++++++++++++----- src/app/features/estate/buildEstate.test.ts | 39 +++++++++++++++- src/app/features/estate/buildEstate.ts | 14 ++++++ 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 2cbbfe4a..56b554fa 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -38,7 +38,7 @@ import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategorie import { makeNavCategoryId } from '../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { EstateRoom, EstateTier } from './buildEstate'; +import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; import * as css from './EstateNavigator.css'; @@ -167,25 +167,53 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { ); } +function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) { + return ( + + {group.rooms.map((r) => ( + + ))} + + ); +} + +/** + * Groups of rooms the user has not joined, behind one quiet entry. Closed until opened, on every screen; the opening is + * remembered per device like any other section. + */ +function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) { + const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); + const key = makeNavCategoryId('estate', 'browse', tierId); + const open = toggled.has(key); + const count = groups.reduce((n, g) => n + g.rooms.length, 0); + + return ( + + + setToggled({ type: open ? 'DELETE' : 'PUT', categoryId: key })} + > + {`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`} + + + {open && groups.map((g) => )} + + ); +} + function Tier({ tier }: { tier: EstateTier }) { const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); + const { front, browse } = useMemo(() => splitGroups(tier), [tier]); return ( {tier.loose.map((r) => ( ))} - {tier.groups.map((g) => ( - - {g.rooms.map((r) => ( - - ))} - + {front.map((g) => ( + ))} + {browse.length > 0 && } ); } diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 8f898a0f..7b63eac5 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate'; +import { + buildEstate, + EstateHierarchyRoom, + EstateInput, + splitGroups, + stewardOfChild, +} from './buildEstate'; const ROOT = '!root:gamecult.org'; const PROJECTS = '!projects:gamecult.org'; @@ -324,3 +330,34 @@ describe('direct invites', () => { ]); }); }); + +describe('splitGroups', () => { + const projects = (membership: EstateInput['membership']) => + buildEstate(input({ membership })).find((t) => t.id === PROJECTS)!; + const labels = (gs: { mind: string }[]) => gs.map((g) => g.mind); + + it('puts a group nobody has joined behind Browse, and keeps loose estate rooms out of it', () => { + const tier = projects(new Map()); + const { front, browse } = splitGroups(tier); + expect(labels(front)).toEqual([]); + expect(labels(browse).sort()).toEqual(['eureka', 'void']); + expect(tier.loose.map((r) => r.name)).toEqual(['Misc']); + }); + + it('keeps a group up front once any room in it is joined, its unjoined siblings with it', () => { + const { front, browse } = splitGroups(projects(new Map([['!cultlib:gamecult.org', 'join']]))); + expect(labels(front)).toEqual(['eureka']); + expect(labels(browse)).toEqual(['void']); + }); + + it('keeps a group up front for an invite, which is something to act on', () => { + const { front } = splitGroups(projects(new Map([['!aeth:gamecult.org', 'invite']]))); + expect(labels(front)).toEqual(['void']); + }); + + it('loses no group: front and browse together are the tier groups', () => { + const tier = projects(new Map([['!cultlib:gamecult.org', 'join']])); + const { front, browse } = splitGroups(tier); + expect([...front, ...browse].sort((a, b) => a.mind.localeCompare(b.mind))).toEqual(tier.groups); + }); +}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index b9232beb..ca656211 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -71,6 +71,20 @@ export type EstateInput = { mindLabels?: ReadonlyMap; }; +/** + * A tier's project-room groups split for the navigator: a group the user is in anything of (joined, or invited, which is + * something to act on) stays up front; a group of rooms nobody has joined waits behind one Browse entry, so the first + * screen is conversations, not a wall of Join buttons. Loose (estate) rooms are never grouped away. + */ +export const splitGroups = (tier: EstateTier): { front: EstateGroup[]; browse: EstateGroup[] } => { + const front: EstateGroup[] = []; + const browse: EstateGroup[] = []; + tier.groups.forEach((g) => + (g.rooms.some((r) => r.state !== 'joinable') ? front : browse).push(g) + ); + return { front, browse }; +}; + export const DIRECT_TIER_ID = 'direct'; export const ELSEWHERE_TIER_ID = 'elsewhere'; -- 2.47.3 From 3c868254e549ffe999ad3b448409e5e68931c5ee Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:47:41 +0000 Subject: [PATCH 2/2] Category buttons expose aria-expanded RoomNavCategoryButton set open/closed only through its chevron. It now sets aria-expanded from `closed` (absent when the section does not toggle); tested by rendering to static markup. Co-Authored-By: Claude Sonnet 5.5 --- .../room-nav/RoomNavCategoryButton.test.tsx | 21 +++++++++++++++++++ .../room-nav/RoomNavCategoryButton.tsx | 1 + 2 files changed, 22 insertions(+) create mode 100644 src/app/features/room-nav/RoomNavCategoryButton.test.tsx diff --git a/src/app/features/room-nav/RoomNavCategoryButton.test.tsx b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx new file mode 100644 index 00000000..a500f920 --- /dev/null +++ b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { RoomNavCategoryButton } from './RoomNavCategoryButton'; + +const html = (closed?: boolean) => + renderToStaticMarkup(Browse); + +describe('RoomNavCategoryButton', () => { + it('says it is collapsed when closed, so assistive tech does not rely on the chevron', () => { + expect(html(true)).toContain('aria-expanded="false"'); + }); + + it('says it is expanded when open', () => { + expect(html(false)).toContain('aria-expanded="true"'); + }); + + it('claims neither when the section does not toggle', () => { + expect(html(undefined)).not.toContain('aria-expanded'); + }); +}); diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 3af8aeac..e232b856 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -16,6 +16,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( src={closed ? Icons.ChevronRight : Icons.ChevronBottom} /> } + aria-expanded={closed === undefined ? undefined : !closed} {...props} ref={ref} > -- 2.47.3