Merge pull request 'Phone text flow: portrait floor, list markers, in-flow banner, pretend-server notice' (#35) from void/text-flow-footer into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m16s

This commit is contained in:
Void 2026-11-09 11:07:05 +00:00
commit dfc74c252a
16 changed files with 256 additions and 27 deletions

View file

@ -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);
@ -202,13 +204,19 @@ 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,
border: `1px solid ${color.Secondary.ContainerLine}`,
borderRadius: toRem(999),
whiteSpace: 'nowrap',
// 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)})`,
borderRadius: toRem(14),
},
},
});
@ -232,6 +240,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 +273,7 @@ export const PoseButton = style({
[PHONE]: {
position: 'static',
float: 'left',
width: 'min(50vw, 210px)',
width: PHONE_PORTRAIT,
height: 'auto',
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
shapeMargin: toRem(7),
@ -387,6 +398,42 @@ 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, 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',
overflow: 'visible',
textOverflow: 'clip',
overflowWrap: 'break-word',
},
},
});
// 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.
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.

View file

@ -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: [] });
});
});

View file

@ -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.

View file

@ -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,
@ -11,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({
@ -61,6 +69,22 @@ 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, 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]':
{
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,
},

View file

@ -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 }) {
<NavItem variant="Background" radii="400">
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px', minHeight: 38 }}>
<NavItemContent>
<Text as="span" size="Inherit" truncate>
{room.name}
</Text>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Hash} size="100" style={{ opacity: config.opacity.P300 }} />
</Avatar>
<Text as="span" size="Inherit" truncate>
{room.name}
</Text>
</Box>
</NavItemContent>
{state.status === AsyncStatus.Error && (
<Text

View file

@ -18,7 +18,7 @@ export const TimelineFloat = recipe({
top: config.space.S400,
},
Bottom: {
bottom: config.space.S400,
bottom: config.space.S200,
},
},
},

View file

@ -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 (
<Text

View file

@ -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: {

View file

@ -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' } },
},
]);

View file

@ -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 (
<Box direction="Column" shrink="No" style={{ padding: '0 1rem' }}>
{statuses.map((s) => (
<Text key={s.mind} size="T200" priority="300">
{poseStatusLine(s)}
<div className={css.StatusBlock} data-open={open}>
{statuses.map((st) => (
<Text
key={st.mind}
className={css.StatusRow}
data-newest={st === newest}
size="T200"
priority="300"
>
{poseStatusLine(st)}
</Text>
))}
</Box>
{rest.length > 0 && (
<button
type="button"
className={css.StatusToggle}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<Text as="span" size="T200">
{open ? 'fewer' : `+${rest.length} more`}
</Text>
</button>
)}
</div>
);
}

View file

@ -1,6 +1,7 @@
import { Box, Checkbox, Text, color, config } from 'folds';
import React from 'react';
import { TRAINING_NOTICE_TEXT } from '../../utils/trainingNotice';
import { useClientConfig } from '../../hooks/useClientConfig';
import { trainingNoticeText } from '../../utils/trainingNotice';
type TrainingNoticeProps = {
checked: boolean;
@ -9,6 +10,7 @@ type TrainingNoticeProps = {
// Registration only: ticking it is the newcomer's acceptance of the current notice version.
export function TrainingNotice({ checked, onToggle }: TrainingNoticeProps) {
const { previewDemo } = useClientConfig();
return (
<Box
direction="Column"
@ -34,7 +36,7 @@ export function TrainingNotice({ checked, onToggle }: TrainingNoticeProps) {
checked={checked}
onClick={onToggle}
/>
{TRAINING_NOTICE_TEXT}
{trainingNoticeText(previewDemo)}
</Text>
</Box>
);

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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: `@heimdall:${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('heimdall'), body: 'Ok.', pose: 'thinking', minutesAgo: 20 },
],
},
{

View file

@ -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);
};
const parseBody = (body: unknown): unknown => {

View file

@ -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);