fix(ui): complete illustrated guidance and launcher defaults

Some room-entry failures still replaced the existing comics with standalone icons in Chinese and English, and status help required a touch hold. These pages now share the same illustrated scenes with localized explanations. Help-only indicators use native buttons and the shared Tooltip owner for click/tap, hover, keyboard and dismissal; action controls retain their actions and long-press guidance.

The App preselects public invitations when no Site is saved, while retaining a saved Site's priority. Launcher metadata and its consumer agree on the existing link mode. The release guide now requires explicit owner approval of each planned version and complete notes before publication.

## Release notes

### 中文

- 补齐中英文的房间错误页、等待状态和操作提示图解,纯视觉模式继续保留。
- 电视下方状态、连接指示灯和说明提示支持点击或轻触展开;再次点击、点外部或按 Esc 可关闭,悬停和键盘操作继续可用。
- App 未保存站点时默认选中“公网邀请”,已保存站点仍优先显示。
- 统一无音频图标,并完善系统“减少动态效果”设置下的显示。

### English

- Completed illustrated guidance for room-entry errors, waiting states and controls in Chinese and English. Pure-visual mode remains available.
- Picture status, connection indicators and informational notices now open on click or tap. Click again, tap outside or press Escape to dismiss; hover and keyboard guidance remain available.
- The App now preselects public invitations when no Site is saved. A saved Site retains priority.
- Made no-audio icons consistent and improved presentation with the system's reduced-motion setting.

## Verification

- `npm run check`: TypeScript, 59 test files / 776 tests and the production Web build passed.
- `npm run check:client-core`: Go tests, formatting, vet and supported local target builds passed; native macOS packaging remains on its macOS runner.
- Existing Launcher HTTP tests verify both the public-link default and saved-Site priority. The browser checked both corresponding metadata responses.
- 27 focused browser checks passed for native touch events, short tap, long press, drag cancellation, action preservation, keyboard dismissal and 260/390px entry layouts.
- 46 overlay combinations passed across Chinese, English, pure-visual mode and 260/390/1280px, including keyboard playback and reduced motion.
- A real local Server returned the missing-room state correctly in all three modes. Status-control accessibility checks, documentation and repository hygiene passed.

