From a7a0eea3367f42bfd6c567198b406034b351e052 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:20:33 +0800 Subject: [PATCH] fix(ui): complete illustrated guidance and launcher defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CONTRIBUTING.md | 2 +- docs/design/visual-language.md | 8 ++- docs/reference/configuration.md | 6 +- docs/reference/versioning.md | 10 +++- internal/app/launcher/server.go | 2 +- internal/app/launcher/server_test.go | 3 +- src/client/App.tsx | 18 +----- .../components/living/CaptureSourcePicker.tsx | 8 +-- src/client/components/living/Header.tsx | 22 ++++---- src/client/components/living/LauncherForm.tsx | 1 + src/client/components/living/Stage.tsx | 4 +- .../components/living/StatusIndicator.tsx | 14 +++-- src/client/components/living/Tooltip.tsx | 55 +++++++++++++------ src/client/components/living/brand-mark.css | 3 +- src/client/components/living/primitives.tsx | 24 ++++---- .../components/living/status-indicator.css | 5 ++ src/client/pages/AppLauncherPage.tsx | 4 +- src/client/pages/HostPage.tsx | 42 ++------------ src/client/pages/StatusPreviewPage.tsx | 2 +- src/client/pages/ViewerPage.tsx | 35 ++---------- src/client/styles.css | 7 ++- 21 files changed, 131 insertions(+), 144 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c425801..568dce0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/docs/design/visual-language.md b/docs/design/visual-language.md index 0827796..c5c518c 100644 --- a/docs/design/visual-language.md +++ b/docs/design/visual-language.md @@ -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 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 0cf7548..5041435 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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 | diff --git a/docs/reference/versioning.md b/docs/reference/versioning.md index 5419e0f..99a1a6e 100644 --- a/docs/reference/versioning.md +++ b/docs/reference/versioning.md @@ -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 diff --git a/internal/app/launcher/server.go b/internal/app/launcher/server.go index 4980af3..8a1ed2d 100644 --- a/internal/app/launcher/server.go +++ b/internal/app/launcher/server.go @@ -207,7 +207,7 @@ func (server *Server) handleState(response http.ResponseWriter, request *http.Re if server.site != "" { return ModeSite } - return ModeLocal + return ModeLink }(), }) } diff --git a/internal/app/launcher/server_test.go b/internal/app/launcher/server_test.go index 14df8b5..f39433a 100644 --- a/internal/app/launcher/server_test.go +++ b/internal/app/launcher/server_test.go @@ -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) } } diff --git a/src/client/App.tsx b/src/client/App.tsx index 6f6443d..9bcf5f0 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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 ? ( ) : ( - + ); } @@ -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({
- {vis ? ( - // The scene states this exact situation, so the meaning needs no - // hover: a tooltip trigger would leave it pointer-only. - - ) : ( - - - - )} + {vis ? null : (

{t(titleKey)}

diff --git a/src/client/components/living/CaptureSourcePicker.tsx b/src/client/components/living/CaptureSourcePicker.tsx index db73cad..8aaeb3a 100644 --- a/src/client/components/living/CaptureSourcePicker.tsx +++ b/src/client/components/living/CaptureSourcePicker.tsx @@ -143,11 +143,9 @@ export function CaptureSourcePicker({ {refreshButton} )} - {vis ? closeButton : ( - - {closeButton} - - )} + + {closeButton} +
{vis ? null : {label}} - + ); - return wrapped ? ( - - {strip} - - ) : ( - strip + return ( + + {wrapped ? ( + + {strip} + + ) : strip} + ); } diff --git a/src/client/components/living/LauncherForm.tsx b/src/client/components/living/LauncherForm.tsx index 4d93fa3..c835fc4 100644 --- a/src/client/components/living/LauncherForm.tsx +++ b/src/client/components/living/LauncherForm.tsx @@ -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()} /> diff --git a/src/client/components/living/Stage.tsx b/src/client/components/living/Stage.tsx index 7bb68a6..c73951d 100644 --- a/src/client/components/living/Stage.tsx +++ b/src/client/components/living/Stage.tsx @@ -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 ? : null} - {(vis || !comic || onActivate) && (showMascot ? ( + {(showMascot || vis || !comic || onActivate) && (showMascot ? ( ) : ( diff --git a/src/client/components/living/StatusIndicator.tsx b/src/client/components/living/StatusIndicator.tsx index 88e8a78..5df38ce 100644 --- a/src/client/components/living/StatusIndicator.tsx +++ b/src/client/components/living/StatusIndicator.tsx @@ -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 = ( - - + ); - return !vis || hint - ? {indicator} : indicator; } diff --git a/src/client/components/living/Tooltip.tsx b/src/client/components/living/Tooltip.tsx index b0640e4..bdd8e11 100644 --- a/src/client/components/living/Tooltip.tsx +++ b/src/client/components/living/Tooltip.tsx @@ -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 ( { 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} * { animation: none !important; opacity: 0 !important; } diff --git a/src/client/components/living/primitives.tsx b/src/client/components/living/primitives.tsx index 6f9f82a..c786380 100644 --- a/src/client/components/living/primitives.tsx +++ b/src/client/components/living/primitives.tsx @@ -231,23 +231,23 @@ export function Pill({ }) { const { vis } = useCopy(); const wrapped = !vis || Boolean(comic); + const Trigger = wrapped ? "button" : "span"; const body = ( - {vis ? {label} : {label}} - + ); - return wrapped ? ( - {body} - ) : ( - body + return ( + + {wrapped ? ( + {body} + ) : body} + ); } @@ -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(); diff --git a/src/client/components/living/status-indicator.css b/src/client/components/living/status-indicator.css index 2f2604e..3e2795e 100644 --- a/src/client/components/living/status-indicator.css +++ b/src/client/components/living/status-indicator.css @@ -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); } diff --git a/src/client/pages/AppLauncherPage.tsx b/src/client/pages/AppLauncherPage.tsx index 52941a5..685b45f 100644 --- a/src/client/pages/AppLauncherPage.tsx +++ b/src/client/pages/AppLauncherPage.tsx @@ -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("local"); + const [mode, setMode] = useState("link"); const [site, setSite] = useState(""); const [localAccessPassword, setLocalAccessPassword] = useState(""); const [error, setError] = useState(null); diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 3423d2b..b28f364 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -3782,42 +3782,12 @@ export function HostPage({ - {vis ? ( - - - {details.hasAudio ? null : ( - - ) : ( - <> - - - {details.hasAudio - ? t("host.capture.hasAudio") - : t("host.capture.noAudio")} - - - )} + + + {details.hasAudio + ? t("host.capture.hasAudio") + : t("host.capture.noAudio")} +
diff --git a/src/client/pages/StatusPreviewPage.tsx b/src/client/pages/StatusPreviewPage.tsx index 01338e1..e87abb5 100644 --- a/src/client/pages/StatusPreviewPage.tsx +++ b/src/client/pages/StatusPreviewPage.tsx @@ -166,7 +166,7 @@ export function StatusPreviewPage() {
-

图标状态词典

一个图标表达一件事。悬停、键盘聚焦或长按查看对应文字或漫画。

+

图标状态词典

一个图标表达一件事。悬停、键盘聚焦或点击查看对应图解;再次点击或点外部关闭。

{Object.entries(STATUS_CATALOG).map(([key, item]) =>
{t(item.labelKey)}{key} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index c7559d6..eba9b71 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -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({
) : (
- {vis ? ( - <> - - - {t(presentation.messageKey)} · {t(deniedHintKey)} - - - ) : ( - - + + + {t(presentation.messageKey)} · {t(deniedHintKey)} - )} {vis ? null : (

{t(presentation.messageKey)}

@@ -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({ { setDisplayNameDraft(displayName); setDisplayNameError(false); diff --git a/src/client/styles.css b/src/client/styles.css index afa36ee..647f6ef 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -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;