From 16cc05a33b214b74acdb2b7dda4b877b5af0b665 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:20:13 +0000 Subject: [PATCH 01/14] phone text flow: portrait width floor, list markers, in-flow preview banner, pretend-server notice - portrait on phones is min(38vw,160px), was min(50vw,210px): at 390px the text column beside it was ~85px - outside list markers ignored the portrait silhouette and landed under the art; inside markers wrap with the text - Jump to Latest sits 8px (was 16px) above the timeline's lower edge - preview banner is in flow above the app, not fixed over the room header and connection strip - on the pretend server the training line and the registration checkbox say nothing is trained (trainingNoticeText); the real sentence and its wording are unchanged and still await Meta's legal read Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/layout/layout.css.ts | 14 +++++++++++- src/app/features/room/RoomTimeline.css.ts | 2 +- src/app/features/room/RoomTrainingLine.tsx | 4 +++- src/app/pages/auth/TrainingNotice.tsx | 6 +++-- src/app/utils/trainingNotice.test.ts | 22 +++++++++++++++++++ src/app/utils/trainingNotice.ts | 15 +++++++++++-- src/preview-demo/install.ts | 11 ++++++---- 7 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 991f60d8..50fd4417 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -262,7 +262,7 @@ export const PoseButton = style({ [PHONE]: { position: 'static', float: 'left', - width: 'min(50vw, 210px)', + width: 'min(38vw, 160px)', height: 'auto', margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, shapeMargin: toRem(7), @@ -387,6 +387,18 @@ globalStyle(`${BubbleContentPose} > div`, { }, }); +// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so +// beside a left portrait the number lands under the art. Inside, it is a word on the first line +// and wraps around the silhouette like the rest. +globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, { + '@media': { + [PHONE]: { + listStylePosition: 'inside', + paddingLeft: 0, + }, + }, +}); + // While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in // the app: its own colour at half strength over a blur of the scene, so the words stay readable // and the ridge shows through. The arrow wedge is opaque, so it goes. diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 9cd428ed..d798f62c 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -18,7 +18,7 @@ export const TimelineFloat = recipe({ top: config.space.S400, }, Bottom: { - bottom: config.space.S400, + bottom: config.space.S200, }, }, }, diff --git a/src/app/features/room/RoomTrainingLine.tsx b/src/app/features/room/RoomTrainingLine.tsx index 147932de..8f09fdcf 100644 --- a/src/app/features/room/RoomTrainingLine.tsx +++ b/src/app/features/room/RoomTrainingLine.tsx @@ -2,6 +2,7 @@ import { Text, color, config } from 'folds'; import { Room } from 'matrix-js-sdk'; import React from 'react'; import { useStateEvent } from '../../hooks/useStateEvent'; +import { useClientConfig } from '../../hooks/useClientConfig'; import { ROOM_NOTICE_EVENT_TYPE, parseRoomNotice, @@ -12,7 +13,8 @@ import { // collector reads the version from the same event, so nothing is stored in the browser. export function RoomTrainingLine({ room }: { room: Room }) { const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE); - const line = roomTrainingLine(parseRoomNotice(event?.getContent())); + const { previewDemo } = useClientConfig(); + const line = roomTrainingLine(parseRoomNotice(event?.getContent()), previewDemo); if (!line) return null; return ( - {TRAINING_NOTICE_TEXT} + {trainingNoticeText(previewDemo)} ); diff --git a/src/app/utils/trainingNotice.test.ts b/src/app/utils/trainingNotice.test.ts index 0663d2f9..dce3ee90 100644 --- a/src/app/utils/trainingNotice.test.ts +++ b/src/app/utils/trainingNotice.test.ts @@ -4,7 +4,9 @@ import { ROOM_NOTICE_EVENT_TYPE, TRAINING_NOTICE_TEXT, parseRoomNotice, + PREVIEW_NOTICE_TEXT, roomTrainingLine, + trainingNoticeText, } from './trainingNotice'; const lineFor = (content: unknown) => roomTrainingLine(parseRoomNotice(content)); @@ -66,3 +68,23 @@ describe('training notice', () => { expect(lineFor({ version: 1, scope: ['train'] })).toBeUndefined(); }); }); + +describe('training notice on the pretend server', () => { + it('states that nothing is trained instead of the real sentence', () => { + expect(trainingNoticeText(true)).toBe(PREVIEW_NOTICE_TEXT); + expect(PREVIEW_NOTICE_TEXT).not.toContain('may be used to train'); + expect(PREVIEW_NOTICE_TEXT.toLowerCase()).toContain('pretend'); + }); + + it('keeps the ruled sentence everywhere else', () => { + expect(trainingNoticeText(false)).toBe(TRAINING_NOTICE_TEXT); + expect(trainingNoticeText(undefined)).toBe(TRAINING_NOTICE_TEXT); + }); + + it('shows the same text in a train room and at registration', () => { + const notice = parseRoomNotice({ version: 1, scope: 'train' }); + expect(roomTrainingLine(notice, true)).toBe(trainingNoticeText(true)); + expect(roomTrainingLine(notice, false)).toBe(trainingNoticeText(false)); + expect(roomTrainingLine(parseRoomNotice({ version: 1, scope: 'show' }), true)).toBeUndefined(); + }); +}); diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index 7f60cce4..e2c1de00 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,6 +3,15 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; +// The preview build runs against a pretend server where nothing is trained, so the real sentence +// would be false there. The real wording awaits Meta's legal read (Heimdall's consent text, open). +export const PREVIEW_NOTICE_TEXT = + 'Pretend server: nothing you type here is stored or used to train anything.'; + +/** The one sentence every first-typing surface shows: the real one, or the pretend server's. */ +export const trainingNoticeText = (previewDemo?: boolean): string => + previewDemo === true ? PREVIEW_NOTICE_TEXT : TRAINING_NOTICE_TEXT; + /** * Heimdall's ruling: a room's notice is a room state event, not a pinned message. Type below, * state_key '', content `{version, scope}`; scope exactly one of show, study or train. Heimdall's @@ -31,5 +40,7 @@ export const parseRoomNotice = (content: unknown): RoomNotice | undefined => { }; /** The line a room shows: only a notice that says `train` in terms gets the train sentence. */ -export const roomTrainingLine = (notice: RoomNotice | undefined): string | undefined => - notice?.scope === 'train' ? TRAINING_NOTICE_TEXT : undefined; +export const roomTrainingLine = ( + notice: RoomNotice | undefined, + previewDemo?: boolean +): string | undefined => (notice?.scope === 'train' ? trainingNoticeText(previewDemo) : undefined); diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts index 36990a57..392d91d5 100644 --- a/src/preview-demo/install.ts +++ b/src/preview-demo/install.ts @@ -30,15 +30,18 @@ const BANNER_TEXT = 'preview: pretend server. Any password works.'; const FOREIGN_SESSION_TEXT = 'preview: pretend server. A login for another server is stored here and cannot be used.'; +// In flow, above the app: body becomes a column and the app root takes the rest, so the banner +// never covers the room header or the connection strip. const showBanner = (text: string) => { const el = document.createElement('div'); el.textContent = text; el.setAttribute('role', 'status'); el.style.cssText = - 'position:fixed;top:0;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:96vw;' + - 'padding:0 8px;font:10px/1.5 system-ui,sans-serif;white-space:nowrap;opacity:0.9;color:#1b1b1b;background:#ffc46b;' + - 'border-radius:0 0 6px 6px;pointer-events:none;text-align:center'; - document.body.appendChild(el); + 'flex:none;padding:2px 8px;font:11px/1.4 system-ui,sans-serif;color:#1b1b1b;background:#ffc46b;text-align:center'; + const root = document.getElementById('root'); + document.body.style.cssText += ';display:flex;flex-direction:column'; + if (root) root.style.cssText += ';flex:1;min-height:0;height:auto'; + document.body.insertBefore(el, root); }; // The preview host's CSP sandbox has no allow-forms, so the browser cancels a native form From a1aece7d36e051ff3f2daa187dab001ec420694d Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:20:31 +0000 Subject: [PATCH 02/14] preview-demo test: fake document carries what the in-flow banner uses Co-Authored-By: Claude Sonnet 5.5 --- src/preview-demo/server.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/preview-demo/server.test.ts b/src/preview-demo/server.test.ts index e3cc977e..d277b97b 100644 --- a/src/preview-demo/server.test.ts +++ b/src/preview-demo/server.test.ts @@ -237,7 +237,8 @@ describe('the guard', () => { } as unknown as Window & typeof globalThis; vi.stubGlobal('document', { createElement: () => ({ style: {}, setAttribute: vi.fn() }), - body: { appendChild: vi.fn() }, + getElementById: () => null, + body: { style: {}, insertBefore: vi.fn() }, addEventListener: vi.fn(), }); const server = installDemo(win); From 58df7b833cbd679926458aff4cc090adff071142 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:41:04 +0000 Subject: [PATCH 03/14] phone: pill clears the portrait, portrait bubbles keep a text floor, status block collapses - the name pill is at most bubble width minus portrait minus 30px, so it cannot reach the face - a bubble with a portrait is at least portrait + 170px wide, so text beside a right portrait no longer wraps into a ~100px column - <=480px: the pose status block shows only the newest mood plus "+N more" on one line, tap to expand; the read-receipt line is hidden and its slot is an 8px margin under the composer Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/layout/layout.css.ts | 11 ++++- src/app/components/message/pose/poses.test.ts | 23 ++++++++++- src/app/components/message/pose/poses.ts | 11 +++++ .../features/room/RoomViewFollowing.css.ts | 10 +++++ .../features/room/RoomViewPoseStatus.css.ts | 40 ++++++++++++++++++ src/app/features/room/RoomViewPoseStatus.tsx | 41 +++++++++++++++---- 6 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 src/app/features/room/RoomViewPoseStatus.css.ts diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 50fd4417..6c7b97f3 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -156,6 +156,8 @@ export const BubbleContentArrowLeft = style({ // beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top // edge, and the text wraps around its silhouette (shape-outside on the alpha channel). const PHONE = 'screen and (max-width: 700px)'; +const PHONE_PORTRAIT = 'min(38vw, 160px)'; +const PHONE_TEXT_FLOOR = toRem(170); const PORTRAIT = toRem(124); const POSE_GUTTER = toRem(14); const BUBBLE_CORNER = toRem(6); @@ -209,6 +211,10 @@ export const BubbleHeaderPose = style({ border: `1px solid ${color.Secondary.ContainerLine}`, borderRadius: toRem(999), whiteSpace: 'nowrap', + // Never wider than the bubble minus the portrait, so it cannot reach the portrait's face. + maxWidth: `calc(100% - ${PHONE_PORTRAIT} - ${toRem(30)})`, + overflow: 'hidden', + textOverflow: 'ellipsis', }, }, }); @@ -232,6 +238,9 @@ export const BubbleContentPose = style({ '@media': { [PHONE]: { display: 'flow-root', + // A bubble with a portrait is at least portrait + a readable column wide, or the text + // beside the portrait collapses to a strip. + minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`, borderTopLeftRadius: config.radii.R500, padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`, }, @@ -262,7 +271,7 @@ export const PoseButton = style({ [PHONE]: { position: 'static', float: 'left', - width: 'min(38vw, 160px)', + width: PHONE_PORTRAIT, height: 'auto', margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, shapeMargin: toRem(7), diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts index ed503abe..81c2f0c6 100644 --- a/src/app/components/message/pose/poses.test.ts +++ b/src/app/components/message/pose/poses.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import catalogue from './catalogue.json'; -import { mindOfSender, poseSrc, poseStatusLine, poseStatuses, resolvePose } from './poses'; +import { + mindOfSender, + poseSrc, + poseStatusLine, + poseStatuses, + resolvePose, + splitNewestStatus, +} from './poses'; describe('resolvePose', () => { it('keeps shared and own-signature labels from the catalogue', () => { @@ -81,3 +88,17 @@ describe('poseStatusLine', () => { ); }); }); + +describe('splitNewestStatus', () => { + const s = (mind: string, since: number) => ({ mind, pose: 'happy', since }); + + it('picks the status that changed last and keeps the others in their order', () => { + const { newest, rest } = splitNewestStatus([s('eureka', 100), s('void', 300), s('nibu', 200)]); + expect(newest?.mind).toBe('void'); + expect(rest.map((r) => r.mind)).toEqual(['eureka', 'nibu']); + }); + + it('has no newest for an empty list', () => { + expect(splitNewestStatus([])).toEqual({ newest: undefined, rest: [] }); + }); +}); diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts index a5b705c3..7175196a 100644 --- a/src/app/components/message/pose/poses.ts +++ b/src/app/components/message/pose/poses.ts @@ -68,6 +68,17 @@ export const poseStatuses = (events: PoseEvent[]): PoseStatus[] => { }); }; +// The status that changed last, and the rest in their given order (phones show only the first). +export const splitNewestStatus = ( + statuses: PoseStatus[] +): { newest: PoseStatus | undefined; rest: PoseStatus[] } => { + let newest: PoseStatus | undefined; + statuses.forEach((st) => { + if (!newest || st.since > newest.since) newest = st; + }); + return { newest, rest: statuses.filter((st) => st !== newest) }; +}; + const two = (n: number) => String(n).padStart(2, '0'); // Client-status text. Rendered locally only; it is never sent to a room. diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts index 18b53ac9..ad7fd12b 100644 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ b/src/app/features/room/RoomViewFollowing.css.ts @@ -2,10 +2,19 @@ import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; +// Phones: the read-receipt line is hidden; its slot shrinks to a margin under the composer. +const NARROW_HIDDEN = { + height: toRem(8), + minHeight: 0, + overflow: 'hidden', + visibility: 'hidden', +} as const; + export const RoomViewFollowingPlaceholder = style([ DefaultReset, { height: toRem(28), + '@media': { 'screen and (max-width: 480px)': NARROW_HIDDEN }, }, ]); @@ -19,6 +28,7 @@ export const RoomViewFollowing = recipe({ backgroundColor: color.Surface.Container, color: color.Surface.OnContainer, outline: 'none', + '@media': { 'screen and (max-width: 480px)': NARROW_HIDDEN }, }, ], variants: { diff --git a/src/app/features/room/RoomViewPoseStatus.css.ts b/src/app/features/room/RoomViewPoseStatus.css.ts new file mode 100644 index 00000000..d8319d0d --- /dev/null +++ b/src/app/features/room/RoomViewPoseStatus.css.ts @@ -0,0 +1,40 @@ +import { style } from '@vanilla-extract/css'; +import { DefaultReset, color, config, toRem } from 'folds'; + +const NARROW = 'screen and (max-width: 480px)'; + +export const StatusBlock = style([ + DefaultReset, + { + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + padding: `0 ${toRem(16)}`, + // Phones: the newest mood and its "+N more" share one line. + '@media': { [NARROW]: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center' } }, + }, +]); + +// On phones only the newest mood shows until the strip is tapped; wide screens show every line. +export const StatusRow = style({ + '@media': { + [NARROW]: { + selectors: { + '&[data-newest="false"]': { display: 'none' }, + [`${StatusBlock}[data-open="true"] &`]: { display: 'block', flexBasis: '100%' }, + }, + }, + }, +}); + +export const StatusToggle = style([ + DefaultReset, + { + display: 'none', + minHeight: toRem(24), + padding: `0 ${config.space.S100}`, + color: color.Primary.Main, + cursor: 'pointer', + '@media': { [NARROW]: { display: 'block' } }, + }, +]); diff --git a/src/app/features/room/RoomViewPoseStatus.tsx b/src/app/features/room/RoomViewPoseStatus.tsx index d4a52c93..e62f81c0 100644 --- a/src/app/features/room/RoomViewPoseStatus.tsx +++ b/src/app/features/room/RoomViewPoseStatus.tsx @@ -1,17 +1,24 @@ import React, { useEffect, useState } from 'react'; -import { Box, Text } from 'folds'; +import { Text } from 'folds'; import { Room, RoomEvent } from 'matrix-js-sdk'; import { settingsAtom } from '../../state/settings'; import { useSetting } from '../../state/hooks/settings'; import { getMxIdLocalPart } from '../../utils/matrix'; import { getMemberDisplayName } from '../../utils/room'; -import { POSE_KEY, poseStatusLine, poseStatuses } from '../../components/message/pose/poses'; +import { + POSE_KEY, + poseStatusLine, + poseStatuses, + splitNewestStatus, +} from '../../components/message/pose/poses'; +import * as css from './RoomViewPoseStatus.css'; // Client-status lines about each Mind's current pose. They are drawn from the // timeline already in memory and are never sent as anyone's message. export function RoomViewPoseStatus({ room }: { room: Room }) { const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles'); const [, bump] = useState(0); + const [open, setOpen] = useState(false); useEffect(() => { const onTimeline = () => bump((n) => n + 1); @@ -41,13 +48,33 @@ export function RoomViewPoseStatus({ room }: { room: Room }) { ); if (statuses.length === 0) return null; + const { newest, rest } = splitNewestStatus(statuses); + return ( - - {statuses.map((s) => ( - - {poseStatusLine(s)} +
+ {statuses.map((st) => ( + + {poseStatusLine(st)} ))} - + {rest.length > 0 && ( + + )} +
); } From 8ac43bd73f10f1f9fb2d615db42b16c55cea7a70 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:05:02 +0000 Subject: [PATCH 04/14] Name pill shows the full name on phones, wraps when long The pill was capped at a percentage of the bubble column and clipped with an ellipsis; a short bubble's column is narrower than its own name, so names read as "E..." and "V...". It is now max-content up to a viewport-based cap and wraps. Demo fixtures gain a long display name to keep this honest. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 13 +++++++------ src/preview-demo/fixtures.ts | 3 +++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 6c7b97f3..f5890c1e 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -209,12 +209,13 @@ export const BubbleHeaderPose = style({ padding: `0 ${toRem(13)}`, background: color.Secondary.Container, border: `1px solid ${color.Secondary.ContainerLine}`, - borderRadius: toRem(999), - whiteSpace: 'nowrap', - // Never wider than the bubble minus the portrait, so it cannot reach the portrait's face. - maxWidth: `calc(100% - ${PHONE_PORTRAIT} - ${toRem(30)})`, - overflow: 'hidden', - textOverflow: 'ellipsis', + // The pill is as wide as the full name needs, up to the room beside the portrait, then the + // name wraps. It is capped by the viewport, not by the column: a short bubble's column is + // narrower than its own name, and a percentage cap there truncated names to one letter. + width: 'max-content', + maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, + overflowWrap: 'anywhere', + borderRadius: toRem(14), }, }, }); diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts index 32d3c7a6..6d03ec4c 100644 --- a/src/preview-demo/fixtures.ts +++ b/src/preview-demo/fixtures.ts @@ -23,6 +23,7 @@ export const USERS: DemoUser[] = [ { id: `@void:${DEMO_SERVER}`, name: 'Void' }, { id: `@nibu:${DEMO_SERVER}`, name: 'Nibu' }, { id: `@visitor:${DEMO_SERVER}`, name: 'Visitor' }, + { id: `@watch:${DEMO_SERVER}`, name: 'Heimdall of the Long Watch, Keeper of the Bridge' }, ]; const user = (localpart: string) => `@${localpart}:${DEMO_SERVER}`; @@ -110,6 +111,8 @@ export const ROOMS: DemoRoom[] = [ pose: 'deadpan', minutesAgo: 25, }, + // A long display name: the name pill has to show all of it on a phone. + { from: user('watch'), body: 'Ok.', pose: 'reading', minutesAgo: 20 }, ], }, { From b091f65cea36fbfc29ded405199bcd8d33dc0ca2 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:08:18 +0000 Subject: [PATCH 05/14] Name inside the pose pill wraps instead of ellipsising Username carries its own nowrap + ellipsis, which still clipped long names ("Heimdall o...") inside the widened pill. On phones the name now wraps. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index f5890c1e..e87627bd 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -397,6 +397,19 @@ globalStyle(`${BubbleContentPose} > div`, { }, }); +// The name inside the pill wraps instead of ellipsising: the pill already caps its own width, and +// Username's own nowrap + ellipsis clipped long names to "Heimdall o…" inside it. +globalStyle(`${BubbleHeaderPose} ${Username}`, { + '@media': { + [PHONE]: { + whiteSpace: 'normal', + overflow: 'visible', + textOverflow: 'clip', + overflowWrap: 'anywhere', + }, + }, +}); + // An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so // beside a left portrait the number lands under the art. Inside, it is a word on the first line // and wraps around the silhouette like the rest. From ea33ab6daa004f3d4d34b30226ad85772f809c4a Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:10:31 +0000 Subject: [PATCH 06/14] Pose pill: the Text inside the name button wraps too The name button wraps a truncate Text span; overriding only the button left the span nowrap, so the long name overflowed the pill. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index e87627bd..0afb68bf 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -398,8 +398,9 @@ globalStyle(`${BubbleContentPose} > div`, { }); // The name inside the pill wraps instead of ellipsising: the pill already caps its own width, and -// Username's own nowrap + ellipsis clipped long names to "Heimdall o…" inside it. -globalStyle(`${BubbleHeaderPose} ${Username}`, { +// Username's own nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to +// "Heimdall o…" inside it. +globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, { '@media': { [PHONE]: { whiteSpace: 'normal', From 34bdccd87c7a6cf64aa8eed2fa360e5527ff1edf Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:21:07 +0000 Subject: [PATCH 07/14] Demo fixture: the long-named user is a known Mind's localpart The pose-label test resolves fixture senders to known Minds; "@watch" was not one, so server.test.ts failed on the long-name fixture. Same long display name, now @heimdall. Co-Authored-By: Claude Sonnet 5.5 --- src/preview-demo/fixtures.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts index 6d03ec4c..4ef4f0bc 100644 --- a/src/preview-demo/fixtures.ts +++ b/src/preview-demo/fixtures.ts @@ -23,7 +23,7 @@ export const USERS: DemoUser[] = [ { id: `@void:${DEMO_SERVER}`, name: 'Void' }, { id: `@nibu:${DEMO_SERVER}`, name: 'Nibu' }, { id: `@visitor:${DEMO_SERVER}`, name: 'Visitor' }, - { id: `@watch:${DEMO_SERVER}`, name: 'Heimdall of the Long Watch, Keeper of the Bridge' }, + { id: `@heimdall:${DEMO_SERVER}`, name: 'Heimdall of the Long Watch, Keeper of the Bridge' }, ]; const user = (localpart: string) => `@${localpart}:${DEMO_SERVER}`; @@ -112,7 +112,7 @@ export const ROOMS: DemoRoom[] = [ minutesAgo: 25, }, // A long display name: the name pill has to show all of it on a phone. - { from: user('watch'), body: 'Ok.', pose: 'reading', minutesAgo: 20 }, + { from: user('heimdall'), body: 'Ok.', pose: 'reading', minutesAgo: 20 }, ], }, { From 8ca38c4980881ce7ebf51ae47374cb810191d1bf Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:21:27 +0000 Subject: [PATCH 08/14] Demo fixture: long-named Heimdall uses a pose Heimdall's art has Co-Authored-By: Claude Sonnet 5.5 --- src/preview-demo/fixtures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts index 4ef4f0bc..c96a4ccf 100644 --- a/src/preview-demo/fixtures.ts +++ b/src/preview-demo/fixtures.ts @@ -112,7 +112,7 @@ export const ROOMS: DemoRoom[] = [ minutesAgo: 25, }, // A long display name: the name pill has to show all of it on a phone. - { from: user('heimdall'), body: 'Ok.', pose: 'reading', minutesAgo: 20 }, + { from: user('heimdall'), body: 'Ok.', pose: 'thinking', minutesAgo: 20 }, ], }, { From 245eb90f63048bf68f1c91e0cefc272cbf71df9c Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:27:42 +0000 Subject: [PATCH 09/14] Pose pill: a long name takes its own lines, the time drops below At 320px the name shrank to a 37px column beside the time and broke mid-word. The pill now wraps its children and breaks only where a single word is too long. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 0afb68bf..ad5d1c38 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -214,7 +214,9 @@ export const BubbleHeaderPose = style({ // narrower than its own name, and a percentage cap there truncated names to one letter. width: 'max-content', maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, - overflowWrap: 'anywhere', + // A name too long for one line takes its own lines and the time drops below it, instead of + // the name shrinking to a sliver beside the time. + flexWrap: 'wrap', borderRadius: toRem(14), }, }, @@ -406,7 +408,7 @@ globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *` whiteSpace: 'normal', overflow: 'visible', textOverflow: 'clip', - overflowWrap: 'anywhere', + overflowWrap: 'break-word', }, }, }); From 14312459f27e05dec5539535e6b22d06c4bfa036 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:29:22 +0000 Subject: [PATCH 10/14] Pose pill: wrap the header row, not the pill The pill's child is the flex row holding name and time; flex-wrap on the pill itself did nothing. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index ad5d1c38..1603fc43 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -214,9 +214,6 @@ export const BubbleHeaderPose = style({ // narrower than its own name, and a percentage cap there truncated names to one letter. width: 'max-content', maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, - // A name too long for one line takes its own lines and the time drops below it, instead of - // the name shrinking to a sliver beside the time. - flexWrap: 'wrap', borderRadius: toRem(14), }, }, @@ -413,6 +410,16 @@ globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *` }, }); +// A name too long for one line takes its own lines and the time drops below it, instead of the +// name shrinking to a sliver beside the time (the pill's one child is the flex row of both). +globalStyle(`${BubbleHeaderPose} > div`, { + '@media': { + [PHONE]: { + flexWrap: 'wrap', + }, + }, +}); + // An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so // beside a left portrait the number lands under the art. Inside, it is a word on the first line // and wraps around the silhouette like the rest. From 6110292e3bcf877bb0d4d263e809776b0ff4e2d3 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:50:24 +0000 Subject: [PATCH 11/14] Pose pill grows upward when its name wraps, clear of the bubble text Anchored by its bottom edge 13px inside the bubble instead of its top edge 15px above it, so a wrapped name and time never cover the first line of text. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 1603fc43..731fa0f5 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -204,7 +204,9 @@ export const BubbleHeaderPose = style({ [PHONE]: { position: 'absolute', zIndex: 2, - top: toRem(-15), + // Anchored by its bottom edge, 13px inside the bubble: a name that wraps grows upward, out of + // the bubble, instead of down over the first line of text. + bottom: `calc(100% - ${toRem(13)})`, right: toRem(14), padding: `0 ${toRem(13)}`, background: color.Secondary.Container, From a23dd239ba4145fe1fb0fa24295b1eae2dc1f8a0 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:50:24 +0000 Subject: [PATCH 12/14] Room list: steady scrim behind rows over the scene, icon on unjoined rows With the background scene on, nav rows were fully clear and the ridges showed through their text. Rows now carry the theme's own surface at 62% (80% on hover and selected). An unjoined row (Commons) gets the same leading room icon as a joined one. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 11 +++++++++++ src/app/features/estate/EstateNavigator.tsx | 13 +++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 48b2a4d3..a7f3053d 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,6 +1,7 @@ import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; export const NavCategory = style([ DefaultReset, @@ -61,6 +62,16 @@ const NavItemBase = style({ '&[data-hover=true]': { backgroundColor: ContainerHover, }, + // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a steady + // scrim of the theme's own surface, so ridges never show through its text. Hover and the + // selected row strengthen it. + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, + }, + ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true], :root[data-scene] &[aria-selected=true]': + { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 80%, transparent)`, + }, [`&:has(.${NavLink}:active)`]: { backgroundColor: ContainerActive, }, diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 6fa4bf48..29451c92 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -1,7 +1,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color, config } from 'folds'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavButton, @@ -76,9 +76,14 @@ function JoinRow({ room }: { room: EstateRoom }) { - - {room.name} - + + + + + + {room.name} + + {state.status === AsyncStatus.Error && ( Date: Mon, 9 Nov 2026 10:51:46 +0000 Subject: [PATCH 13/14] Room list: category labels get the same scene scrim as the rows Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index a7f3053d..70344c04 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -12,6 +12,13 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, + selectors: { + // Category labels sit on the scene like the rows do, and need the same scrim to be read. + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, + borderRadius: config.radii.R400, + }, + }, }); export const NavLink = style({ From cfb8a8641aea71c36bcaa55eb915e79563ee2401 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 11:06:24 +0000 Subject: [PATCH 14/14] Room list scrim: hover, selected and pressed rows are three distinct steps Soul: selected and hover shared one 80% value and the press state lost to hover on specificity. Now hover 74%, selected 84%, pressed 92%. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 70344c04..927fee0f 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -70,15 +70,21 @@ const NavItemBase = style({ backgroundColor: ContainerHover, }, // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a steady - // scrim of the theme's own surface, so ridges never show through its text. Hover and the - // selected row strengthen it. + // scrim of the theme's own surface, so ridges never show through its text. Hover, the selected row and a + // press each strengthen it a step. ':root[data-scene] &': { backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, }, - ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true], :root[data-scene] &[aria-selected=true]': + ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 80%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} 74%, transparent)`, }, + ':root[data-scene] &[aria-selected=true]': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 84%, transparent)`, + }, + [`:root[data-scene] &:has(.${NavLink}:active)`]: { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 92%, transparent)`, + }, [`&:has(.${NavLink}:active)`]: { backgroundColor: ContainerActive, },