From 1195b0bffc48b24b37e41ad54821bc035df7723a Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 17:12:06 +0000 Subject: [PATCH 1/3] Navigator ink: chevrons 0.9, room icons 0.26, solid unread dot, label plate over the scene Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 21 ++++++++++++------- .../components/unread-badge/UnreadBadge.tsx | 5 +++-- .../room-nav/RoomNavCategoryButton.tsx | 3 ++- src/app/features/room-nav/styles.css.ts | 7 ------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index d66097af..35cd729a 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -2,7 +2,6 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@van import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; import { surfaceInk } from '../../features/scene/sceneShell.css'; -import { skyLabelInk } from '../../features/scene/skyLabelInk'; // Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows // are three distinct steps above them. @@ -32,10 +31,10 @@ export const NavLevel = style({ paddingLeft: LEVEL_STEP, }); -// Chevrons and room icons are matched in weight, so a foldout is not outweighed by its rooms. -export const NAV_ICON_REST_OPACITY = 0.6; +// Chevrons and room icons are matched in measured ink (a room glyph is about 1.7x a chevron's, unread rooms stay far above), so a foldout is not outweighed by its rooms. +export const NAV_ICON_REST_OPACITY = 0.9; // A room's glyph is bigger and denser than the chevron, so it rests lighter to read as the same weight. -export const NAV_ROOM_ICON_REST_OPACITY = 0.42; +export const NAV_ROOM_ICON_REST_OPACITY = 0.26; export const NAV_ICON_UNREAD_OPACITY = 0.9; export const NavCategory = style([ @@ -252,9 +251,17 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { opacity: 0.6, textDecorationLine: 'underline', }); -phoneLabel(`:root[data-scene] ${label}`, { - color: skyLabelInk('inherit'), - textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, +// Over the scene a label sits on a feathered plate of the theme's own surface (not a card: no edge, the +// shadow carries it past the text) and wears the theme's own ink, the colour that surface was made for. +// The plate is on the label itself, so the label is at full strength here: an opacity would fade the plate too. +const LABEL_PLATE = `color-mix(in srgb, ${surfaceInk} 80%, transparent)`; +/** The same plate for text that is not a row label (a foldout's heading). */ +export const NavPlate = style({}); +globalStyle(`:root[data-scene] ${label}, :root[data-scene] .${NavPlate}`, { + opacity: 1, + backgroundColor: LABEL_PLATE, + boxShadow: `0 0 6px 5px ${LABEL_PLATE}`, + borderRadius: toRem(6), }); // Icons, dots and the Join action share the label's halo over the scene. diff --git a/src/app/components/unread-badge/UnreadBadge.tsx b/src/app/components/unread-badge/UnreadBadge.tsx index 84eb5ae4..00c7fa2f 100644 --- a/src/app/components/unread-badge/UnreadBadge.tsx +++ b/src/app/components/unread-badge/UnreadBadge.tsx @@ -22,8 +22,9 @@ export function UnreadBadge({ highlight, count }: UnreadBadgeProps) { 0 ? '400' : '200'} - // A mention is the one solid pill; plain unread is a soft tint, so a row's count is never its loudest part. - fill={highlight ? 'Solid' : 'Soft'} + // A mention and the bare unread dot are solid (a soft dot measured 1.6:1 against the ground); a plain unread + // count is a soft tint, so a row's count is never its loudest part. + fill={highlight || count === 0 ? 'Solid' : 'Soft'} radii="Pill" outlined={false} > diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 74945be3..90e1f5d6 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { as, Chip, Icon, Icons, Text } from 'folds'; import classNames from 'classnames'; import { NavGutter } from '../../components/nav'; +import { NavPlate } from '../../components/nav/styles.css'; import * as css from './styles.css'; export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( @@ -21,7 +22,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( src={closed ? Icons.ChevronRight : Icons.ChevronBottom} /> - + {children} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index 1d46e596..f87de142 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,7 +1,5 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; -import { surfaceInk } from '../scene/sceneShell.css'; -import { skyLabelInk } from '../scene/skyLabelInk'; import { NAV_ICON_REST_OPACITY } from '../../components/nav/styles.css'; export const CategoryButton = style({ @@ -21,11 +19,6 @@ export const CategoryButton = style({ border: 'none', borderRadius: 0, }, - ':root[data-scene] &&': { - color: skyLabelInk('inherit'), - textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, - filter: `drop-shadow(0 0 3px ${surfaceInk})`, - }, }, }, }, From d226ca45fbc0302e7c321bbcd28807b6a3341b8a Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 17:12:57 +0000 Subject: [PATCH 2/3] Media row: a joined file-zone room (by type or fileZoneRooms) is the row; alias or carried id only finds an unjoined one Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 2 +- src/app/features/estate/MediaRow.tsx | 28 +++++++++++++++++++-- src/app/features/estate/mediaRoom.test.ts | 27 +++++++++++++++++++- src/app/features/estate/mediaRoom.ts | 10 ++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 2a38e1f0..8b8b3644 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -195,7 +195,7 @@ function MindMedia({ media }: { media: MediaRowState }) { } function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) { - const media = useMindMedia(group.mind); + const media = useMindMedia(group.mind, group.rooms); const hasMedia = MINDS.includes(group.mind); // The media room is the Media row; it is not listed a second time among the Mind's rooms. const rooms = diff --git a/src/app/features/estate/MediaRow.tsx b/src/app/features/estate/MediaRow.tsx index 684fa6c5..6277f09c 100644 --- a/src/app/features/estate/MediaRow.tsx +++ b/src/app/features/estate/MediaRow.tsx @@ -4,6 +4,9 @@ import { useAtomValue } from 'jotai'; import { Avatar, Box, Icon, Icons, Text } from 'folds'; import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav'; import { NAV_ROOM_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css'; +import { EstateRoom } from './buildEstate'; +import { isFileZone } from '../albums/isFileZone'; +import { useClientConfig } from '../../hooks/useClientConfig'; import { MINDS } from '../../components/message/pose/poses'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { allRoomsAtom } from '../../state/room-list/roomList'; @@ -15,6 +18,7 @@ import { lookupMediaRoom, mediaRoomIdFromStewardships, mediaRow, + typedMediaRoom, } from './mediaRoom'; const mediaIcon = ( @@ -58,12 +62,31 @@ export function MediaLink({ to }: { to: string }) { } /** The Mind's media room as the navigator sees it right now. */ -export const useMindMedia = (mind: string): MediaRowState => { +export const useMindMedia = (mind: string, rooms: readonly EstateRoom[]): MediaRowState => { const mx = useMatrixClient(); const { rootId } = useEstateRootId(); const joined = useAtomValue(allRoomsAtom); const invited = useAtomValue(allInvitesAtom); + const config = useClientConfig(); const server = mx.getDomain() ?? ''; + // A joined room whose type says it is a file zone is the Media row; the alias or carried id only finds a + // room the client cannot type yet (not joined). + const typed = typedMediaRoom(rooms, (roomId) => { + const room = mx.getRoom(roomId); + return ( + !!room && + isFileZone( + { + roomId, + aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter( + (a): a is string => !!a + ), + createType: room.currentState.getStateEvents('m.room.create', '')?.getContent().type, + }, + config + ) + ); + }); const carried = mediaRoomIdFromStewardships( rootId ? mx.getRoom(rootId)?.currentState.getStateEvents(STEWARDSHIPS_KEY, '')?.getContent() @@ -74,10 +97,11 @@ export const useMindMedia = (mind: string): MediaRowState => { queryKey: ['mind-media', mind, server, carried], queryFn: () => lookupMediaRoom(mx, mind, server, carried), staleTime: 60_000, - enabled: MINDS.includes(mind), + enabled: MINDS.includes(mind) && !typed, }); const membership = new Map(); invited.forEach((id) => membership.set(id, 'invite')); joined.forEach((id) => membership.set(id, 'join')); + if (typed) return { kind: 'room', room: { ...typed, name: 'Media' } }; return mediaRow(lookup.data, membership); }; diff --git a/src/app/features/estate/mediaRoom.test.ts b/src/app/features/estate/mediaRoom.test.ts index 2cfbce81..af53c12b 100644 --- a/src/app/features/estate/mediaRoom.test.ts +++ b/src/app/features/estate/mediaRoom.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { lookupMediaRoom, mediaAlias, mediaRoomIdFromStewardships, mediaRow } from './mediaRoom'; +import { + lookupMediaRoom, + mediaAlias, + mediaRoomIdFromStewardships, + mediaRow, + typedMediaRoom, +} from './mediaRoom'; const notFound = Object.assign(new Error('Room alias not found'), { httpStatus: 404, @@ -90,4 +96,23 @@ describe('the Media row of a Mind', () => { expect(mediaRow(undefined, new Map())).toEqual({ kind: 'checking' }); expect(mediaRow({ found: false, reason: 'unset' }, new Map())).toEqual({ kind: 'unset' }); }); + + it('takes the joined room whose type says file zone, whatever its alias, before any alias lookup', () => { + const room = (roomId: string, state: 'joined' | 'joinable') => ({ + roomId, + name: roomId, + state, + via: [], + parentId: null, + direct: false, + }); + const rooms = [ + room('!chat', 'joined'), + room('!old-files-slug', 'joined'), + room('!unjoined', 'joinable'), + ]; + const zones = new Set(['!old-files-slug', '!unjoined']); + expect(typedMediaRoom(rooms, (id) => zones.has(id))?.roomId).toBe('!old-files-slug'); + expect(typedMediaRoom(rooms, () => false)).toBeUndefined(); + }); }); diff --git a/src/app/features/estate/mediaRoom.ts b/src/app/features/estate/mediaRoom.ts index a99c3d9d..a5f09664 100644 --- a/src/app/features/estate/mediaRoom.ts +++ b/src/app/features/estate/mediaRoom.ts @@ -19,6 +19,16 @@ export const mediaRoomIdFromStewardships = (content: unknown, mind: string): str return typeof id === 'string' && id.startsWith('!') ? id : undefined; }; +/** + * The Mind's media room among the rooms the client already knows: the first one whose room type (or the + * fileZoneRooms config) says it is a file zone. The type is the authority; the alias is only how an unknown + * room is found, below. + */ +export const typedMediaRoom = ( + rooms: readonly EstateRoom[], + isZone: (roomId: string) => boolean +): EstateRoom | undefined => rooms.find((r) => r.state === 'joined' && isZone(r.roomId)); + type MediaClient = { getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers?: string[] }>; }; From ec48dbf2ed953fe105385e3a6ffeef22cde77b47 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 17:23:13 +0000 Subject: [PATCH 3/3] Navigator: no label plate (Meta); icon weights and the solid unread dot stay Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 17 +++++------------ .../features/room-nav/RoomNavCategoryButton.tsx | 3 +-- src/app/features/room-nav/styles.css.ts | 7 +++++++ 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 35cd729a..e6156362 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -2,6 +2,7 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@van import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; import { surfaceInk } from '../../features/scene/sceneShell.css'; +import { skyLabelInk } from '../../features/scene/skyLabelInk'; // Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows // are three distinct steps above them. @@ -31,7 +32,7 @@ export const NavLevel = style({ paddingLeft: LEVEL_STEP, }); -// Chevrons and room icons are matched in measured ink (a room glyph is about 1.7x a chevron's, unread rooms stay far above), so a foldout is not outweighed by its rooms. +// Chevrons and room icons are matched in measured ink (a room glyph is about 1.7x a chevron's; unread rooms stay far above), so a foldout is not outweighed by its rooms. export const NAV_ICON_REST_OPACITY = 0.9; // A room's glyph is bigger and denser than the chevron, so it rests lighter to read as the same weight. export const NAV_ROOM_ICON_REST_OPACITY = 0.26; @@ -251,17 +252,9 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { opacity: 0.6, textDecorationLine: 'underline', }); -// Over the scene a label sits on a feathered plate of the theme's own surface (not a card: no edge, the -// shadow carries it past the text) and wears the theme's own ink, the colour that surface was made for. -// The plate is on the label itself, so the label is at full strength here: an opacity would fade the plate too. -const LABEL_PLATE = `color-mix(in srgb, ${surfaceInk} 80%, transparent)`; -/** The same plate for text that is not a row label (a foldout's heading). */ -export const NavPlate = style({}); -globalStyle(`:root[data-scene] ${label}, :root[data-scene] .${NavPlate}`, { - opacity: 1, - backgroundColor: LABEL_PLATE, - boxShadow: `0 0 6px 5px ${LABEL_PLATE}`, - borderRadius: toRem(6), +phoneLabel(`:root[data-scene] ${label}`, { + color: skyLabelInk('inherit'), + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, }); // Icons, dots and the Join action share the label's halo over the scene. diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 90e1f5d6..74945be3 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { as, Chip, Icon, Icons, Text } from 'folds'; import classNames from 'classnames'; import { NavGutter } from '../../components/nav'; -import { NavPlate } from '../../components/nav/styles.css'; import * as css from './styles.css'; export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( @@ -22,7 +21,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( src={closed ? Icons.ChevronRight : Icons.ChevronBottom} /> - + {children} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index f87de142..1d46e596 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,5 +1,7 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; +import { surfaceInk } from '../scene/sceneShell.css'; +import { skyLabelInk } from '../scene/skyLabelInk'; import { NAV_ICON_REST_OPACITY } from '../../components/nav/styles.css'; export const CategoryButton = style({ @@ -19,6 +21,11 @@ export const CategoryButton = style({ border: 'none', borderRadius: 0, }, + ':root[data-scene] &&': { + color: skyLabelInk('inherit'), + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, + filter: `drop-shadow(0 0 3px ${surfaceInk})`, + }, }, }, },