Phone text flow: portrait floor, list markers, in-flow banner, pretend-server notice #35
16 changed files with 256 additions and 27 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const TimelineFloat = recipe({
|
|||
top: config.space.S400,
|
||||
},
|
||||
Bottom: {
|
||||
bottom: config.space.S400,
|
||||
bottom: config.space.S200,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
40
src/app/features/room/RoomViewPoseStatus.css.ts
Normal file
40
src/app/features/room/RoomViewPoseStatus.css.ts
Normal 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' } },
|
||||
},
|
||||
]);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue