feat(ui): polish living room presentation (#355)

This commit is contained in:
AAA authored and GitHub committed 2026-08-31 10:37:11 +08:00
1 parent e79361d681
commit 1e5c77d916
7 files changed
+158 -36

No files matched your search

+22 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState, type AnimationEvent } from "react";
import { bindSvgReplayOnPointerEnter } from "../../ui/animation";
export type BrandMotion = "static" | "once" | "loop";
@@ -11,15 +11,30 @@ export function BrandMark({
motion?: BrandMotion;
}) {
const animated = motion !== "static";
const [loopPhase, setLoopPhase] = useState<"intro" | "active">("intro");
const motionClass =
motion === "loop" ? " is-animated" : motion === "once" ? " is-once" : "";
motion === "loop"
? ` is-animated is-loop-${loopPhase}`
: motion === "once"
? " is-once"
: "";
const markRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (motion === "loop") setLoopPhase("intro");
}, [motion]);
useEffect(() => {
if (motion !== "once" || !markRef.current) return;
return bindSvgReplayOnPointerEnter(markRef.current);
}, [motion]);
const finishLoopIntro = (event: AnimationEvent<SVGPathElement>): void => {
if (motion === "loop" && event.animationName === "lr-brand-sparkle-alt") {
setLoopPhase("active");
}
};
return (
<svg
ref={markRef}
@@ -65,7 +80,11 @@ export function BrandMark({
</g>
<g className="lr-brand-sparkles">
<path pathLength={1} d="m25 11.5 .8 1.9 1.9.8-1.9.8-.8 1.9-.8-1.9-1.9-.8 1.9-.8.8-1.9Z" />
<path pathLength={1} d="m25 17.2 .45 1.1 1.1.45-1.1.45-.45 1.1-.45-1.1-1.1-.45 1.1-.45.45-1.1Z" />
<path
pathLength={1}
d="m25 17.2 .45 1.1 1.1.45-1.1.45-.45 1.1-.45-1.1-1.1-.45 1.1-.45.45-1.1Z"
onAnimationEnd={finishLoopIntro}
/>
</g>
</svg>
);
+21 -4
View File
@@ -28,7 +28,7 @@ interface TreeNode {
const ROW_BASE = 40;
const DEFAULT_TOPOLOGY_WIDTH = 640;
const PAWN_CENTER_X = 20;
const PAWN_SCALE = 0.72;
const PAWN_SCALE = 0.82;
const PAWN_CENTER_Y = 24;
const LABEL_GAP = 12;
@@ -75,6 +75,20 @@ function compactVisibleLabel(label: string, maximumCodePoints: number): string {
return `${codePoints.slice(0, maximumCodePoints - 1).join("")}…`;
}
function topologyVisibleLabel(
label: string,
peerId: string | null,
maximumCodePoints: number,
): string {
const peerIdSuffix = peerId?.slice(-6) ?? "";
const withoutRedundantRole =
peerIdSuffix &&
(label === `👑-${peerIdSuffix}` || label === `👤-${peerIdSuffix}`)
? peerIdSuffix
: label;
return compactVisibleLabel(withoutRedundantRole, maximumCodePoints);
}
function xForDepth(depth: number, layout: TopologyLayout): number {
return layout.hostX + (depth + 1) * layout.columnGap;
}
@@ -355,8 +369,9 @@ export const RouteTree = memo(function RouteTree({
y={pawnLabelY(hostPos.y, PAWN_SCALE)}
textAnchor="middle"
>
{compactVisibleLabel(
{topologyVisibleLabel(
hostLabel,
hostPeerId,
layoutConfig.maxVisibleLabelCodePoints,
)}
</text>
@@ -438,8 +453,9 @@ export const RouteTree = memo(function RouteTree({
y={pawnLabelY(point.y, PAWN_SCALE)}
textAnchor="middle"
>
{compactVisibleLabel(
{topologyVisibleLabel(
node.label,
node.key,
layoutConfig.maxVisibleLabelCodePoints,
)}
</text>
@@ -453,8 +469,9 @@ export const RouteTree = memo(function RouteTree({
y={pawnLabelY(point.y, PAWN_SCALE)}
textAnchor="middle"
>
{compactVisibleLabel(
{topologyVisibleLabel(
point.viewer.label,
point.viewer.peerId,
layoutConfig.maxVisibleLabelCodePoints,
)}
</text>
@@ -23,11 +23,13 @@ export function topologyLayoutForWidth(containerWidth: number): TopologyLayout {
const progress =
(baseWidth - MIN_LAYOUT_WIDTH) /
(MAX_LAYOUT_WIDTH - MIN_LAYOUT_WIDTH);
const columnGap = interpolate(130, 220, progress);
const hostX = (baseWidth - columnGap) / 2;
return {
baseWidth,
hostX: interpolate(54, 170, progress),
columnGap: interpolate(130, 220, progress),
rightLabelReserve: interpolate(65, 170, progress),
hostX,
columnGap,
rightLabelReserve: hostX,
maxVisibleLabelCodePoints:
baseWidth < 420 ? 10 : baseWidth < 560 ? 13 : 16,
};
+4
View File
@@ -2633,6 +2633,10 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
)}
</form>
<div className="lr-row-group lr-group-actions lr-viewer-actions-slot">
<span
className="lr-viewer-action-separator"
aria-hidden="true"
/>
<span className="lr-viewer-action-cluster">
<Btn
icon={theaterMode ? "contract" : "expand"}
+49 -23
View File
@@ -139,12 +139,12 @@ ul { margin: 0; padding: 0; list-style: none; }
transform-box: fill-box;
transform-origin: center;
}
.lr-brand-mark.is-animated {
animation: lr-brand-bob 3.6s cubic-bezier(.3, .8, .35, 1) infinite;
}
.lr-brand-mark.is-once {
.lr-brand-mark:is(.is-animated.is-loop-intro, .is-once) {
animation: lr-brand-bob 1.6s cubic-bezier(.3, .8, .35, 1) 1 both;
}
.lr-brand-mark.is-animated.is-loop-active {
animation: lr-brand-bob-loop 3.6s cubic-bezier(.3, .8, .35, 1) infinite;
}
.lr-brand-mark:is(.is-animated, .is-once) .lr-brand-shell > * {
stroke-dasharray: 1;
stroke-dashoffset: 1;
@@ -164,39 +164,39 @@ ul { margin: 0; padding: 0; list-style: none; }
transform-origin: center;
animation: lr-brand-face-in 180ms ease-out 500ms both;
}
.lr-brand-mark.is-animated .lr-brand-eye-open {
.lr-brand-mark.is-animated.is-loop-active .lr-brand-eye-open {
transform-box: fill-box;
transform-origin: center;
animation: lr-brand-eye-open 3.6s ease-in-out infinite;
animation: lr-brand-eye-open-loop 3.6s ease-in-out infinite;
}
.lr-brand-mark.is-animated .lr-brand-eye-wink {
animation: lr-brand-eye-wink 3.6s ease-in-out infinite;
.lr-brand-mark.is-animated.is-loop-active .lr-brand-eye-wink {
animation: lr-brand-eye-wink-loop 3.6s ease-in-out infinite;
}
.lr-brand-mark.is-animated .lr-brand-sparkles { opacity: 1; }
.lr-brand-mark.is-animated .lr-brand-sparkles > * {
.lr-brand-mark.is-animated.is-loop-active .lr-brand-sparkles { opacity: 1; }
.lr-brand-mark.is-animated.is-loop-active .lr-brand-sparkles > * {
transform-box: fill-box;
transform-origin: center;
animation: lr-brand-sparkle 3.6s cubic-bezier(.3, 1.45, .45, 1) infinite both;
animation: lr-brand-sparkle-loop 3.6s cubic-bezier(.3, 1.45, .45, 1) infinite both;
}
.lr-brand-mark.is-animated .lr-brand-sparkles > :nth-child(2) {
animation-name: lr-brand-sparkle-alt;
.lr-brand-mark.is-animated.is-loop-active .lr-brand-sparkles > :nth-child(2) {
animation-name: lr-brand-sparkle-alt-loop;
animation-delay: 90ms;
}
.lr-brand-mark.is-once .lr-brand-eye-open {
.lr-brand-mark:is(.is-animated.is-loop-intro, .is-once) .lr-brand-eye-open {
transform-box: fill-box;
transform-origin: center;
animation: lr-brand-eye-open 1.6s ease-in-out 1 both;
}
.lr-brand-mark.is-once .lr-brand-eye-wink {
.lr-brand-mark:is(.is-animated.is-loop-intro, .is-once) .lr-brand-eye-wink {
animation: lr-brand-eye-wink 1.6s ease-in-out 1 both;
}
.lr-brand-mark.is-once .lr-brand-sparkles { opacity: 1; }
.lr-brand-mark.is-once .lr-brand-sparkles > * {
.lr-brand-mark:is(.is-animated.is-loop-intro, .is-once) .lr-brand-sparkles { opacity: 1; }
.lr-brand-mark:is(.is-animated.is-loop-intro, .is-once) .lr-brand-sparkles > * {
transform-box: fill-box;
transform-origin: center;
animation: lr-brand-sparkle 1.6s cubic-bezier(.3, 1.45, .45, 1) 1 both;
}
.lr-brand-mark.is-once .lr-brand-sparkles > :nth-child(2) {
.lr-brand-mark:is(.is-animated.is-loop-intro, .is-once) .lr-brand-sparkles > :nth-child(2) {
animation-name: lr-brand-sparkle-alt;
animation-delay: 45ms;
}
@@ -206,6 +206,11 @@ ul { margin: 0; padding: 0; list-style: none; }
26% { transform: translateY(-.8px) rotate(-2deg); }
32% { transform: translateY(.8px) rotate(1.5deg); }
}
@keyframes lr-brand-bob-loop {
0%, 20%, 42%, 100% { transform: translateY(0) rotate(0); }
26% { transform: translateY(-.8px) rotate(-2deg); }
32% { transform: translateY(.8px) rotate(1.5deg); }
}
@keyframes lr-brand-face-in {
from { opacity: 0; transform: scale(.35); }
to { opacity: 1; transform: scale(1); }
@@ -219,6 +224,16 @@ ul { margin: 0; padding: 0; list-style: none; }
38% { opacity: 1; stroke-dasharray: 1; stroke-dashoffset: 1; }
44%, 100% { opacity: 1; stroke-dasharray: 1; stroke-dashoffset: 0; }
}
@keyframes lr-brand-eye-open-loop {
0%, 34%, 84%, 100% { opacity: 1; transform: scale(1); }
38%, 76% { opacity: 0; transform: scaleY(.15); }
}
@keyframes lr-brand-eye-wink-loop {
0%, 34% { opacity: 0; stroke-dasharray: 1; stroke-dashoffset: 1; }
38% { opacity: 1; stroke-dasharray: 1; stroke-dashoffset: 1; }
44%, 76% { opacity: 1; stroke-dasharray: 1; stroke-dashoffset: 0; }
84%, 100% { opacity: 0; stroke-dasharray: 1; stroke-dashoffset: 1; }
}
@keyframes lr-brand-sparkle {
0%, 42% { opacity: 0; transform: translate(-1px, 2px) scale(.12) rotate(-20deg); }
48% { opacity: 1; transform: translate(0, 0) scale(.85) rotate(0); }
@@ -233,6 +248,20 @@ ul { margin: 0; padding: 0; list-style: none; }
74% { opacity: .3; transform: translate(8px, 8px) scale(.5) rotate(-30deg); }
82%, 100% { opacity: 0; transform: translate(11px, 11px) scale(.18) rotate(-42deg); }
}
@keyframes lr-brand-sparkle-loop {
0%, 42% { opacity: 0; transform: translate(-1px, 2px) scale(.12) rotate(-20deg); }
48% { opacity: 1; transform: translate(0, 0) scale(.85) rotate(0); }
60% { opacity: .9; transform: translate(5px, -5px) scale(1) rotate(18deg); }
74% { opacity: .35; transform: translate(9px, -9px) scale(.55) rotate(34deg); }
82%, 100% { opacity: 0; transform: translate(12px, -12px) scale(.2) rotate(48deg); }
}
@keyframes lr-brand-sparkle-alt-loop {
0%, 42% { opacity: 0; transform: translate(-1px, -1px) scale(.12) rotate(18deg); }
48% { opacity: .95; transform: translate(0, 0) scale(.75) rotate(0); }
60% { opacity: .8; transform: translate(4px, 4px) scale(.95) rotate(-16deg); }
74% { opacity: .3; transform: translate(8px, 8px) scale(.5) rotate(-30deg); }
82%, 100% { opacity: 0; transform: translate(11px, 11px) scale(.18) rotate(-42deg); }
}
.lr-brand-sparkles { stroke: #d08a22; }
html[data-theme="dark"] .lr-brand-sparkles { stroke: #f4c14d; }
@@ -660,8 +689,7 @@ body.lr-theater-open { overflow: hidden; }
.lr-host-diagnostics-slot,
.lr-host-share-slot { flex: 0 0 auto; }
.lr-host-diagnostics-slot,
.lr-host-share-slot,
.lr-viewer-actions-slot {
.lr-host-share-slot {
padding-inline-start: 10px;
border-inline-start: 2.5px solid var(--wall-2);
}
@@ -1083,7 +1111,7 @@ html[data-theme="dark"] .lr-meter-cell svg { color: #7d92a8; }
scrollbar-color: var(--route-sfu) transparent;
scrollbar-width: thin;
}
.lr-route svg { display: block; width: 100%; max-width: 880px; height: auto; margin: 0 auto; }
.lr-route > svg { display: block; width: 100%; max-width: 880px; height: auto; margin: 0 auto; }
.lr-route:focus-visible { border-radius: 10px; }
.lr-route-edge {
fill: none;
@@ -1101,8 +1129,6 @@ html[data-theme="dark"] .lr-meter-cell svg { color: #7d92a8; }
animation: lr-flow 1.1s linear infinite;
}
.lr-route-node {
transform-box: fill-box;
transform-origin: center;
transition: opacity 180ms ease;
}
.lr-route-node.is-recovering { opacity: .58; }
+1 -1
View File
@@ -43,7 +43,7 @@ describe("living-room presentation", () => {
expect(mark).toContain("lr-brand-eye-wink");
expect(mark).not.toContain("is-animated");
expect(loader).toContain('class="lr-brand-loader"');
expect(loader).toContain("lr-brand-mark is-animated");
expect(loader).toContain("lr-brand-mark is-animated is-loop-intro");
expect(loader).toContain('aria-hidden="true"');
});
+56 -2
View File
@@ -39,7 +39,7 @@ describe("RouteTree", () => {
expect(narrow).toEqual({
baseWidth: 260,
hostX: 54,
hostX: 65,
columnGap: 130,
rightLabelReserve: 65,
maxVisibleLabelCodePoints: 10,
@@ -51,7 +51,19 @@ describe("RouteTree", () => {
narrow.hostX + narrow.columnGap * 3 + narrow.rightLabelReserve,
).toBeGreaterThan(narrow.baseWidth);
expect(desktop.baseWidth).toBe(640);
expect(desktop.hostX).toBe(210);
expect(desktop.columnGap).toBe(220);
expect(desktop.rightLabelReserve).toBe(210);
});
it("derives symmetric topology insets at every responsive width", () => {
for (const width of [260, 390, 520, 640]) {
const layout = topologyLayoutForWidth(width);
expect(layout.hostX).toBe(layout.rightLabelReserve);
expect(
layout.hostX * 2 + layout.columnGap,
).toBe(layout.baseWidth);
}
});
it("keeps topology geometry continuous across the former viewport breakpoint", () => {
@@ -204,6 +216,48 @@ describe("RouteTree", () => {
expect(html).not.toContain("<title");
});
it("centers visual default IDs without repeating the pawn role", () => {
const hostPeerId = "host-abc123";
const viewerPeerId = "viewer-def456";
const { host, viewers } = labelParticipantSnapshot([
{
role: "host",
peerId: hostPeerId,
displayName: "👑-abc123",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: viewerPeerId,
displayName: "👤-def456",
upstream: { kind: "peer", peerId: hostPeerId },
mediaReady: true,
},
{
role: "viewer",
peerId: "viewer-custom789",
displayName: "👤-custom",
upstream: { kind: "peer", peerId: hostPeerId },
mediaReady: true,
},
]);
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "Host",
viewers,
}),
);
expect(html).toContain(">abc123</text>");
expect(html).toContain(">def456</text>");
expect(html).toContain(">👤-custom</text>");
expect(html).not.toContain(">👑-abc123</text>");
expect(html).not.toContain(">👤-def456</text>");
expect(html).toContain("scale(0.82)");
});
it("uses the Host identity color in the topology", () => {
const hostPeerId = "host-color-identity";
const html = renderToStaticMarkup(
@@ -374,7 +428,7 @@ describe("RouteTree", () => {
);
const width = Number(html.match(/viewBox="0 0 (\d+) /)?.[1]);
expect(width).toBeGreaterThan(640);
expect(width).toBe(1_520);
expect(html).toContain(`style="width:${width}px;max-width:none"`);
expect(
new Set(