Compatible patch. No signaling, native-control, capture or storage contract changes. Existing physical device/network deferrals remain in force.
This commit is contained in:
AAA authored and GitHub committed 2026-09-13 02:20:33 +08:00
1 parent f2b8a96c04
commit a7a0eea336
21 files changed
+131 -144

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ fix.
2. Update only the single durable owner for changed semantics and any materially changed current snapshot. If semantics remain disputed, record a hold and stop dependent work.
3. Implement and run repository hygiene, `npm run check`, the relevant browser/network gates, and review in proportion to the whole acceptance boundary.
4. Keep accepted truth checkpoints and dependent candidates on branches until the phase is complete. Rebase or rebuild a retained candidate from exact `main` only when starting a new phase, preserving main's owning truth on conflicts and transplanting only approved scoped code, tests, and new facts.
5. Open one pull request for the complete phase, resolve required review/checks, squash-merge it once, deploy that merged revision when authorized, perform scoped postflight, then audit references and reparse safety before cleanup.
5. Open one pull request for the complete phase, resolve required review/checks, meet the [release approval boundary](./docs/reference/versioning.md#automatic-publication) before a publishing merge, squash-merge it once, deploy that merged revision when authorized, perform scoped postflight, then audit references and reparse safety before cleanup.
One coherent phase should leave one meaningful squash commit on `main`. Include
its source, tests, owned semantic documentation, and materially changed status
+7 -1
View File
@@ -39,7 +39,13 @@ order and interaction ownership consistent across languages, themes and viewport
sizes. Reflow or bounded scrolling must not hide a primary action, truncate an
essential value, overlap controls or change meaning.
Tooltips and state overlays reuse the same illustrative scene in every mode.
Help-only indicators toggle their guidance on click or tap and dismiss on a
second activation, outside tap, Escape or focus leaving. Action controls keep
their direct click action and offer touch guidance on long-press. `Tooltip`
owns both interactions; pages do not add their own open state or timers.
Tooltips, state overlays and entry/error pages reuse the same illustrative scene
in every mode.
Chinese and English add a concise caption beneath it; pure-visual mode keeps the
scene and its playful pictograms as an explicit alternative. Names, addresses and other literal
values without a corresponding scene remain text. Illustration and caption
+5 -1
View File
@@ -74,9 +74,13 @@ schema field. The user settings are:
| Setting | Contract |
| --- | --- |
| `site` | Saved Piik Site origin; omit it for Local mode. The launcher or `--site` updates it. |
| `site` | Saved Piik Site origin. The launcher or `--site` updates it. |
| `localAccessPassword` | Empty by default. Optional password for the App's Local room authority, with the same exact-match behavior as `SITE_ACCESS_PASSWORD`. It is separate from a hosted site's password. |
The launcher preselects a saved Site when present; otherwise it preselects the
public invitation link. Selecting a mode does not start it: the user confirms
with the launch button. Local mode remains available for the same network.
Command-line options select entry and local runtime behavior:
| Option | Purpose |
+9 -1
View File
@@ -1,6 +1,6 @@
# Versions And Compatibility
Reviewed 2026-09-12. `v1.0.0` is the declared public compatibility baseline.
Reviewed 2026-09-13. `v1.0.0` is the declared public compatibility baseline.
App, Server and the embedded Web build share one product version, plus the full
Git SHA for source provenance. [Status](../status.md) owns the current publication
state; automatic publication follows the product-change boundary below.
@@ -110,6 +110,14 @@ identity separately. Stable update notices ignore prereleases.
## Automatic Publication
Before a product-changing merge or manual run that can publish, present the
planned version and complete Chinese/English release notes to the owner and
obtain explicit authorization for that release. Implementation or UI acceptance
does not authorize publication. Each later release needs fresh confirmation;
previous release/deployment permission is not standing authority. Once a release
is authorized, its packaging, mirror retries and matching deployments may proceed
within that scope without asking again. Automation executes the approved release.
After one-time activation, a complete squash PR merged to main runs the existing
CI pipeline. `scripts/release-version.mjs` filters unreleased first-parent
commits by actual changed paths. Website/film, documentation, research, agent
+1 -1
View File
@@ -207,7 +207,7 @@ func (server *Server) handleState(response http.ResponseWriter, request *http.Re
if server.site != "" {
return ModeSite
}
return ModeLocal
return ModeLink
}(),
})
}
+2 -1
View File
@@ -96,10 +96,11 @@ func TestLauncherIncludesTheInjectedBuildVersionAndRevision(t *testing.T) {
Version string `json:"version"`
Revision string `json:"revision"`
PackageTarget string `json:"packageTarget"`
DefaultMode Mode `json:"defaultMode"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&state) != nil ||
state.Version != version || state.Revision != revision ||
state.PackageTarget != runtime.GOOS+"-"+runtime.GOARCH {
state.PackageTarget != runtime.GOOS+"-"+runtime.GOARCH || state.DefaultMode != ModeLink {
t.Fatalf("launcher build = %d, %+v", response.StatusCode, state)
}
}
+3 -15
View File
@@ -24,7 +24,7 @@ import { AppHeader } from "./components/living/Header";
import { BrandLoader } from "./components/living/BrandMark";
import { Btn, Pill } from "./components/living/primitives";
import { Comic, type ComicKind } from "./components/living/Comic";
import { Glyph, type GlyphName } from "./ui/icons";
import { Glyph } from "./ui/icons";
import { setCopy, useCopy } from "./ui/copy";
import { initTheme } from "./ui/theme";
import { installBrowserDebug } from "./lib/debug";
@@ -132,7 +132,6 @@ class RouteBoundary extends Component<
render(): ReactNode {
return this.state.failed ? (
<StaticRoute
icon="alert"
comic="warning"
titleKey="gate.unavailableRoute"
action="reload"
@@ -177,14 +176,13 @@ function AppRoute() {
}
return appRoute.kind === "malformed-room" ? (
<StaticRoute
icon="door"
comic="room-not-found"
titleKey="gate.malformed"
hintKey="gate.malformedHint"
action="join"
/>
) : (
<StaticRoute icon="alert" comic="warning" titleKey="gate.unavailableRoute" />
<StaticRoute comic="warning" titleKey="gate.unavailableRoute" />
);
}
@@ -215,13 +213,11 @@ function RouteLoader() {
}
function StaticRoute({
icon,
comic,
titleKey,
hintKey,
action,
}: {
icon: GlyphName;
comic: ComicKind;
titleKey: "gate.malformed" | "gate.unavailableRoute";
hintKey?: "gate.malformedHint";
@@ -233,15 +229,7 @@ function StaticRoute({
<AppHeader />
<main className="lr-join">
<div className="lr-join-panel">
{vis ? (
// The scene states this exact situation, so the meaning needs no
// hover: a tooltip trigger would leave it pointer-only.
<Comic kind={comic} theme="paper" />
) : (
<span className="lr-tv-big" style={{ borderColor: "var(--ink)", color: "var(--ink)", background: "var(--paper)" }}>
<Glyph name={icon} size={30} />
</span>
)}
<Comic kind={comic} theme="paper" />
{vis ? null : (
<div className="lr-access-text">
<h1>{t(titleKey)}</h1>
@@ -143,11 +143,9 @@ export function CaptureSourcePicker({
{refreshButton}
</Tooltip>
)}
{vis ? closeButton : (
<Tooltip text={t("common.cancel")} place="below" align="end">
{closeButton}
</Tooltip>
)}
<Tooltip kind="hint-close" text={vis ? undefined : t("common.cancel")} place="below" align="end">
{closeButton}
</Tooltip>
</header>
<div
+12 -10
View File
@@ -23,26 +23,28 @@ export function LedStrip({
}) {
const { vis } = useCopy();
const wrapped = !vis || comic;
const Trigger = wrapped ? "button" : "span";
const strip = (
<span
<Trigger
type={wrapped ? "button" : undefined}
className="lr-leds"
data-state={state}
role="status"
aria-label={label}
tabIndex={wrapped ? 0 : undefined}
>
<i />
<i />
<i />
{vis ? null : <span className="lr-leds-label">{label}</span>}
</span>
</Trigger>
);
return wrapped ? (
<Tooltip kind={comic} tone={state} text={vis ? undefined : label} place="below" align="start">
{strip}
</Tooltip>
) : (
strip
return (
<span role="status">
{wrapped ? (
<Tooltip toggleOnClick kind={comic} tone={state} text={vis ? undefined : label} place="below" align="start">
{strip}
</Tooltip>
) : strip}
</span>
);
}
@@ -168,6 +168,7 @@ export function LauncherForm({
cap="client.launch.go"
tone="primary"
type="submit"
hint={MODES.find((choice) => choice.mode === mode)?.comic}
disabled={mode === "site" && !site.trim()}
/>
</form>
+2 -2
View File
@@ -102,14 +102,14 @@ export function StageOverlay({
const { vis } = useCopy();
const showMascot =
transition ||
(vis && Boolean(comic) &&
(Boolean(comic) &&
(Boolean(spin) || comic === "waiting-for-host" || comic === "recovering"));
const content = (
<>
{comic ? <Comic kind={comic} theme="stage" tone={tone}
motion={transition || spin ? "progress" : undefined} /> : null}
<span className="lr-tv-status-content">
{(vis || !comic || onActivate) && (showMascot ? (
{(showMascot || vis || !comic || onActivate) && (showMascot ? (
<BrandLoader />
) : (
<span className={`lr-tv-big${spin ? " lr-spin" : ""}${onActivate ? " is-action is-ripple" : ""}`}>
@@ -12,20 +12,22 @@ export function StatusIndicator({ status, label }: {
const { vis, t } = useCopy();
const hint = status.tooltip ?? status.comic;
const text = label ?? t(status.labelKey);
const wrapped = !vis || Boolean(hint);
const Trigger = wrapped ? "button" : "span";
const indicator = (
<span
<Trigger
type={wrapped ? "button" : undefined}
className="lr-status-indicator"
data-tone={status.tone}
data-pulse={status.pulse || undefined}
role="img"
role={wrapped ? undefined : "img"}
aria-label={text}
tabIndex={!vis || hint ? 0 : undefined}
>
<Glyph name={status.icon} size={17} />
</span>
</Trigger>
);
return !vis || hint
? <Tooltip kind={hint} tone={status.tone} motion={status.pulse ? "progress" : undefined}
return wrapped
? <Tooltip toggleOnClick kind={hint} tone={status.tone} motion={status.pulse ? "progress" : undefined}
text={vis ? undefined : text}>{indicator}</Tooltip>
: indicator;
}
+39 -16
View File
@@ -1,8 +1,8 @@
// Shared tooltip: the same comic in every mode, with a localized caption in
// text modes. Literal names and values can stand alone. Visibility has three channels:
// hover on fine pointers, keyboard focus via :has(:focus-visible), and the
// Material long-press on touch (500ms hold → open, ~1.5s after release →
// auto-hide, context menu and the trailing synthetic click suppressed).
// text modes. Literal names and values can stand alone. Hover and keyboard
// focus show guidance; help-only controls also toggle it on click/tap.
// Action controls keep their click and use a 500ms touch hold for guidance,
// hiding 1.5s after release and suppressing the trailing synthetic click.
// Alignment: the align prop is a desktop-tuned preference; whenever a show
// channel opens, the trigger's live viewport position is measured and
// start/center/end is re-picked so the panel never clips off-screen (rows
@@ -45,6 +45,7 @@ export function Tooltip({
className,
place = "above",
align = "center",
toggleOnClick = false,
children,
}: {
kind?: ComicKind | HintKind;
@@ -56,6 +57,8 @@ export function Tooltip({
place?: "above" | "below";
/** Preferred alignment; re-picked at open time if it would clip off-screen. */
align?: Align;
/** For help-only buttons; action controls keep click and touch long-press. */
toggleOnClick?: boolean;
children: ReactNode;
}) {
const defaults = getComicPresentation(kind);
@@ -71,17 +74,19 @@ export function Tooltip({
const pressPoint = useRef<{ x: number; y: number } | null>(null);
const [hoverOpen, setHoverOpen] = useState(false);
const [focusOpen, setFocusOpen] = useState(false);
const [touchOpen, setTouchOpen] = useState(false);
const [pressOpen, setPressOpen] = useState(false);
const [panelMounted, setPanelMounted] = useState(false);
const [position, setPosition] = useState({ left: 0, top: 0, caret: 0, below: place === "below" });
// True while the current gesture is a touch, so contextmenu can tell a
// long-press from a mouse right-click without reading vendor event fields.
const touchGesture = useRef(false);
const interactionOpen = hoverOpen || focusOpen || touchOpen;
const interactionOpen = hoverOpen || focusOpen || pressOpen;
const trigger = isValidElement<{
disabled?: boolean;
"aria-label"?: string;
"aria-describedby"?: string;
"aria-expanded"?: boolean;
"aria-controls"?: string;
label?: string;
title?: string;
}>(children) ? children : null;
@@ -194,7 +199,7 @@ export function Tooltip({
const dismiss = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setHoverOpen(false);
setTouchOpen(false);
setPressOpen(false);
setFocusOpen(false);
};
document.addEventListener("keydown", dismiss);
@@ -226,17 +231,19 @@ export function Tooltip({
[],
);
// While long-press-open, any tap elsewhere dismisses (WCAG dismissible).
// A click/touch-open hint stays until dismissed or focus leaves its trigger.
useEffect(() => {
if (!touchOpen) return;
if (!pressOpen) return;
const dismiss = (event: PointerEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) {
setTouchOpen(false);
setPressOpen(false);
setHoverOpen(false);
setFocusOpen(false);
}
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [touchOpen]);
}, [pressOpen]);
const cancelPress = () => {
if (pressTimer.current !== null) {
@@ -249,7 +256,7 @@ export function Tooltip({
return (
<span
ref={wrapRef}
className={`lr-comic-tip-wrap${className ? ` ${className}` : ""}${disabledTrigger ? " is-disabled-trigger" : ""}${hoverOpen ? " is-hover-open" : ""}${focusOpen ? " is-focus-open" : ""}${touchOpen ? " is-tip-open" : ""}`}
className={`lr-comic-tip-wrap${className ? ` ${className}` : ""}${disabledTrigger ? " is-disabled-trigger" : ""}${hoverOpen ? " is-hover-open" : ""}${focusOpen ? " is-focus-open" : ""}${pressOpen ? " is-tip-open" : ""}`}
tabIndex={disabledTrigger ? 0 : undefined}
aria-label={disabledTriggerLabel}
aria-describedby={disabledTrigger && text && interactionOpen ? tooltipId : undefined}
@@ -273,6 +280,7 @@ export function Tooltip({
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setFocusOpen(false);
if (toggleOnClick) setPressOpen(false);
}
}}
onPointerDown={(event) => {
@@ -282,13 +290,14 @@ export function Tooltip({
// completed long-press. A later touch starts a new, actionable gesture.
longPressed.current = false;
cancelPress();
if (toggleOnClick) return;
pressPoint.current = { x: event.clientX, y: event.clientY };
pressTimer.current = window.setTimeout(() => {
pressTimer.current = null;
longPressed.current = true;
mountPanel();
pickAlign();
setTouchOpen(true);
setPressOpen(true);
}, LONG_PRESS_MS);
}}
onPointerUp={() => {
@@ -299,7 +308,7 @@ export function Tooltip({
hideTimer.current = window.setTimeout(() => {
hideTimer.current = null;
longPressed.current = false;
setTouchOpen(false);
setPressOpen(false);
}, TOUCH_HIDE_MS);
}
}}
@@ -311,7 +320,7 @@ export function Tooltip({
window.clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setTouchOpen(false);
setPressOpen(false);
}}
onPointerMove={(event) => {
// Only a real drag (scroll intent) cancels the hold — Chrome emits
@@ -345,6 +354,18 @@ export function Tooltip({
event.stopPropagation();
}
}}
onClick={(event) => {
if (!toggleOnClick || disabledTrigger || tipRef.current?.contains(event.target as Node)) return;
if (pressOpen) {
setPressOpen(false);
setHoverOpen(false);
setFocusOpen(false);
} else {
mountPanel();
pickAlign();
setPressOpen(true);
}
}}
onKeyDown={(event) => {
if (event.key !== "Escape" && (event.target as HTMLElement).matches(":focus-visible")) {
mountPanel();
@@ -352,8 +373,10 @@ export function Tooltip({
}
}}
>
{trigger && text ? cloneElement(trigger, {
{trigger && (text || toggleOnClick) ? cloneElement(trigger, {
"aria-describedby": [trigger.props["aria-describedby"], interactionOpen ? tooltipId : undefined].filter(Boolean).join(" ") || undefined,
"aria-expanded": toggleOnClick ? interactionOpen : trigger.props["aria-expanded"],
"aria-controls": toggleOnClick ? tooltipId : trigger.props["aria-controls"],
}) : children}
<span ref={tipRef} id={tooltipId} popover="manual"
data-tone={resolvedTone}
+2 -1
View File
@@ -163,7 +163,8 @@ html[data-theme="dark"] .lr-brand-sparkles { stroke: #f4c14d; }
stroke-dashoffset: 0 !important;
}
.lr-brand-mark:is(.is-animated, .is-once) .lr-brand-eye-open,
.lr-brand-mark:is(.is-animated, .is-once) .lr-brand-sparkles {
.lr-brand-mark:is(.is-animated, .is-once) .lr-brand-sparkles,
.lr-brand-mark:is(.is-animated, .is-once) .lr-brand-sparkles > * {
animation: none !important;
opacity: 0 !important;
}
+12 -12
View File
@@ -231,23 +231,23 @@ export function Pill({
}) {
const { vis } = useCopy();
const wrapped = !vis || Boolean(comic);
const Trigger = wrapped ? "button" : "span";
const body = (
<span
<Trigger
type={wrapped ? "button" : undefined}
className={`lr-pill${tone ? ` is-${tone}` : ""}`}
role={alert ? "alert" : "status"}
// Hint-wrapped bodies must take keyboard focus, or the comic is
// unreachable for keyboard users (spans never match :focus-visible).
tabIndex={wrapped ? 0 : undefined}
>
<Glyph name={icon} size={16} />
{vis ? <span className="visually-hidden">{label}</span> : <span>{label}</span>}
</span>
</Trigger>
);
return wrapped ? (
<Tooltip kind={comic} tone={tooltipTone ?? (tone === "good" ? "live" : tone === "bad" ? "bad" : "warn")}
motion={tone === "good" ? "still" : undefined} text={vis ? undefined : label}>{body}</Tooltip>
) : (
body
return (
<span role={alert ? "alert" : "status"}>
{wrapped ? (
<Tooltip toggleOnClick kind={comic} tone={tooltipTone ?? (tone === "good" ? "live" : tone === "bad" ? "bad" : "warn")}
motion={tone === "good" ? "still" : undefined} text={vis ? undefined : label}>{body}</Tooltip>
) : body}
</span>
);
}
@@ -268,7 +268,7 @@ export function SwitchItem({
label: string;
/** Text modes: extra sentence in the tooltip. */
note?: string;
/** Vis mode: 2-panel hint comic on hover/focus; native title stays off. */
/** Shared hint comic; native title stays off. */
hint?: HintKind;
}) {
const { vis, t } = useCopy();
@@ -10,6 +10,11 @@
border-radius: 8px;
vertical-align: middle;
}
button.lr-status-indicator {
border: 0;
background: transparent;
cursor: pointer;
}
.lr-status-indicator[data-tone="live"] { --status-colour: var(--live); }
.lr-status-indicator[data-tone="busy"] { --status-colour: var(--action); }
.lr-status-indicator[data-tone="warn"] { --status-colour: var(--warn); }
+2 -2
View File
@@ -18,7 +18,7 @@ import {
const launcherStateSchema = z.object({
site: z.string(),
localAccessPassword: z.string(),
defaultMode: z.enum(["local", "site"]),
defaultMode: z.enum(["local", "link", "site"]),
revision: z.string(),
version: z.string().default("development"),
packageTarget: z.string().optional(),
@@ -29,7 +29,7 @@ export function AppLauncherPage() {
const { lang, vis, t } = useCopy();
const [loading, setLoading] = useState(true);
const [starting, setStarting] = useState(false);
const [mode, setMode] = useState<AppMode>("local");
const [mode, setMode] = useState<AppMode>("link");
const [site, setSite] = useState("");
const [localAccessPassword, setLocalAccessPassword] = useState("");
const [error, setError] = useState<null | "load" | "launch">(null);
+6 -36
View File
@@ -3782,42 +3782,12 @@ export function HostPage({
<span
className="lr-meter-cell"
>
{vis ? (
<span
style={{ position: "relative", display: "inline-flex" }}
>
<Glyph name="speaker" size={16} />
{details.hasAudio ? null : (
<span
aria-hidden="true"
style={{
position: "absolute",
top: -3,
bottom: -3,
left: "50%",
width: 2.5,
borderRadius: 2,
background: "currentColor",
transform: "translateX(-50%) rotate(45deg)",
}}
/>
)}
<span className="visually-hidden">
{details.hasAudio
? t("host.capture.hasAudio")
: t("host.capture.noAudio")}
</span>
</span>
) : (
<>
<Glyph name="speaker" size={16} />
<b>
{details.hasAudio
? t("host.capture.hasAudio")
: t("host.capture.noAudio")}
</b>
</>
)}
<Glyph name={details.hasAudio ? "speaker" : "speakerOff"} size={16} />
<b className={vis ? "visually-hidden" : undefined}>
{details.hasAudio
? t("host.capture.hasAudio")
: t("host.capture.noAudio")}
</b>
</span>
</div>
</div>
+1 -1
View File
@@ -166,7 +166,7 @@ export function StatusPreviewPage() {
</div>
</section>
<section className="sp-catalog">
<header><h2>图标状态词典</h2><p>一个图标表达一件事。悬停、键盘聚焦或长按查看对应文字或漫画。</p></header>
<header><h2>图标状态词典</h2><p>一个图标表达一件事。悬停、键盘聚焦或点击查看对应图解;再次点击或点外部关闭。</p></header>
<div className="sp-catalog-grid">
{Object.entries(STATUS_CATALOG).map(([key, item]) => <article key={key}>
<StatusIndicator status={item} /><span>{t(item.labelKey)}</span><code>{key}</code>
+6 -29
View File
@@ -2168,15 +2168,6 @@ export function ViewerPage({
failureCode === "ROOM_CLOSED"
? "room-not-found"
: "warning";
const deniedIcon: GlyphName =
failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_CLOSED"
? "door"
: failureCode === "ROOM_ACCESS_DENIED"
? "lock"
: failureCode === "ROOM_FULL"
? "users"
: "alert";
const deniedHintKey: CopyKey = codeOnlyDenied
? "viewer.hint.denied"
: failureCode === "ROOM_NOT_FOUND" ||
@@ -2213,27 +2204,10 @@ export function ViewerPage({
</div>
) : (
<div className="lr-join-panel">
{vis ? (
<>
<Comic kind={deniedComic} theme="paper" tone={viewerStatus.activity.tone} />
<span className="visually-hidden" role="alert">
{t(presentation.messageKey)} · {t(deniedHintKey)}
</span>
</>
) : (
<span
className="lr-tv-big"
style={{
borderColor: "var(--ink)",
color: "var(--ink)",
background: "var(--paper)",
}}
role="img"
aria-label={t(presentation.messageKey)}
>
<Glyph name={deniedIcon} size={30} />
<Comic kind={deniedComic} theme="paper" tone={viewerStatus.activity.tone} />
<span className="visually-hidden" role="alert">
{t(presentation.messageKey)} · {t(deniedHintKey)}
</span>
)}
{vis ? null : (
<div className="lr-access-text">
<h1>{t(presentation.messageKey)}</h1>
@@ -2246,6 +2220,7 @@ export function ViewerPage({
icon="key"
cap="join.passwordAction"
title="join.passwordAction"
hint={viewerPasswordExpanded ? "hint-collapse" : "hint-password"}
expanded={viewerPasswordExpanded}
controls="viewer-password-retry"
onClick={() => setViewerPasswordExpanded((current) => !current)}
@@ -2545,12 +2520,14 @@ export function ViewerPage({
<Btn
icon="check"
title="host.nameSave"
hint="hint-rename"
type="submit"
disabled={displayNameDraft === displayName}
/>
<Btn
icon="x"
title="host.nameCancel"
hint="hint-close"
onClick={() => {
setDisplayNameDraft(displayName);
setDisplayNameError(false);
+4 -3
View File
@@ -134,6 +134,7 @@ ul { margin: 0; padding: 0; list-style: none; }
gap: 7px;
align-items: center;
padding: 8px 12px;
border: 0;
border-radius: 999px;
background: var(--wall-2);
}
@@ -1732,8 +1733,8 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
explanation focus instead. Enter is delayed ~120ms, exit is immediate. Under
prefers-reduced-motion the global block collapses the transition to instant
and the comic's own RM block pins its final frame. */
/* Visibility is three-channel state owned by Tooltip: fine-pointer hover,
keyboard focus-visible, and Material-style touch long-press. */
/* Tooltip owns hover, keyboard focus and press. Help buttons toggle on tap;
action controls retain long-press guidance. */
.lr-comic-tip-wrap { position: relative; display: inline-flex; }
.lr-comic-tip-wrap.is-disabled-trigger > :disabled { pointer-events: none; }
.lr-comic-tip {
@@ -1826,7 +1827,7 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
}
}
/* channels 2+3: keyboard focus-visible and touch long-press */
/* Keyboard focus and click/long-press guidance. */
.lr-comic-tip-wrap.is-focus-open .lr-comic-tip,
.lr-comic-tip-wrap.is-tip-open .lr-comic-tip {
opacity: 1;