diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 991f60d8..731fa0f5 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); @@ -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. 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/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 48b2a4d3..927fee0f 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, @@ -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, }, 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 && ( { 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 && ( + + )} +
); } diff --git a/src/app/pages/auth/TrainingNotice.tsx b/src/app/pages/auth/TrainingNotice.tsx index 73b1caa0..6aeaf9d4 100644 --- a/src/app/pages/auth/TrainingNotice.tsx +++ b/src/app/pages/auth/TrainingNotice.tsx @@ -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 ( - {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/fixtures.ts b/src/preview-demo/fixtures.ts index 32d3c7a6..c96a4ccf 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: `@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 }, ], }, { diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts index b182a935..a7b66de9 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); }; const parseBody = (body: unknown): unknown => { 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);