feat(ui): polish living room presentation (#355)
This commit is contained in:
1 parent
e79361d681
commit
1e5c77d916
7 files changed
+158
-36
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user