fix(ui): clarify visual status semantics (#348)

This commit is contained in:
AAA authored and GitHub committed 2026-08-31 02:07:51 +08:00
1 parent e47d06fcdd
commit c01303a491
14 files changed
+359 -38

No files matched your search

+11 -3
View File
@@ -1,4 +1,9 @@
import type { PeerSnapshot } from "../types";
import type { PeerSnapshot, QualityWarningKind } from "../types";
export interface QualityLimitationSummary {
message: string;
kind: QualityWarningKind;
}
export function formatPacketLossPercent(
value: number | null,
@@ -11,10 +16,13 @@ export function formatPacketLossPercent(
export function qualityLimitationSummary(
snapshots: readonly PeerSnapshot[],
): string | null {
): QualityLimitationSummary | null {
for (const snapshot of snapshots) {
if (snapshot.qualityWarning) {
return snapshot.qualityWarning;
return {
message: snapshot.qualityWarning,
kind: snapshot.qualityWarningKind ?? "other",
};
}
}
return null;
+128 -1
View File
@@ -22,6 +22,8 @@ export type ComicKind =
| "access-denied"
| "invalid-invite"
| "room-full"
| "bandwidth-limited"
| "encoder-limited"
| "warning";
export const COMIC_KINDS: readonly ComicKind[] = [
@@ -39,6 +41,8 @@ export const COMIC_KINDS: readonly ComicKind[] = [
"access-denied",
"invalid-invite",
"room-full",
"bandwidth-limited",
"encoder-limited",
"warning",
];
@@ -60,6 +64,8 @@ const DEFAULT_THEME: Record<ComicKind, ComicTheme> = {
"access-denied": "paper",
"invalid-invite": "paper",
"room-full": "paper",
"bandwidth-limited": "paper",
"encoder-limited": "paper",
warning: "paper",
};
@@ -1192,7 +1198,126 @@ ${rmBlock(
);
}
/** 15. warning: 1 wide panel, loop 2.6s. Persistent condition: the you-pawn
/** Native bandwidth adaptation: a roomy path narrows, then the delivered
* picture shrinks. The route owner is deliberately absent; this describes the
* Browser report without guessing which physical link is responsible. */
function SceneBandwidthLimited({ theme }: { theme: ComicTheme }) {
return (
<>
<style>{`
.vls-bw-flow-a{animation:vlsBwFlowA 2.8s ease-in-out infinite}
.vls-bw-flow-b{animation:vlsBwFlowB 2.8s ease-in-out infinite}
.vls-bw-throat{transform-box:fill-box;transform-origin:center;animation:vlsBwThroat 2.8s ease-in-out infinite}
.vls-bw-small{transform-box:fill-box;transform-origin:center;animation:vlsBwSmall 2.8s ease-in-out infinite}
@keyframes vlsBwFlowA{0%,12%{transform:translateX(-8px);opacity:0}26%,58%{transform:none;opacity:1}72%,100%{transform:translateX(8px);opacity:0}}
@keyframes vlsBwFlowB{0%,32%{transform:translateX(-7px);opacity:0}48%,72%{transform:none;opacity:1}86%,100%{transform:translateX(5px);opacity:0}}
@keyframes vlsBwThroat{0%,30%,100%{transform:scaleY(1)}48%,76%{transform:scaleY(.62)}}
@keyframes vlsBwSmall{0%,42%{transform:scale(1)}58%,100%{transform:scale(.82)}}
${rmBlock(
["vls-bw-flow-a", "vls-bw-flow-b", "vls-bw-throat", "vls-bw-small"],
[
[".vls-bw-flow-a,.vls-bw-flow-b", "opacity:1;transform:none"],
[".vls-bw-throat", "transform:scaleY(.62)"],
[".vls-bw-small", "transform:scale(.82)"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={WARN} />
<Floor x1={18} x2={142} />
<Floor x1={178} x2={302} />
<Pawn x={28} yb={76} s={7} eyes />
<path d="M45 48H90" stroke={SKY} strokeWidth={8} strokeLinecap="round" />
<g className="vls-bw-flow-a" fill={MINT}>
<circle cx={55} cy={48} r={3.5} />
<circle cx={68} cy={48} r={3.5} />
<circle cx={81} cy={48} r={3.5} />
</g>
<MiniTv x={94} y={28} w={44} h={36} />
<path
className="vls-bw-throat"
d="M181 38H208L226 45V53L208 60H181"
fill="none"
stroke={WARN}
strokeWidth={3}
strokeLinejoin="round"
/>
<path d="M226 49H250" stroke={WARN} strokeWidth={4} strokeLinecap="round" />
<g className="vls-bw-flow-b" fill={STAR_GOLD}>
<circle cx={192} cy={49} r={3.2} />
<circle cx={207} cy={49} r={3.2} />
<circle cx={239} cy={49} r={2.6} />
</g>
<g className="vls-bw-small">
<MiniTv x={254} y={37} w={34} h={28} />
</g>
<Pawn x={298} yb={76} s={6.5} eyes color={WARN} />
</>
);
}
/** Native CPU adaptation: frames queue at the encoder, one falls away, and
* the delivered picture shrinks. */
function SceneEncoderLimited({ theme }: { theme: ComicTheme }) {
return (
<>
<style>{`
.vls-en-frame-a{animation:vlsEnFlow 2.9s ease-in-out infinite}
.vls-en-frame-b{animation:vlsEnFlow 2.9s ease-in-out .3s infinite}
.vls-en-drop{transform-box:fill-box;transform-origin:center;animation:vlsEnDrop 2.9s ease-in infinite}
.vls-en-heat{animation:vlsEnHeat 2.9s ease-out infinite}
.vls-en-small{transform-box:fill-box;transform-origin:center;animation:vlsEnSmall 2.9s ease-in-out infinite}
@keyframes vlsEnFlow{0%,12%{transform:translateX(-7px);opacity:0}28%,60%{transform:none;opacity:1}76%,100%{transform:translateX(8px);opacity:0}}
@keyframes vlsEnDrop{0%,42%{transform:none;opacity:1}64%,100%{transform:translateY(16px) rotate(12deg);opacity:0}}
@keyframes vlsEnHeat{0%,34%{transform:translateY(3px);opacity:0}48%,72%{transform:none;opacity:1}86%,100%{opacity:0}}
@keyframes vlsEnSmall{0%,46%{transform:scale(1)}62%,100%{transform:scale(.82)}}
${rmBlock(
["vls-en-frame-a", "vls-en-frame-b", "vls-en-drop", "vls-en-heat", "vls-en-small"],
[
[".vls-en-frame-a,.vls-en-frame-b,.vls-en-heat", "opacity:1;transform:none"],
[".vls-en-drop", "opacity:0;transform:translateY(16px)"],
[".vls-en-small", "transform:scale(.82)"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} accent={WARN} />
<Floor x1={18} x2={142} />
<Floor x1={178} x2={302} />
<Pawn x={27} yb={76} s={6.5} eyes />
<g fill="none" stroke={SKY} strokeWidth={2.5}>
<rect x={52} y={34} width={36} height={28} rx={5} />
<path d="M58 30v4m8-4v4m8-4v4m8-4v4M58 62v4m8-4v4m8-4v4m8-4v4" />
</g>
<g className="vls-en-frame-a" fill={MINT}>
<rect x={96} y={38} width={8} height={8} rx={1.5} />
<rect x={108} y={38} width={8} height={8} rx={1.5} />
</g>
<MiniTv x={119} y={30} w={28} h={30} />
<g fill="none" stroke={WARN} strokeWidth={2.5}>
<rect x={184} y={34} width={38} height={28} rx={5} />
<path d="M190 30v4m8-4v4m8-4v4m8-4v4M190 62v4m8-4v4m8-4v4m8-4v4" />
</g>
<g className="vls-en-heat" stroke={WARN} strokeWidth={2} strokeLinecap="round">
<path d="M191 26l-3-6m13 6v-7m10 7l4-6" />
</g>
<g className="vls-en-frame-b" fill={STAR_GOLD}>
<rect x={229} y={35} width={8} height={8} rx={1.5} />
<rect x={241} y={35} width={8} height={8} rx={1.5} />
</g>
<g className="vls-en-drop">
<rect x={241} y={49} width={8} height={8} rx={1.5} fill={DANGER} />
<RedX cx={245} cy={53} arm={3} />
</g>
<g className="vls-en-small">
<MiniTv x={258} y={37} w={34} h={28} />
</g>
<Pawn x={301} yb={76} s={6} eyes color={WARN} />
</>
);
}
/** Generic warning: 1 wide panel, loop 2.6s. Persistent condition: the you-pawn
* stands beside the big Hearth coal-bowl warning glyph; heat-rays rise in a
* staggered 0-55% window, the pawn blinks at 70%, rest >=50%. */
function SceneWarning({ theme }: { theme: ComicTheme }) {
@@ -1260,6 +1385,8 @@ const SCENES: Record<ComicKind, (props: { theme: ComicTheme }) => ReactNode> = {
"access-denied": SceneAccessDenied,
"invalid-invite": SceneInvalidInvite,
"room-full": SceneRoomFull,
"bandwidth-limited": SceneBandwidthLimited,
"encoder-limited": SceneEncoderLimited,
warning: SceneWarning,
};
+27 -3
View File
@@ -3,21 +3,45 @@
import { VisGlyph } from "./primitives";
import { BrandMark } from "./BrandMark";
import { ComicTooltip } from "./ComicTooltip";
import type { ComicKind } from "./Comic";
import { useCopy, type Lang } from "../../ui/copy";
import { useTheme } from "../../ui/theme";
export type LedState = "live" | "busy" | "warn" | "bad" | "off";
export function LedStrip({ state, label }: { state: LedState; label: string }) {
export function LedStrip({
state,
label,
comic,
}: {
state: LedState;
label: string;
comic?: ComicKind;
}) {
const { vis } = useCopy();
return (
<span className="lr-leds" data-state={state} role="status" title={vis ? undefined : label} aria-label={label}>
const wrapped = vis && comic;
const strip = (
<span
className="lr-leds"
data-state={state}
role="status"
title={vis ? undefined : label}
aria-label={label}
tabIndex={wrapped ? 0 : undefined}
>
<i />
<i />
<i />
{vis ? null : <span className="lr-leds-label">{label}</span>}
</span>
);
return wrapped ? (
<ComicTooltip kind={wrapped} place="below" align="start">
{strip}
</ComicTooltip>
) : (
strip
);
}
export function HeaderControls() {
+41 -1
View File
@@ -1,9 +1,10 @@
// LCD room code chip with copy and in-place room replacement confirmation.
import { useEffect, useRef, useState } from "react";
import type { CodeEntryPolicy } from "../../../shared/protocol";
import { Glyph } from "../../ui/icons";
import { useCopy } from "../../ui/copy";
import { copyRoomCode } from "../room-code";
import { Btn } from "./primitives";
import { Btn, Pill } from "./primitives";
import { ComicTooltip } from "./ComicTooltip";
export function Lcd({ code }: { code: string }) {
@@ -151,3 +152,42 @@ export function RoomChip({
</>
);
}
export function RoomAdmissionBadge({
policy,
passwordEnabled,
}: {
policy: CodeEntryPolicy;
passwordEnabled: boolean;
}) {
const { t } = useCopy();
const presentation =
policy === "open"
? {
icon: "globe" as const,
label: "host.policy.currentOpen" as const,
tone: "good" as const,
comic: "hint-policy-open" as const,
}
: passwordEnabled
? {
icon: "key" as const,
label: "host.policy.currentPassword" as const,
tone: "good" as const,
comic: "hint-password" as const,
}
: {
icon: "lock" as const,
label: "host.policy.currentInvite" as const,
tone: undefined,
comic: "hint-policy-private" as const,
};
return (
<Pill
icon={presentation.icon}
tone={presentation.tone}
label={t(presentation.label)}
comic={presentation.comic}
/>
);
}
+1 -1
View File
@@ -207,7 +207,7 @@ export function Pill({
tone?: "bad" | "good";
label: string;
alert?: boolean;
comic?: ComicKind;
comic?: ComicKind | HintKind;
}) {
const { vis } = useCopy();
const wrapped = vis && comic;
+3
View File
@@ -106,6 +106,9 @@ export const en: Record<CopyKey, string> = {
"host.policy": "Admission",
"host.policy.open": "Open",
"host.policy.private": "Private",
"host.policy.currentOpen": "Room code entry",
"host.policy.currentPassword": "Password entry",
"host.policy.currentInvite": "Invite only",
"host.policy.openHint": "Anyone with the room code can join",
"host.policy.privateHint": "Invite link only, or set a room password",
"host.policy.setOpen": "Room is now open",
+3
View File
@@ -104,6 +104,9 @@ export const zh = {
"host.policy": "准入方式",
"host.policy.open": "公开",
"host.policy.private": "私密",
"host.policy.currentOpen": "房间号可加入",
"host.policy.currentPassword": "密码可加入",
"host.policy.currentInvite": "仅限邀请",
"host.policy.openHint": "知道房间号即可加入",
"host.policy.privateHint": "凭邀请链接加入,或设置房间密码",
"host.policy.setOpen": "房间已设为公开",
+32 -2
View File
@@ -26,7 +26,10 @@ import { AppHeader, LedStrip, type LedState } from "../components/living/Header"
import { Couch, type CouchEntry } from "../components/living/Couch";
import { useMetricsExpanded } from "../components/living/Metrics";
import { PawnDetail } from "../components/living/PawnDetail";
import { RoomChip } from "../components/living/RoomChip";
import {
RoomAdmissionBadge,
RoomChip,
} from "../components/living/RoomChip";
import { RouteTree } from "../components/living/RouteTree";
import {
ViewerOverview,
@@ -2634,6 +2637,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
<LedStrip
state={SIGNAL_LED_STATE[signalStatus]}
label={t(SIGNAL_LED_LABEL[signalStatus])}
comic={
signalStatus === "reconnecting" || signalStatus === "offline"
? "recovering"
: undefined
}
/>
}
/>
@@ -2863,6 +2871,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
onReplace={replaceCurrentRoom}
replaceDisabled={phase === "starting" || roomMutating}
/>
{activeCodeEntryPolicy ? (
<RoomAdmissionBadge
policy={activeCodeEntryPolicy}
passwordEnabled={viewerPasswordEnabled}
/>
) : null}
</RowGroup>
) : null}
<RowGroup>
@@ -2871,7 +2885,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
<Pill icon="speaker" label={t("host.noAudio")} comic="no-audio" />
) : null}
{qualityLimitation ? (
<Pill icon="alert" label={qualityLimitation} comic="warning" />
<Pill
icon="alert"
label={qualityLimitation.message}
comic={
qualityLimitation.kind === "bandwidth"
? "bandwidth-limited"
: qualityLimitation.kind === "cpu"
? "encoder-limited"
: "warning"
}
/>
) : null}
{noticeText && (vis || noticeText !== phaseLine) ? (
<Pill
@@ -3353,6 +3377,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
? "host.invite.emptyPassword"
: "host.invite.emptyPrivate",
)}
comic={
activeCodeEntryPolicy === "private" &&
!viewerPasswordEnabled
? "invalid-invite"
: undefined
}
/>
) : null}
</RowGroup>
+24 -18
View File
@@ -2499,8 +2499,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
{qualityLimitation ? (
<Pill
icon="alert"
label={qualityLimitation}
comic="warning"
label={qualityLimitation.message}
comic={
qualityLimitation.kind === "bandwidth"
? "bandwidth-limited"
: qualityLimitation.kind === "cpu"
? "encoder-limited"
: "warning"
}
/>
) : null}
</div>
@@ -2533,22 +2539,6 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
/>
</div>
<div className="lr-deck">
{selectedPawn !== null && selectedChildEvidence ? (
<PawnDetail
pawnKey={selectedPawn}
name={
viewers.find((viewer) => viewer.peerId === selectedPawn)
?.label ?? selectedPawn
}
route={null}
metrics={metricsFromQualityEvidence(selectedChildEvidence.evidence)}
direction="receive"
tag={{ icon: "arrowUp", label: t("stats.downstream") }}
expanded={pawnMetricsExpanded}
onToggleMetrics={setPawnMetricsExpanded}
onClose={() => setSelectedPawn(null)}
/>
) : null}
<div className="lr-row lr-viewer-summary-row">
<div className="lr-row-group lr-viewer-room-slot">
<FieldCap k="common.roomCode" />
@@ -2754,6 +2744,22 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
/>
</Row>
) : null}
{selectedPawn !== null && selectedChildEvidence ? (
<PawnDetail
pawnKey={selectedPawn}
name={
viewers.find((viewer) => viewer.peerId === selectedPawn)
?.label ?? selectedPawn
}
route={null}
metrics={metricsFromQualityEvidence(selectedChildEvidence.evidence)}
direction="receive"
tag={{ icon: "arrowUp", label: t("stats.downstream") }}
expanded={pawnMetricsExpanded}
onToggleMetrics={setPawnMetricsExpanded}
onClose={() => setSelectedPawn(null)}
/>
) : null}
{showTopology && labeledHostPresence ? (
<Row sub>
<RouteTree
+6
View File
@@ -6,6 +6,11 @@ export type SignalConnectionState =
export type MediaPath = "direct" | "unknown";
export type NativeEdgeQualityState = "unknown" | "healthy" | "degraded";
export type QualityWarningKind =
| "bandwidth"
| "cpu"
| "other"
| "configuration";
export interface ConnectionMetrics {
sampleTimestampMs: number | null;
@@ -88,6 +93,7 @@ export interface PeerSnapshot {
| import("./media/quality").AudioSenderParameterReadback
| null;
qualityWarning?: string | null;
qualityWarningKind?: QualityWarningKind | null;
}
export const EMPTY_METRICS: ConnectionMetrics = {
+28 -4
View File
@@ -25,6 +25,7 @@ import {
import {
EMPTY_METRICS,
type PeerSnapshot,
type QualityWarningKind,
} from "../types";
import {
captureMetrics,
@@ -114,6 +115,7 @@ export class HostPeer {
senderParameters: null,
audioSenderParameters: null,
qualityWarning: null,
qualityWarningKind: null,
};
this.bindConnectionEvents();
}
@@ -638,8 +640,7 @@ export class HostPeer {
this.snapshot = {
...this.snapshot,
metrics,
qualityWarning:
this.combinedSenderWarning() ?? this.persistentLimitationWarning(),
...this.qualityWarningSnapshot(),
};
this.emit();
} catch {
@@ -736,8 +737,7 @@ export class HostPeer {
...this.snapshot,
senderParameters,
audioSenderParameters: this.appliedAudioSenderParameters,
qualityWarning:
this.combinedSenderWarning() ?? this.persistentLimitationWarning(),
...this.qualityWarningSnapshot(),
};
this.emit();
return videoSucceeded && audioSucceeded;
@@ -785,6 +785,30 @@ export class HostPeer {
}
}
private qualityWarningSnapshot(): {
qualityWarning: string | null;
qualityWarningKind: QualityWarningKind | null;
} {
const configurationWarning = this.combinedSenderWarning();
if (configurationWarning) {
return {
qualityWarning: configurationWarning,
qualityWarningKind: "configuration",
};
}
const qualityWarning = this.persistentLimitationWarning();
if (!qualityWarning) {
return { qualityWarning: null, qualityWarningKind: null };
}
return {
qualityWarning,
qualityWarningKind:
this.limitationReason === "bandwidth" || this.limitationReason === "cpu"
? this.limitationReason
: "other",
};
}
private emit(): void {
if (this.disposed) {
return;
+16 -5
View File
@@ -4,9 +4,16 @@ import {
formatPacketLossPercent,
qualityLimitationSummary,
} from "../src/client/components/connection-details.ts";
import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types.ts";
import {
EMPTY_METRICS,
type PeerSnapshot,
type QualityWarningKind,
} from "../src/client/types.ts";
function snapshot(qualityWarning: string | null): PeerSnapshot {
function snapshot(
qualityWarning: string | null,
qualityWarningKind: QualityWarningKind | null = null,
): PeerSnapshot {
return {
peerId: "viewer-1",
connectionId: "connection-1",
@@ -15,6 +22,7 @@ function snapshot(qualityWarning: string | null): PeerSnapshot {
metrics: { ...EMPTY_METRICS, qualityLimitationReason: "bandwidth" },
error: null,
qualityWarning,
qualityWarningKind,
};
}
@@ -23,10 +31,13 @@ describe("progressive connection details", () => {
expect(
qualityLimitationSummary([
snapshot(null),
snapshot("当前连接带宽受限,画质已自动降低"),
snapshot("编码性能受限,画质已自动降低"),
snapshot("当前连接带宽受限,画质已自动降低", "bandwidth"),
snapshot("编码性能受限,画质已自动降低", "cpu"),
]),
).toBe("当前连接带宽受限,画质已自动降低");
).toEqual({
message: "当前连接带宽受限,画质已自动降低",
kind: "bandwidth",
});
expect(qualityLimitationSummary([snapshot(null)])).toBeNull();
});
+3
View File
@@ -1511,10 +1511,12 @@ describe("HostPeer source replacement", () => {
expect(updates.at(-1)?.qualityWarning).toBe(
"当前连接带宽受限,画质已自动降低",
);
expect(updates.at(-1)?.qualityWarningKind).toBe("bandwidth");
expect(sender.setParameters).toHaveBeenCalledTimes(2);
await sample();
expect(updates.at(-1)?.qualityWarning).toBeNull();
expect(updates.at(-1)?.qualityWarningKind).toBeNull();
expect(sender.setParameters).toHaveBeenCalledTimes(2);
});
@@ -1546,6 +1548,7 @@ describe("HostPeer source replacement", () => {
expect(updates.at(-1)?.qualityWarning).toBe(
"浏览器持续报告未分类的画质限制",
);
expect(updates.at(-1)?.qualityWarningKind).toBe("other");
});
it("rolls the first sender back when the second replacement fails", async () => {
+36
View File
@@ -11,6 +11,7 @@ import {
import { Comic } from "../src/client/components/living/Comic.tsx";
import { ComicTooltip } from "../src/client/components/living/ComicTooltip.tsx";
import { HintComic } from "../src/client/components/living/hints/index.tsx";
import { RoomAdmissionBadge } from "../src/client/components/living/RoomChip.tsx";
import { StageOverlay } from "../src/client/components/living/Stage.tsx";
import { ViewerOverview } from "../src/client/components/living/ViewerOverview.tsx";
import { Btn, NameTag } from "../src/client/components/living/primitives.tsx";
@@ -195,6 +196,41 @@ describe("living-room presentation", () => {
expect(html).toContain("vlsPrivLock");
});
it.each([
["open", false, "host.policy.currentOpen", true],
["private", true, "host.policy.currentPassword", true],
["private", false, "host.policy.currentInvite", false],
] as const)(
"renders %s/%s admission from one policy fact",
(policy, passwordEnabled, labelKey, good) => {
setCopy({ lang: "en", vis: true });
const html = renderToStaticMarkup(
createElement(RoomAdmissionBadge, { policy, passwordEnabled }),
);
expect(html).toContain(
`<span class="visually-hidden">${t("en", labelKey)}</span>`,
);
expect(html).toContain("lr-comic-tip-wrap");
expect(html).toContain("visually-hidden");
expect(html.includes("lr-pill is-good")).toBe(good);
},
);
it("uses distinct native quality-limit comics", () => {
const bandwidth = renderToStaticMarkup(
createElement(Comic, { kind: "bandwidth-limited", size: 320 }),
);
const encoder = renderToStaticMarkup(
createElement(Comic, { kind: "encoder-limited", size: 320 }),
);
expect(bandwidth).toContain("vls-bw-throat");
expect(bandwidth).toContain("vlsBwSmall");
expect(encoder).toContain("vls-en-drop");
expect(encoder).toContain("vlsEnHeat");
});
it("keeps the Host-offline comic free of the waiting-room moon", () => {
const html = renderToStaticMarkup(
createElement(Comic, { kind: "host-offline", theme: "stage" }),