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:
21 files changed
+131
-144
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -207,7 +207,7 @@ func (server *Server) handleState(response http.ResponseWriter, request *http.Re
|
||||
if server.site != "" {
|
||||
return ModeSite
|
||||
}
|
||||
return ModeLocal
|
||||
return ModeLink
|
||||
}(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user