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;