From 9ea97b670e2824f4fe43a5c789260d33469aa926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Mon, 28 Sep 2026 14:27:15 +0800 Subject: [PATCH] feat(onboarding): animated splash into first-run login (#519) Add the first-run gate: the brand-mark splash plays for 900ms, then a 400ms shared-element transition lands on the login/register card (~1.3s cold), or a 300ms one reaches the workspace when a session is saved (~1.2s warm). An expired session gets an inline notice, never a modal, and the dead record is cleared. prefers-reduced-motion switches to 240ms of fades; the window is revealed on ready-to-show so no empty frame paints before the splash. Authentication sits behind a narrow adapter interface with an explicitly stubbed default that persists no password and compares nothing, so #395 can land independently. The login card is native form elements plus CSS: no new runtime dependency, and ADR-0002's provider chain stays inside App. The #127 full-app layout smoke renders the real app and has no session on a fresh CI machine, so it now declares itself in the loaded URL and the renderer skips onboarding only for that exact declaration; the trusted renderer URL is built with the same query so window IPC trust still matches. Refs #519 --- CHANGELOG.md | 2 + apps/desktop/renderer/src/App.tsx | 44 +- apps/desktop/renderer/src/WindowControls.tsx | 59 +++ apps/desktop/renderer/src/main.tsx | 43 +- .../renderer/src/onboarding/BrandMark.tsx | 43 ++ .../renderer/src/onboarding/LoginScreen.tsx | 255 ++++++++++ .../src/onboarding/OnboardingGate.tsx | 301 ++++++++++++ .../renderer/src/onboarding/SplashScreen.tsx | 53 +++ .../renderer/src/onboarding/auth-adapter.ts | 248 ++++++++++ .../renderer/src/onboarding/onboarding.css | 444 ++++++++++++++++++ .../renderer/test/onboarding-entry.test.tsx | 70 +++ .../renderer/test/onboarding-gate.test.tsx | 364 ++++++++++++++ .../renderer/test/onboarding-login.test.tsx | 173 +++++++ apps/desktop/src/main.js | 28 +- apps/desktop/src/packaged-smoke.cjs | 22 + .../test/onboarding-brand-mark.test.cjs | 87 ++++ apps/desktop/test/onboarding-motion.test.cjs | 171 +++++++ packages/ui/src/locales/en.ts | 26 + packages/ui/src/locales/zh.ts | 26 + 19 files changed, 2413 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/renderer/src/WindowControls.tsx create mode 100644 apps/desktop/renderer/src/onboarding/BrandMark.tsx create mode 100644 apps/desktop/renderer/src/onboarding/LoginScreen.tsx create mode 100644 apps/desktop/renderer/src/onboarding/OnboardingGate.tsx create mode 100644 apps/desktop/renderer/src/onboarding/SplashScreen.tsx create mode 100644 apps/desktop/renderer/src/onboarding/auth-adapter.ts create mode 100644 apps/desktop/renderer/src/onboarding/onboarding.css create mode 100644 apps/desktop/renderer/test/onboarding-entry.test.tsx create mode 100644 apps/desktop/renderer/test/onboarding-gate.test.tsx create mode 100644 apps/desktop/renderer/test/onboarding-login.test.tsx create mode 100644 apps/desktop/test/onboarding-brand-mark.test.cjs create mode 100644 apps/desktop/test/onboarding-motion.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6397ed..ed808f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Added +- #519:桌面端首启体验。冷启动先播品牌标启动画(约 0.9s),再过 0.4s 的共享元素过渡淡入登录 / 注册卡,总时长 ≈1.3s;已有会话则 ≈1.2s 直接进工作区,不显示登录卡;会话过期时落在登录卡上并给一条非阻塞行内提示,不用错误弹窗。登录 / 注册同卡切换,客户端校验错误行内展示;字段在过渡结束前不自动聚焦。`prefers-reduced-motion` 下只留淡入淡出并把启动画压到 240ms。窗口改为 `ready-to-show` 后再显示,消除首帧白闪。认证走 `auth-adapter` 窄接口,默认实现是明确标注的 stub(本地记录、不落密码、不做比对),真实按部署登录仍由 #395 负责,两者可独立合入。零新增运行时依赖。Refs #519。 + - #469:可选 Laya 接力停止建议。仅在 `ROLEWEAVE_LAYA_ENABLED` 开启、已完成步骤得到正向建议且仍有未执行步骤时暂停;桌面端由 owner 显式选择“停止剩余步骤”或“继续接力”。30 秒超时、SSE 断连、无效或失败建议均继续原 mention order;Laya 只接收 `status`、`errorCode`、`hasOutput`,不接收输入、输出或 handoff 文本。已接受的消息与 spawn 清单保持不可变。Refs #422。 - #360:工作区 `work/` 领地布局。initialize/create 脚手架 `work/README.md`(已有工作区不加强制迁移)。hire 创建 `work//`,仅给 hire 岗位生成 SKILL.md Territory 段,并把新角色的 `memoryScope` 写成 `./work//`;项目负责人保持 `memoryScope: "/"`,SKILL 不声称不存在的 `work//`。默认 `toolAllow` 只有 Read/Grep/Glob,不含 Write/Edit/Bash。hire 任何 staging 失败都回滚声明文件。employee.json 摘要密封机制不变。Refs #360。 diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx index cd52052b..fa1efb7f 100644 --- a/apps/desktop/renderer/src/App.tsx +++ b/apps/desktop/renderer/src/App.tsx @@ -36,6 +36,7 @@ import type { } from "@roleweave/shared"; import { Activity, Brain, ChartColumn, ChevronsRight, ClipboardCheck, Flag, FolderKanban, FolderOpen, Network, Orbit, PencilLine, Plus, Settings, Undo2, UsersRound } from "lucide-react"; import { useThemeMode, useThemeProfile } from "./theme-toggle"; +import { WindowControls } from "./WindowControls"; import { useTheme, ThemeProvider } from "./theme-context"; import { themeToAntdSeed } from "./theme-resolution"; import { DEFAULT_PRESET_ID } from "./theme-presets"; @@ -2576,49 +2577,6 @@ function normalizePositionForDisplay(position: PositionCardData, locale: OwbLoca }, locale); } -function WindowControls() { - const t = useT(); - return ( - - - - {/* 文案保持静态:WSLg 下 isMaximized() 不可信,不向用户谎报当前状态。 */} - - - ); -} - function Breadcrumbs({ workspace, }: { diff --git a/apps/desktop/renderer/src/WindowControls.tsx b/apps/desktop/renderer/src/WindowControls.tsx new file mode 100644 index 00000000..26f1a05b --- /dev/null +++ b/apps/desktop/renderer/src/WindowControls.tsx @@ -0,0 +1,59 @@ +/** + * The three window-control buttons of the 40px custom title bar (#73). The + * window is frameless, so these are the only way to close, minimize, or + * maximize it — #94 and #248 exist because that affordance is not decorative. + * + * Extracted out of `App.tsx` for #519: the first-run splash and the + * login/register card also run inside the frameless window, but `` is + * deliberately not mounted yet at that point. Keeping the buttons inside App + * would have left the user with a window they could neither drag nor close + * during the splash and while the login card is up. Sharing the component is + * the point — a second copy would drift out of the #94 / #248 guards in + * window-controls-affordance.test.cjs, which read the built bundle by class + * name and would keep passing while the copy they never see rots. + */ +import { useT } from "@roleweave/ui"; + +export function WindowControls() { + const t = useT(); + return ( + + + + {/* The label stays static on purpose: `isMaximized()` is not trustworthy + under WSLg, and the button must not report a state it cannot know. */} + + + ); +} diff --git a/apps/desktop/renderer/src/main.tsx b/apps/desktop/renderer/src/main.tsx index 601fbba9..3d3b422f 100644 --- a/apps/desktop/renderer/src/main.tsx +++ b/apps/desktop/renderer/src/main.tsx @@ -20,11 +20,18 @@ import "./roleweave-data.css"; import "./workspace-polish.css"; import "./memory/memory-workspace.css"; import "./control-legibility.css"; +import "./onboarding/onboarding.css"; import { App } from "./App"; import { initThemeMode } from "./theme-mode"; +import { OnboardingGate } from "./onboarding/OnboardingGate"; export const PACKAGED_SMOKE_QUERY_KEY = "orgWorkbenchPackagedSmoke"; const PACKAGED_SMOKE_NONCE = /^[a-f0-9]{64}$/; +/** #519: the #127 full-app layout harness is declared by main as a query + * parameter so the renderer can skip the first-run gate for it. Keep in sync + * with LAYOUT_SMOKE_QUERY_KEY in apps/desktop/src/packaged-smoke.cjs — + * `apps/desktop/test/onboarding-motion.test.cjs` fails if the two drift. */ +export const LAYOUT_SMOKE_QUERY_KEY = "orgWorkbenchLayoutSmoke"; type RendererLocation = Pick; @@ -37,6 +44,18 @@ export function isPackagedSmokeEntry(location: RendererLocation): boolean { PACKAGED_SMOKE_NONCE.test(query.get(PACKAGED_SMOKE_QUERY_KEY) ?? ""); } +/** The layout harness renders the real app, and a fresh CI install has no saved + * session — without this the gate would sit on the login card and the columns + * being measured would never mount. */ +export function isLayoutSmokeEntry(location: RendererLocation): boolean { + if (location.protocol !== "file:") return false; + const query = new URLSearchParams(location.search); + const keys = [...query.keys()]; + return keys.length === 1 && + keys[0] === LAYOUT_SMOKE_QUERY_KEY && + query.get(LAYOUT_SMOKE_QUERY_KEY) === "1"; +} + export function rendererEntryElement( location: RendererLocation, AppComponent: React.ComponentType = App, @@ -61,6 +80,28 @@ export function rendererEntryElement( const root = document.getElementById("root"); if (!root) throw new Error("renderer root element missing"); +/** + * What actually gets mounted: the entry, wrapped in the #519 first-run gate. + * + * The gate sits **above** `` on purpose — see OnboardingGate's header. + * `` stays the workspace shell, so `App.test.tsx`'s 30+ direct renders + * keep meaning what they meant. + * + * The packaged smoke entry is deliberately exempt: it is a static marker the + * external staging harness scores, it must call no bridge method, and a timed + * splash in front of it would turn that harness into a timing test. The layout + * harness is exempt for a different reason — it measures the real workspace, so + * it has to reach it without a session on a fresh CI machine. + */ +export function rendererRootElement( + location: RendererLocation, + AppComponent: React.ComponentType = App, +): React.ReactElement { + const entry = rendererEntryElement(location, AppComponent); + if (isPackagedSmokeEntry(location) || isLayoutSmokeEntry(location)) return entry; + return {entry}; +} + // Theme seed before the first render (#94). index.html can only ship a static // data-theme literal — its CSP is `script-src 'self'`, so the usual pre-paint // inline script is not an option — and this runs before createRoot(), which is @@ -72,5 +113,5 @@ initThemeMode(); // The antd ConfigProvider (ADR-0002 theme tokens) lives inside so the // test harness renders the exact same configuration as production. createRoot(root).render( - rendererEntryElement(window.location), + rendererRootElement(window.location), ); diff --git a/apps/desktop/renderer/src/onboarding/BrandMark.tsx b/apps/desktop/renderer/src/onboarding/BrandMark.tsx new file mode 100644 index 00000000..567221e4 --- /dev/null +++ b/apps/desktop/renderer/src/onboarding/BrandMark.tsx @@ -0,0 +1,43 @@ +/** + * RoleWeave app mark, drawn as JSX so the splash and the login card can render + * it without a network/asset request and without a new build loader. + * + * Source of truth is `branding/roleweave/roleweave-icon.svg` (see that folder's + * README: the canvas is filled edge-to-edge so no platform adds a white square + * around it). This file carries **geometry only** — every `id`/`fill` of the + * asset became a class, and the brand palette lives in `onboarding.css` as + * custom properties. + * + * Both halves of that split are pinned against the asset by + * `apps/desktop/test/onboarding-brand-mark.test.cjs`: path data, viewBox and + * transform must match the SVG, and the CSS custom properties must carry the + * asset's own hex values. A copy that silently drifts from the shipped icon + * therefore fails the suite instead of shipping. + */ + +export interface BrandMarkProps { + /** Size/shape come from CSS; this only adds the consumer's own hook. */ + className?: string; +} + +export function BrandMark({ className }: BrandMarkProps) { + return ( + + ); +} diff --git a/apps/desktop/renderer/src/onboarding/LoginScreen.tsx b/apps/desktop/renderer/src/onboarding/LoginScreen.tsx new file mode 100644 index 00000000..70a86026 --- /dev/null +++ b/apps/desktop/renderer/src/onboarding/LoginScreen.tsx @@ -0,0 +1,255 @@ +/** + * #519 login / register card. + * + * Three deliberate choices: + * 1. **No antd.** The first-run surface runs outside ``, and ADR-0002's + * ConfigProvider / DSProvider (including `autoInsertSpace: false`) live + * inside App. Dragging that provider chain in front of the login page + * would make the signed-out state carry the whole design-token setup. This + * uses native form elements plus onboarding.css, which also keeps the "no + * new runtime dependency" criterion trivially true. + * 2. **No focus during the transition.** The inputs do not touch focus when + * they mount; `focus()` runs once `settled` flips, so the keyboard cannot + * pop up in the middle of the animation (motion spec). + * 3. **Local validation, inline reporting.** Error values are i18n keys, not + * sentences: the component holds no user-visible copy (the zh/en catalogs + * are the only source). Form-level failures (bad credentials, taken email, + * unreachable service) render as one inline notice and never as a modal. + */ +import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useT, type OwbT } from "@roleweave/ui"; +import { + MIN_PASSWORD_LENGTH, + type OnboardingAuthFailure, + type OnboardingCredentials, +} from "./auth-adapter"; + +export type LoginMode = "signIn" | "signUp"; + +export interface LoginFieldErrors { + /** i18n keys, not sentences. */ + email?: string; + password?: string; +} + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Client-side validation (the issue requires inline errors on both forms). + * + * Exported as a pure function so it can be tested on its own: it is the single + * validation rule shared by sign-in and sign-up, and hiding it inside the + * component would leave only indirect, render-based verification. + * + * This is usability validation, not security validation — the real rules are + * the server's, and they arrive with #395. + */ +export function validateCredentials(mode: LoginMode, values: { email: string; password: string }): LoginFieldErrors { + const errors: LoginFieldErrors = {}; + const email = values.email.trim(); + if (email.length === 0) errors.email = "onboarding.login.errEmailRequired"; + else if (!EMAIL_PATTERN.test(email)) errors.email = "onboarding.login.errEmailFormat"; + + if (mode === "signUp") { + if (values.password.length === 0) errors.password = "onboarding.login.errPasswordRequired"; + else if (values.password.length < MIN_PASSWORD_LENGTH) errors.password = "onboarding.login.errPasswordShort"; + } else if (values.password.length === 0) { + errors.password = "onboarding.login.errPasswordRequired"; + } + return errors; +} + +const FAILURE_KEYS: Record = { + invalid_credentials: "onboarding.login.errInvalidCredentials", + email_taken: "onboarding.login.errEmailTaken", + unreachable: "onboarding.login.errUnreachable", +}; + +export interface LoginScreenProps { + /** Whether the transition has finished. #519: fields must not autofocus + * before it does. */ + settled: boolean; + /** Request in flight: the button goes busy and a second submit is blocked. */ + busy: boolean; + /** Server-side failure from the previous submit (the stub can report + * "unreachable" too). */ + failure: OnboardingAuthFailure | null; + /** Expired session: one inline notice rather than an error dialog. */ + expiredNotice: boolean; + onSubmit: (mode: LoginMode, credentials: OnboardingCredentials) => void; + /** Supplied once #395 wires "forgot password"; without it the row stays + * visible but inert. */ + onForgotPassword?: () => void; +} + +export function LoginScreen({ + settled, + busy, + failure, + expiredNotice, + onSubmit, + onForgotPassword, +}: LoginScreenProps) { + const t = useT(); + const [mode, setMode] = useState("signIn"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errors, setErrors] = useState({}); + const emailRef = useRef(null); + const fieldId = useId(); + + useEffect(() => { + if (settled) emailRef.current?.focus(); + }, [settled]); + + const switchMode = useCallback((next: LoginMode) => { + setMode(next); + setErrors({}); + // Switching to sign-up does not keep the password: the two forms have + // different password rules, and carrying it over only confuses. + setPassword(""); + }, []); + + const onTabKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + switchMode(mode === "signIn" ? "signUp" : "signIn"); + }, [mode, switchMode]); + + const submit = useCallback(() => { + const values = { email: email.trim(), password }; + const found = validateCredentials(mode, values); + setErrors(found); + if (found.email !== undefined || found.password !== undefined) return; + onSubmit(mode, values); + }, [email, mode, onSubmit, password]); + + const emailId = `${fieldId}-email`; + const passwordId = `${fieldId}-password`; + const emailErrorId = `${emailId}-error`; + const passwordErrorId = `${passwordId}-error`; + const failureText = failure === null ? null : failureKeyText(t, failure); + + return ( +
+
+

{t("onboarding.login.title")}

+

{t("onboarding.login.subtitle")}

+ +
+ + +
+ + {expiredNotice ? ( +

{t("onboarding.login.expiredNotice")}

+ ) : null} + + {failureText !== null ? ( +

{failureText}

+ ) : null} + +
{ event.preventDefault(); submit(); }} + > +
+ + { + const next = event.target.value; + setEmail(next); + setErrors((current) => ({ ...current, email: undefined })); + }} + /> + {errors.email !== undefined ? ( +

{t(errors.email)}

+ ) : null} +
+ +
+ + { + const next = event.target.value; + setPassword(next); + setErrors((current) => ({ ...current, password: undefined })); + }} + /> + {errors.password !== undefined ? ( +

{t(errors.password)}

+ ) : null} + {mode === "signUp" ? ( +

{t("onboarding.login.passwordHint", { count: MIN_PASSWORD_LENGTH })}

+ ) : null} +
+ + +
+ + + +

{t("onboarding.login.legal")}

+
+
+ ); +} + +function failureKeyText(t: OwbT, failure: OnboardingAuthFailure): string { + return t(FAILURE_KEYS[failure]); +} diff --git a/apps/desktop/renderer/src/onboarding/OnboardingGate.tsx b/apps/desktop/renderer/src/onboarding/OnboardingGate.tsx new file mode 100644 index 00000000..f84a4acb --- /dev/null +++ b/apps/desktop/renderer/src/onboarding/OnboardingGate.tsx @@ -0,0 +1,301 @@ +/** + * #519 first-run gate: `splash → (session ? workspace : login)`. + * + * Why the gate sits **above** `` (wrapping the renderer entry in + * main.tsx) instead of inside App: `` is the workspace shell, and + * `App.test.tsx` renders it directly in 30+ cases that assert the workspace is + * immediately usable. Putting the login flow inside App would force those cases + * to sign in first — a real behaviour change traded for a test adaptation. Above + * App, "App = workspace shell" keeps its meaning, the first-run flow is a state + * machine in front of it, and both stay independently testable. + * + * The state machine is an **exported pure reducer** (`reduceOnboarding`); the + * component only translates timers and adapter results into events. The routing + * rules (a saved session skips login, an expired one does not, a successful + * sign-in reaches the workspace, nothing lands mid-transition) are the substance + * of this change, and a reducer proves them far more reliably than inferring + * them through fake timers and DOM assertions. + * + * Motion budget: splash 900ms + transition 400ms (login) / 300ms (workspace) — + * a cold launch reaches the login card in ≈1.3s and a warm launch reaches the + * workspace in ≈1.2s, both inside the acceptance criteria. + * `prefers-reduced-motion` substitutes 240 + 220 / 180ms of pure fades. + */ +import { useCallback, useEffect, useMemo, useReducer, useState, type CSSProperties, type ReactNode } from "react"; +import { OwbI18nProvider, useT } from "@roleweave/ui"; +import { WindowControls } from "../WindowControls"; +import { seedLocale } from "../locale-mode"; +import { LoginScreen, type LoginMode } from "./LoginScreen"; +import { SplashScreen } from "./SplashScreen"; +import { + resolveDefaultAuthAdapter, + type OnboardingAuthAdapter, + type OnboardingAuthFailure, + type OnboardingCredentials, + type SessionRestore, +} from "./auth-adapter"; + +export type OnboardingRoute = "login" | "workspace"; + +export interface OnboardingMachineState { + /** Which layer is on screen. */ + stage: "splash" | "transition" | "login" | "workspace"; + /** Where the transition is heading; null while the session is unknown. */ + pending: OnboardingRoute | null; + /** Session verdict; null means "not read yet". */ + restore: SessionRestore | null; + /** Whether the splash's minimum duration has elapsed. */ + splashElapsed: boolean; +} + +export type OnboardingEvent = + | { type: "session"; restore: SessionRestore } + | { type: "splashElapsed" } + | { type: "transitionElapsed" } + | { type: "authenticated"; session: Extract["session"] }; + +export function initialOnboardingState(): OnboardingMachineState { + return { stage: "splash", pending: null, restore: null, splashElapsed: false }; +} + +function routeFor(restore: SessionRestore): OnboardingRoute { + return restore.state === "authenticated" ? "workspace" : "login"; +} + +/** Normalisation applied after every event: once the splash's minimum duration + * has elapsed *and* the destination is known, the transition must start. It is + * a separate function so the two conditions converge to the same state in + * either arrival order — the session resolving first or the timer firing first + * must not change the result. */ +function settle(state: OnboardingMachineState): OnboardingMachineState { + if (state.stage === "splash" && state.splashElapsed && state.pending !== null) { + return { ...state, stage: "transition" }; + } + return state; +} + +export function reduceOnboarding( + state: OnboardingMachineState, + event: OnboardingEvent, +): OnboardingMachineState { + switch (event.type) { + case "session": + return settle({ ...state, restore: event.restore, pending: routeFor(event.restore) }); + case "splashElapsed": + return settle({ ...state, splashElapsed: true }); + case "transitionElapsed": + return state.stage === "transition" && state.pending !== null + ? { ...state, stage: state.pending } + : state; + case "authenticated": + // Sign-in/sign-up succeeded: run one transition before landing on the + // workspace rather than cutting to it. + return { + ...state, + restore: { state: "authenticated", session: event.session }, + pending: "workspace", + stage: "transition", + }; + default: + return state; + } +} + +export interface OnboardingTimings { + splashMs: number; + loginTransitionMs: number; + workspaceTransitionMs: number; +} + +export const DEFAULT_ONBOARDING_TIMINGS: OnboardingTimings = { + splashMs: 900, + loginTransitionMs: 400, + workspaceTransitionMs: 300, +}; + +/** Acceptance criterion: under reduced motion the splash is ≤500ms and only + * opacity fades remain. */ +export const REDUCED_MOTION_ONBOARDING_TIMINGS: OnboardingTimings = { + splashMs: 240, + loginTransitionMs: 220, + workspaceTransitionMs: 180, +}; + +export function resolveOnboardingTimings(reducedMotion: boolean): OnboardingTimings { + return reducedMotion ? REDUCED_MOTION_ONBOARDING_TIMINGS : DEFAULT_ONBOARDING_TIMINGS; +} + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +/** An unreadable query counts as "no reduced-motion request": playing the full + * motion is better than pretending the user's setting was honoured. Same + * handling as theme-mode's matchMedia probe. */ +export function readReducedMotionPreference(): boolean { + try { + return window.matchMedia?.(REDUCED_MOTION_QUERY).matches === true; + } catch { + return false; + } +} + +function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(() => readReducedMotionPreference()); + useEffect(() => { + let query: MediaQueryList | null = null; + try { + const candidate = window.matchMedia?.(REDUCED_MOTION_QUERY); + // jsdom's matchMedia has no EventTarget half; reading once is enough, + // crashing is not. + query = candidate !== undefined && typeof candidate.addEventListener === "function" ? candidate : null; + } catch { + query = null; + } + if (query === null) return; + const onChange = (event: MediaQueryListEvent): void => setReduced(event.matches); + query.addEventListener("change", onChange); + return () => query?.removeEventListener("change", onChange); + }, []); + return reduced; +} + +export interface OnboardingGateProps { + children: ReactNode; + /** Defaults to `resolveDefaultAuthAdapter()` (the stub in auth-adapter.ts). */ + adapter?: OnboardingAuthAdapter; + /** Timing override for tests and debugging; production goes through + * resolveOnboardingTimings. */ + timings?: OnboardingTimings; +} + +/** The product name is not translated; it is a constant. */ +const PRODUCT_NAME = "RoleWeave"; + +/** + * The i18n provider lives outside the state machine and inside the gate: the + * splash and login copy must follow the user's persisted locale, and + * `packages/ui`'s `useT` reads the nearest provider. Splitting into an outer + * and an inner component rather than having the gate call `useT()` itself is + * the point — that would read the outer default (zh-CN) and waste the seeding. + */ +export function OnboardingGate(props: OnboardingGateProps) { + // Seeded once, before the workspace's own locale state exists: the first-run + // copy has to follow the persisted choice on its very first paint. + const [locale] = useState(() => seedLocale()); + return ( + + + + ); +} + +function OnboardingGateInner({ children, adapter, timings }: OnboardingGateProps) { + const t = useT(); + const authAdapter = useMemo(() => adapter ?? resolveDefaultAuthAdapter(), [adapter]); + const reducedMotion = useReducedMotion(); + const resolved = timings ?? resolveOnboardingTimings(reducedMotion); + const [state, dispatch] = useReducer(reduceOnboarding, undefined, initialOnboardingState); + const [failure, setFailure] = useState(null); + const [busy, setBusy] = useState(false); + + // Session restore and the splash timer start **in parallel**: the splash + // should play its minimum, but never delay discovering that there is in fact + // no session. + useEffect(() => { + let cancelled = false; + const settleAnonymous = (): void => { + if (!cancelled) dispatch({ type: "session", restore: { state: "anonymous" } }); + }; + try { + void authAdapter.getSession().then((restore) => { + if (!cancelled) dispatch({ type: "session", restore }); + }, settleAnonymous); + } catch { + // A *synchronous* throw needs an exit too: treat it as anonymous so the + // login card still offers a way forward. + settleAnonymous(); + } + return () => { cancelled = true; }; + }, [authAdapter]); + + useEffect(() => { + const id = setTimeout(() => dispatch({ type: "splashElapsed" }), resolved.splashMs); + return () => clearTimeout(id); + }, [resolved.splashMs]); + + const transitionMs = state.pending === "workspace" ? resolved.workspaceTransitionMs : resolved.loginTransitionMs; + useEffect(() => { + if (state.stage !== "transition") return; + const id = setTimeout(() => dispatch({ type: "transitionElapsed" }), transitionMs); + return () => clearTimeout(id); + }, [state.stage, transitionMs]); + + const submit = useCallback((mode: LoginMode, credentials: OnboardingCredentials) => { + setBusy(true); + setFailure(null); + const request = mode === "signIn" + ? authAdapter.signIn(credentials) + : authAdapter.signUp(credentials); + void request.then((result) => { + if (result.ok) dispatch({ type: "authenticated", session: result.session }); + else setFailure(result.failure); + }).catch(() => setFailure("unreachable")).finally(() => setBusy(false)); + }, [authAdapter]); + + const restore = state.restore; + const expiredNotice = restore !== null && restore.state === "expired"; + // Mounting the app as soon as the workspace transition begins (it paints its + // first frame under the splash overlay) makes "entering the workspace" a + // reveal rather than a re-layout. It also hands window control to App's own + // 40px title bar, so the first-run chrome is never duplicated. + const workspaceMounted = state.stage === "workspace" || (state.stage === "transition" && state.pending === "workspace"); + const showSplash = state.stage === "splash" || state.stage === "transition"; + const showLogin = state.stage === "transition" ? state.pending === "login" : state.stage === "login"; + const showSurface = !workspaceMounted || showSplash || showLogin; + + return ( + <> + {/* The workspace route gets no wrapper element: `` stays a direct + child of #root, so App's 100%-height chain and the existing layout + guards are untouched. The first-run layers are fixed overlays. */} + {workspaceMounted ? children : null} + {showSurface ? ( +
+ {!workspaceMounted ? ( +
void window.owb.windowToggleMaximize?.()} + > + +
+ ) : null} + {showSplash ? ( + + ) : null} + {showLogin ? ( + + ) : null} +
+ ) : null} + + ); +} diff --git a/apps/desktop/renderer/src/onboarding/SplashScreen.tsx b/apps/desktop/renderer/src/onboarding/SplashScreen.tsx new file mode 100644 index 00000000..7556d437 --- /dev/null +++ b/apps/desktop/renderer/src/onboarding/SplashScreen.tsx @@ -0,0 +1,53 @@ +/** + * #519 first-run splash: the brand mark enters, holds, and hands off + * (shared-element transition into the login card). + * + * Three phases, expressed by `phase`, interpolated by CSS: + * - `"splash"`: the mark goes scale 0.85→1 and opacity 0→1 over ~350ms with + * cubic-bezier(0.22, 1, 0.36, 1); the wordmark fades in afterwards and + * settles; + * - `"docking"`: the mark shrinks and fades while the login card's own header + * logo cross-fades in (a path the motion spec explicitly allows), and the + * supporting halo disperses; + * - nothing else: under `prefers-reduced-motion` only the opacity fades + * remain, and OnboardingGate shortens the whole sequence. + * + * The progress hint (three pulsing dots) is pure CSS, delayed to ~600ms by + * `animation-delay`, so the last stretch of waiting gets feedback without the + * first 600ms implying that something is stuck. + */ +import { BrandMark } from "./BrandMark"; + +export type SplashPhase = "splash" | "docking"; + +export interface SplashScreenProps { + phase: SplashPhase; + /** Announcement for assistive technology; comes from the i18n catalog. */ + label: string; + /** Secondary line under the wordmark. */ + hint: string; + wordmark: string; +} + +export function SplashScreen({ phase, label, hint, wordmark }: SplashScreenProps) { + return ( +
+ + ); +} diff --git a/apps/desktop/renderer/src/onboarding/auth-adapter.ts b/apps/desktop/renderer/src/onboarding/auth-adapter.ts new file mode 100644 index 00000000..df6e32b3 --- /dev/null +++ b/apps/desktop/renderer/src/onboarding/auth-adapter.ts @@ -0,0 +1,248 @@ +/** + * The first-run authentication seam (#519). + * + * ⚠️ This is NOT an authentication implementation. The real protocol, the + * credential storage format, and per-deployment session scoping all belong to + * #395. This module provides exactly two things: + * + * 1. `OnboardingAuthAdapter` — the narrow interface the first-run flow + * depends on (`getSession` / `signIn` / `signUp` / `signOut`). #395 swaps + * the default implementation behind that same interface, so the two issues + * land independently. + * 2. `createStubAuthAdapter()` — the default implementation, explicitly a + * stub, so the state machine, the motion, and the error branches are + * runnable and testable today. + * + * The stub's behaviour, spelled out so nobody mistakes it for a security + * boundary: + * - the session is kept in localStorage under `owb.onboarding.session.v1`, + * stamped with a deployment id; + * - credentials only exist on the call stack for the duration of the call; + * a password is never persisted; + * - any syntactically plausible email with a long-enough password succeeds — + * nothing is verified or compared; + * - `signOut()` only clears the local record. + * + * In other words it buys usability, not safety. Until #395 lands, no deployment + * may read "the stub let me through" as an authorization result. + */ + +export interface OnboardingSession { + schemaVersion: "onboarding-session.v1"; + /** Session subject; #395 replaces this with a real identity. */ + subject: string; + displayName: string; + email: string; + /** Which deployment the session belongs to — the scoping rule itself is + * #395's; the stub only stores and returns it. */ + deploymentId: string; + issuedAt: number; + /** Unix epoch ms; at or past this instant the session counts as expired + * (acceptance criterion 3). */ + expiresAt: number; +} + +/** The verdict of `getSession()`. Three states rather than `Session | null`: + * "expired" needs a different screen from "never signed in" (a notice on the + * login card), and collapsing them would throw that distinction away. */ +export type SessionRestore = + | { state: "authenticated"; session: OnboardingSession } + | { state: "anonymous" } + | { state: "expired" }; + +export interface OnboardingCredentials { + email: string; + password: string; +} + +export type OnboardingAuthFailure = + | "invalid_credentials" + | "email_taken" + | "unreachable"; + +export type OnboardingAuthResult = + | { ok: true; session: OnboardingSession } + | { ok: false; failure: OnboardingAuthFailure }; + +export interface OnboardingAuthAdapter { + /** Anchor for per-deployment session scoping; the stub uses it to stamp the + * local record. */ + readonly deploymentId: string; + getSession(): Promise; + signIn(credentials: OnboardingCredentials): Promise; + signUp(credentials: OnboardingCredentials): Promise; + signOut(): Promise; +} + +export const SESSION_STORAGE_KEY = "owb.onboarding.session.v1"; +export const STUB_DEPLOYMENT_ID = "local"; +/** Seven days. The stub needs something that can expire, so that the + * expired-session branch is actually reachable and testable. */ +export const STUB_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +export const MIN_PASSWORD_LENGTH = 8; + +/** Storage surface the adapter reads. Writes are optional and separate: with a + * read-only storage the app must still boot, not wedge. */ +export interface SessionStorageLike { + getItem(key: string): string | null; + setItem?(key: string, value: string): void; + removeItem?(key: string): void; +} + +function browserStorage(): SessionStorageLike | null { + try { + // Accessing localStorage throws outright when site data is disabled, so + // its presence cannot be assumed. + return typeof window === "undefined" ? null : (window.localStorage as SessionStorageLike); + } catch { + return null; + } +} + +/** A stored record is UNTRUSTED input: written by a previous run, or hand-edited + * since. Validate field by field and treat anything malformed as "never signed + * in" rather than letting it become a runtime crash. */ +export function parseStoredSession(raw: string | null): OnboardingSession | null { + if (raw === null) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const record = parsed as Record; + if (record.schemaVersion !== "onboarding-session.v1") return null; + const strings = ["subject", "displayName", "email", "deploymentId"] as const; + for (const key of strings) { + const value = record[key]; + if (typeof value !== "string" || value.length === 0) return null; + } + if (typeof record.issuedAt !== "number" || !Number.isFinite(record.issuedAt)) return null; + if (typeof record.expiresAt !== "number" || !Number.isFinite(record.expiresAt)) return null; + return { + schemaVersion: "onboarding-session.v1", + subject: record.subject as string, + displayName: record.displayName as string, + email: record.email as string, + deploymentId: record.deploymentId as string, + issuedAt: record.issuedAt, + expiresAt: record.expiresAt, + }; +} + +/** Stored record → three-state verdict. An expired record is dropped on the way + * out; otherwise every future launch re-judges the same dead session. */ +export function restoreFromStorage( + storage: SessionStorageLike | null, + key: string, + now: number, +): SessionRestore { + if (storage === null) return { state: "anonymous" }; + let raw: string | null = null; + try { + raw = storage.getItem(key); + } catch { + return { state: "anonymous" }; + } + const session = parseStoredSession(raw); + if (session === null) return { state: "anonymous" }; + if (session.expiresAt <= now) { + try { + storage.removeItem?.(key); + } catch { + // Failing to clear it only means judging it again next launch; the route + // for this launch is already decided. + } + return { state: "expired" }; + } + return { state: "authenticated", session }; +} + +export interface StubAuthAdapterOptions { + deploymentId?: string; + storage?: SessionStorageLike | null; + storageKey?: string; + sessionTtlMs?: number; + /** Injectable clock: expiry has to be testable without waiting for it. */ + now?: () => number; + /** Injectable latency, used to render the busy state. Defaults to 0 so tests + * never wait. */ + latencyMs?: number; +} + +function displayNameFromEmail(email: string): string { + const local = email.slice(0, email.indexOf("@")); + return local.length > 0 ? local : email; +} + +/** The default implementation — a STUB. See the file header. */ +export function createStubAuthAdapter(options: StubAuthAdapterOptions = {}): OnboardingAuthAdapter { + const deploymentId = options.deploymentId ?? STUB_DEPLOYMENT_ID; + const storageKey = options.storageKey ?? SESSION_STORAGE_KEY; + const ttl = options.sessionTtlMs ?? STUB_SESSION_TTL_MS; + const now = options.now ?? (() => Date.now()); + const latencyMs = options.latencyMs ?? 0; + const storage = options.storage === undefined ? browserStorage() : options.storage; + + const wait = (): Promise => latencyMs > 0 + ? new Promise((resolve) => setTimeout(resolve, latencyMs)) + : Promise.resolve(); + + const issue = (email: string): OnboardingSession => { + const issuedAt = now(); + return { + schemaVersion: "onboarding-session.v1", + subject: `${deploymentId}:${email}`, + displayName: displayNameFromEmail(email), + email, + deploymentId, + issuedAt, + expiresAt: issuedAt + ttl, + }; + }; + + const persist = (session: OnboardingSession): void => { + try { + storage?.setItem?.(storageKey, JSON.stringify(session)); + } catch { + // Failing to persist only costs the next launch its "remember me"; it must + // not fail the sign-in the user just performed. + } + }; + + return { + deploymentId, + async getSession(): Promise { + await wait(); + return restoreFromStorage(storage, storageKey, now()); + }, + async signIn(credentials: OnboardingCredentials): Promise { + await wait(); + // The stub compares nothing: it has no credential store to compare + // against. See the file header for what that does and does not mean. + const session = issue(credentials.email); + persist(session); + return { ok: true, session }; + }, + async signUp(credentials: OnboardingCredentials): Promise { + await wait(); + const session = issue(credentials.email); + persist(session); + return { ok: true, session }; + }, + async signOut(): Promise { + try { + storage?.removeItem?.(storageKey); + } catch { + // Same as above: an unclearable record is not an error. + } + }, + }; +} + +/** The one the application uses by default. #395 replaces this with the real + * per-deployment implementation. */ +export function resolveDefaultAuthAdapter(): OnboardingAuthAdapter { + return createStubAuthAdapter(); +} diff --git a/apps/desktop/renderer/src/onboarding/onboarding.css b/apps/desktop/renderer/src/onboarding/onboarding.css new file mode 100644 index 00000000..b844ecc3 --- /dev/null +++ b/apps/desktop/renderer/src/onboarding/onboarding.css @@ -0,0 +1,444 @@ +/* #519 first-run layout and motion (splash -> login card). + * + * Two hard constraints run through this file: + * 1. **Compositor properties only** (transform / opacity / filter). That is + * why the wordmark's "letter-spacing settle" is expressed as a scale() + * convergence and the card's "16px rise" as a translateY — animating + * letter-spacing or top/left would run on the layout thread and drop + * frames exactly while the user is waiting. + * 2. **Colour comes from tokens** (`--ui-*`), so both themes hold. The one + * exception is the `--owb-brandmark-*` group below: those are the brand + * asset's own colours, they must match + * `branding/roleweave/roleweave-icon.svg` value for value, and + * apps/desktop/test/onboarding-brand-mark.test.cjs pins them. + */ + +.owb-onboarding { + /* Brand mark (branding/roleweave/roleweave-icon.svg) palette. Editing these + is editing the product icon; the guard test will say so. */ + --owb-brandmark-tile: #141820; + --owb-brandmark-edge: #2c3a52; + --owb-brandmark-role: #722ed1; + --owb-brandmark-weave: #1677ff; + --owb-brandmark-node: #ffffff; + + position: fixed; + inset: 0; + z-index: 900; + display: grid; + /* Same tracks as .owb-app: a 40px title bar over the content. The splash + stays under the title bar, so the drag/close hit area exists from the very + first frame instead of waiting for App to mount. */ + grid-template-rows: 40px minmax(0, 1fr); + min-height: 0; + overflow: hidden; + color: var(--ui-foreground); + font-family: var(--ui-font-sans); + /* The brand ground is painted on the first frame; together with + BrowserWindow's backgroundColor that removes the white flash. */ + background: + radial-gradient(circle at 50% 38%, color-mix(in srgb, var(--ui-primary) 14%, transparent), transparent 42%), + var(--ui-canvas); + /* The overlay yields drag events; __chrome below owns the drag region. */ + -webkit-app-region: no-drag; +} + +/* Transition into the workspace: the whole layer fades out to reveal the app + that is already mounted. Opacity only. */ +.owb-onboarding[data-stage="transition"][data-pending="workspace"] { + opacity: 0; + transition: opacity var(--owb-transition-ms, 300ms) var(--ui-ease); + pointer-events: none; +} + +/* Styled as the same title bar the workspace draws (.owb-wintitle), so the + 40px strip does not visibly jump when App takes the window over. */ +.owb-onboarding__chrome { + display: flex; + align-items: center; + gap: 12px; + padding: 0 14px; + border-bottom: 1px solid var(--ui-border); + background: var(--ui-surface-raised); + -webkit-app-region: drag; +} + +.owb-onboarding__chrome .owb-wintitle__controls { + -webkit-app-region: no-drag; +} + +/* ---------- splash ---------- */ + +.owb-splash { + /* Pinned to the second track (under the title bar): by the time the workspace + transition starts the chrome is gone, and an explicit grid-row keeps the + splash out of the 40px first track. */ + grid-row: 2; + position: relative; + display: grid; + grid-template-rows: minmax(0, 1fr) auto auto auto minmax(0, 1fr); + justify-items: center; + align-content: center; + gap: 14px; + padding: 24px; + text-align: center; +} + +.owb-splash__halo { + position: absolute; + top: 50%; + left: 50%; + width: 320px; + height: 320px; + margin: -160px 0 0 -160px; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--ui-primary) 26%, transparent), transparent 68%); + opacity: 0.9; + animation: owb-onboarding-halo-in 700ms var(--ui-ease) both; + pointer-events: none; +} + +.owb-splash__mark { + grid-row: 2; + animation: owb-onboarding-mark-in 350ms cubic-bezier(0.22, 1, 0.36, 1) both; + will-change: transform, opacity; +} + +.owb-brandmark { + display: block; +} + +.owb-brandmark__tile, +.owb-brandmark__cut { + fill: var(--owb-brandmark-tile); +} + +.owb-brandmark__edge { + fill: none; + stroke: var(--owb-brandmark-edge); + stroke-width: 8; +} + +.owb-brandmark__role { + fill: var(--owb-brandmark-role); +} + +.owb-brandmark__weave { + fill: var(--owb-brandmark-weave); +} + +.owb-brandmark__node { + fill: var(--owb-brandmark-node); +} + +.owb-brandmark--splash { + width: 96px; + height: 96px; + filter: drop-shadow(0 18px 34px color-mix(in srgb, var(--owb-brandmark-tile) 42%, transparent)); +} + +.owb-splash__wordmark { + grid-row: 3; + margin: 0; + font: 600 26px/1.2 var(--ui-font-sans); + color: var(--ui-foreground); + animation: owb-onboarding-wordmark-in 420ms 160ms var(--ui-ease) both; +} + +.owb-splash__hint { + grid-row: 4; + margin: 0; + min-height: 1.2em; + font-size: var(--ui-text-sm, 13px); + color: var(--ui-foreground-muted); + animation: owb-onboarding-fade-in 260ms 320ms linear both; +} + +/* Three pulsing dots, delayed to 600ms: the last stretch of waiting gets + feedback without the first few hundred milliseconds implying that something + is stuck. Pure CSS, no requestAnimationFrame timer needed. */ +.owb-splash__progress { + grid-row: 5; + align-self: start; + display: flex; + gap: 6px; + margin-top: 6px; + opacity: 0; + animation: owb-onboarding-fade-in 200ms 600ms linear both; +} + +.owb-splash__progress i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ui-primary); + animation: owb-onboarding-dot-pulse 1.1s 600ms ease-in-out infinite; +} + +.owb-splash__progress i:nth-child(2) { + animation-delay: 750ms; +} + +.owb-splash__progress i:nth-child(3) { + animation-delay: 900ms; +} + +/* The shared-element beat: the mark shrinks and fades while the login card's own + header logo cross-fades in (a path the motion spec allows), and the halo + disperses. Everything here is transform/opacity/filter only. */ +.owb-splash[data-phase="docking"] .owb-splash__mark { + transform: scale(0.46) translateY(-14px); + opacity: 0; + filter: blur(2px); + transition: + transform var(--owb-transition-ms, 400ms) cubic-bezier(0.22, 1, 0.36, 1), + opacity var(--owb-transition-ms, 400ms) linear, + filter var(--owb-transition-ms, 400ms) linear; +} + +.owb-splash[data-phase="docking"] .owb-splash__halo { + transform: scale(1.5); + opacity: 0; + transition: transform var(--owb-transition-ms, 400ms) var(--ui-ease), opacity var(--owb-transition-ms, 400ms) linear; +} + +.owb-splash[data-phase="docking"] .owb-splash__wordmark, +.owb-splash[data-phase="docking"] .owb-splash__hint, +.owb-splash[data-phase="docking"] .owb-splash__progress { + opacity: 0; + transition: opacity calc(var(--owb-transition-ms, 400ms) / 2) linear; +} + +@keyframes owb-onboarding-halo-in { + from { transform: scale(0.82); opacity: 0; } + to { transform: scale(1); opacity: 0.9; } +} + +@keyframes owb-onboarding-mark-in { + from { transform: scale(0.85); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +/* A compositor version of a "letter-spacing settle": uniform scale convergence + plus a fade. */ +@keyframes owb-onboarding-wordmark-in { + from { transform: scale(1.045); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +@keyframes owb-onboarding-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes owb-onboarding-dot-pulse { + 0%, 100% { transform: scale(0.7); opacity: 0.35; } + 50% { transform: scale(1); opacity: 1; } +} + +/* ---------- login / register card ---------- */ + +.owb-login { + grid-row: 2; + display: grid; + place-items: center; + padding: 24px; + min-height: 0; + overflow: auto; +} + +.owb-login__card { + width: min(400px, 100%); + padding: 28px 28px 20px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-lg); + background: var(--ui-surface); + box-shadow: var(--ui-shadow-lg); + animation: owb-onboarding-card-in var(--owb-transition-ms, 400ms) cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes owb-onboarding-card-in { + from { transform: translateY(16px); opacity: 0; filter: blur(6px); } + to { transform: translateY(0); opacity: 1; filter: blur(0); } +} + +.owb-login__title { + margin: 0; + font: 600 20px/1.25 var(--ui-font-sans); +} + +.owb-login__subtitle { + margin: 6px 0 18px; + font-size: var(--ui-text-sm, 13px); + color: var(--ui-foreground-muted); +} + +.owb-login__tabs { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 4px; + padding: 3px; + margin-bottom: 16px; + border-radius: var(--ui-radius-md); + background: var(--ui-surface-inset); +} + +.owb-login__tab { + height: 32px; + border: 0; + border-radius: calc(var(--ui-radius-md) - 2px); + background: transparent; + color: var(--ui-foreground-muted); + font: 500 13px/1 var(--ui-font-sans); + cursor: pointer; +} + +.owb-login__tab[aria-selected="true"] { + background: var(--ui-surface); + color: var(--ui-foreground); + box-shadow: var(--ui-shadow-sm); +} + +.owb-login__notice { + margin: 0 0 14px; + padding: 9px 11px; + border-radius: var(--ui-radius-sm); + font-size: var(--ui-text-sm, 13px); +} + +.owb-login__notice[data-variant="expired"] { + background: var(--ui-warning-soft, var(--ui-surface-inset)); + color: var(--ui-warning-strong, var(--ui-foreground)); +} + +.owb-login__notice[data-variant="failure"] { + background: var(--ui-danger-soft, var(--ui-surface-inset)); + color: var(--ui-danger-strong, var(--ui-danger)); +} + +.owb-login__form { + display: grid; + gap: 14px; +} + +.owb-login__field { + display: grid; + gap: 6px; +} + +.owb-login__field label { + font-size: var(--ui-text-sm, 13px); + color: var(--ui-foreground-muted); +} + +.owb-login__field input { + height: 34px; + padding: 0 10px; + border: 1px solid var(--ui-border-strong); + border-radius: var(--ui-radius-sm); + background: var(--ui-surface-inset); + color: var(--ui-foreground); + font: 400 13px/1 var(--ui-font-sans); +} + +.owb-login__field input:focus-visible { + outline: 2px solid var(--ui-focus); + outline-offset: 1px; +} + +.owb-login__field input[aria-invalid="true"] { + border-color: var(--ui-danger); +} + +.owb-login__field-error { + margin: 0; + font-size: var(--ui-text-xs, 12px); + color: var(--ui-danger); +} + +.owb-login__hint { + margin: 0; + font-size: var(--ui-text-xs, 12px); + color: var(--ui-foreground-subtle); +} + +.owb-login__submit { + height: 36px; + margin-top: 2px; + border: 0; + border-radius: var(--ui-radius-sm); + background: var(--ui-primary); + color: var(--ui-primary-foreground); + font: 600 13px/1 var(--ui-font-sans); + cursor: pointer; + transition: background-color var(--ui-duration-fast) var(--ui-ease); +} + +.owb-login__submit:hover:not(:disabled) { + background: var(--ui-primary-hover); +} + +.owb-login__submit:disabled { + cursor: progress; + opacity: 0.72; +} + +.owb-login__submit:focus-visible { + outline: 2px solid var(--ui-focus); + outline-offset: 2px; +} + +.owb-login__link { + justify-self: center; + margin-top: 12px; + border: 0; + background: none; + color: var(--ui-primary); + font: 500 12px/1 var(--ui-font-sans); + cursor: pointer; +} + +.owb-login__link:disabled { + color: var(--ui-foreground-subtle); + cursor: not-allowed; +} + +.owb-login__legal { + margin: 16px 0 0; + padding-top: 12px; + border-top: 1px solid var(--ui-border); + font-size: var(--ui-text-xs, 12px); + color: var(--ui-foreground-subtle); + text-align: center; +} + +/* ---------- prefers-reduced-motion ---------- */ +/* Fades only: no translation, scale, or blur. The duration is shortened on the + JS side as well (REDUCED_MOTION_ONBOARDING_TIMINGS: splash 240ms, inside the + ≤500ms acceptance criterion). */ +@media (prefers-reduced-motion: reduce) { + .owb-splash__halo, + .owb-splash__mark, + .owb-login__card { + animation-name: owb-onboarding-fade-in; + animation-duration: 200ms; + animation-delay: 0ms; + } + + .owb-splash[data-phase="docking"] .owb-splash__mark, + .owb-splash[data-phase="docking"] .owb-splash__halo { + transform: none; + filter: none; + } + + .owb-splash__progress i, + .owb-onboarding[data-reduced-motion="true"] .owb-splash__progress i { + animation: none; + opacity: 0.7; + } + + .owb-onboarding[data-stage="transition"][data-pending="workspace"] { + transition-duration: var(--owb-transition-ms, 180ms); + } +} diff --git a/apps/desktop/renderer/test/onboarding-entry.test.tsx b/apps/desktop/renderer/test/onboarding-entry.test.tsx new file mode 100644 index 00000000..6fe13198 --- /dev/null +++ b/apps/desktop/renderer/test/onboarding-entry.test.tsx @@ -0,0 +1,70 @@ +/** + * #519: where the first-run gate is mounted. + * + * Three boundaries to prove: + * 1. an ordinary launch is wrapped by the gate (the splash comes first and the + * workspace does not race ahead); + * 2. the packaged smoke entry bypasses it — that is a static marker the + * external staging tool scores, it must make zero bridge calls, and it must + * not turn into a timed startup-animation test; + * 3. the layout harness bypasses it too, because it measures the real + * workspace on a machine that has no saved session. + * 2 and 3 are the only places this change could quietly break packaging + * acceptance, so they are pinned here. + */ +import { act, render, screen } from "@testing-library/react"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +describe("#519 renderer root entry", () => { + let rendererRootElement: typeof import("../src/main").rendererRootElement; + + beforeAll(async () => { + document.body.innerHTML = '
'; + await act(async () => { + ({ rendererRootElement } = await import("../src/main")); + }); + // main.tsx mounted its own tree during import; drop it so the assertions + // below only see what this file renders. + document.getElementById("root")?.remove(); + }); + + it("wraps an ordinary launch in the first-run gate", () => { + const App = vi.fn(() =>
workspace body
); + render(rendererRootElement({ protocol: "http:", search: "" }, App)); + expect(screen.getByRole("status", { name: "RoleWeave 正在启动" })).toBeInTheDocument(); + expect(screen.queryByText("workspace body")).toBeNull(); + }); + + it("leaves the packaged smoke entry ungated and free of bridge calls", () => { + const bridge = { status: vi.fn(), orgTree: vi.fn(), onEvent: vi.fn() }; + function BridgeUsingApp() { + bridge.status(); + bridge.orgTree(); + bridge.onEvent(); + return
workspace body
; + } + render(rendererRootElement( + { protocol: "file:", search: `?orgWorkbenchPackagedSmoke=${"a".repeat(64)}` }, + BridgeUsingApp, + )); + expect(screen.getByText("RoleWeave clean-staging renderer")).toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "RoleWeave 正在启动" })).toBeNull(); + for (const call of Object.values(bridge)) expect(call).not.toHaveBeenCalled(); + }); + + it("leaves the layout harness ungated so it can measure a fresh install", () => { + render(rendererRootElement({ protocol: "file:", search: "?orgWorkbenchLayoutSmoke=1" }, () => ( +
workspace body
+ ))); + expect(screen.getByText("workspace body")).toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "RoleWeave 正在启动" })).toBeNull(); + }); + + it("ignores a look-alike layout parameter", () => { + render(rendererRootElement({ protocol: "file:", search: "?orgWorkbenchLayoutSmoke=0" }, () => ( +
workspace body
+ ))); + expect(screen.getByRole("status", { name: "RoleWeave 正在启动" })).toBeInTheDocument(); + expect(screen.queryByText("workspace body")).toBeNull(); + }); +}); diff --git a/apps/desktop/renderer/test/onboarding-gate.test.tsx b/apps/desktop/renderer/test/onboarding-gate.test.tsx new file mode 100644 index 00000000..d07c317d --- /dev/null +++ b/apps/desktop/renderer/test/onboarding-gate.test.tsx @@ -0,0 +1,364 @@ +/** + * #519 first-run gate. + * + * Two layers: the **pure reducer** enumerates the routing rules (the substance + * of this change), and the **component with fake timers** proves the timeline + * and the DOM are wired to those rules. They are separate because the reducer + * can cover orderings while the component can only show that one ordering was + * wired correctly. + */ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_ONBOARDING_TIMINGS, + OnboardingGate, + REDUCED_MOTION_ONBOARDING_TIMINGS, + initialOnboardingState, + readReducedMotionPreference, + reduceOnboarding, + resolveOnboardingTimings, + type OnboardingEvent, + type OnboardingMachineState, +} from "../src/onboarding/OnboardingGate"; +import { + createStubAuthAdapter, + type OnboardingAuthAdapter, + type OnboardingSession, + type SessionStorageLike, +} from "../src/onboarding/auth-adapter"; + +const SESSION: OnboardingSession = { + schemaVersion: "onboarding-session.v1", + subject: "local:dev@example.com", + displayName: "dev", + email: "dev@example.com", + deploymentId: "local", + issuedAt: 1_700_000_000_000, + expiresAt: 1_700_000_600_000, +}; + +const NOW = 1_700_000_000_000; + +function run(events: OnboardingEvent[]): OnboardingMachineState { + return events.reduce(reduceOnboarding, initialOnboardingState()); +} + +describe("#519 onboarding gate state machine", () => { + it("starts on the splash with no route decided", () => { + expect(initialOnboardingState()).toEqual({ + stage: "splash", + pending: null, + restore: null, + splashElapsed: false, + }); + }); + + it("routes a saved session to the workspace and a fresh install to login", () => { + const saved = run([ + { type: "session", restore: { state: "authenticated", session: SESSION } }, + { type: "splashElapsed" }, + { type: "transitionElapsed" }, + ]); + const fresh = run([ + { type: "session", restore: { state: "anonymous" } }, + { type: "splashElapsed" }, + { type: "transitionElapsed" }, + ]); + expect(saved.stage).toBe("workspace"); + expect(fresh.stage).toBe("login"); + }); + + it("sends an expired session to login rather than to the workspace", () => { + const final = run([ + { type: "session", restore: { state: "expired" } }, + { type: "splashElapsed" }, + { type: "transitionElapsed" }, + ]); + expect(final.stage).toBe("login"); + }); + + it("converges whether the session or the splash timer arrives first", () => { + const sessionFirst = run([ + { type: "session", restore: { state: "anonymous" } }, + { type: "splashElapsed" }, + ]); + const timerFirst = run([ + { type: "splashElapsed" }, + { type: "session", restore: { state: "anonymous" } }, + ]); + expect(sessionFirst).toEqual(timerFirst); + expect(sessionFirst.stage).toBe("transition"); + }); + + it("never leaves the splash before the route is known, and never skips its minimum", () => { + expect(run([{ type: "splashElapsed" }])).toMatchObject({ stage: "splash", pending: null }); + expect(run([{ type: "session", restore: { state: "anonymous" } }])).toMatchObject({ + stage: "splash", + splashElapsed: false, + }); + }); + + it("moves to the workspace on a successful sign-in without a hard cut", () => { + const atLogin = run([ + { type: "session", restore: { state: "anonymous" } }, + { type: "splashElapsed" }, + { type: "transitionElapsed" }, + ]); + expect(atLogin.stage).toBe("login"); + const signingIn = reduceOnboarding(atLogin, { type: "authenticated", session: SESSION }); + expect(signingIn).toMatchObject({ stage: "transition", pending: "workspace" }); + expect(reduceOnboarding(signingIn, { type: "transitionElapsed" }).stage).toBe("workspace"); + }); + + it("ignores a transition tick when no transition is running", () => { + const state = initialOnboardingState(); + expect(reduceOnboarding(state, { type: "transitionElapsed" })).toBe(state); + }); +}); + +describe("#519 onboarding timings", () => { + it("keeps the cold-launch path inside the 1.3s acceptance budget", () => { + expect(DEFAULT_ONBOARDING_TIMINGS.splashMs + DEFAULT_ONBOARDING_TIMINGS.loginTransitionMs).toBeLessThanOrEqual(1300); + }); + + it("keeps the warm-launch path inside the 1.5s acceptance budget", () => { + expect(DEFAULT_ONBOARDING_TIMINGS.splashMs + DEFAULT_ONBOARDING_TIMINGS.workspaceTransitionMs).toBeLessThanOrEqual(1500); + }); + + it("shortens the splash to 500ms or less under prefers-reduced-motion", () => { + expect(REDUCED_MOTION_ONBOARDING_TIMINGS.splashMs).toBeLessThanOrEqual(500); + expect(resolveOnboardingTimings(true)).toBe(REDUCED_MOTION_ONBOARDING_TIMINGS); + expect(resolveOnboardingTimings(false)).toBe(DEFAULT_ONBOARDING_TIMINGS); + }); + + it("reads the OS preference, defaulting to 'no preference' when it is unreadable", () => { + const original = window.matchMedia; + const reducedMotionQuery = (query: string) => ({ + matches: query.includes("reduced-motion"), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }); + try { + window.matchMedia = reducedMotionQuery as unknown as typeof window.matchMedia; + expect(readReducedMotionPreference()).toBe(true); + window.matchMedia = (() => { throw new Error("no matchMedia"); }) as unknown as typeof window.matchMedia; + expect(readReducedMotionPreference()).toBe(false); + } finally { + window.matchMedia = original; + } + }); +}); + +function memoryStorage(seed: Record = {}): SessionStorageLike & { readonly map: Map } { + const map = new Map(Object.entries(seed)); + return { + map, + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value), + removeItem: (key) => void map.delete(key), + }; +} + +function storedSession(expiresAt: number): SessionStorageLike { + return memoryStorage({ "owb.onboarding.session.v1": JSON.stringify({ ...SESSION, expiresAt }) }); +} + +function adapterWith(overrides: Partial = {}): OnboardingAuthAdapter { + const base = createStubAuthAdapter({ storage: memoryStorage(), now: () => NOW }); + return { ...base, ...overrides }; +} + +const FAST_TIMINGS = { splashMs: 900, loginTransitionMs: 400, workspaceTransitionMs: 300 }; +const SPLASH_NAME = "RoleWeave 正在启动"; +const TABS_NAME = "登录或创建账号"; + +/** Advance timers and microtasks together, so a resolved promise is never left + * unflushed by act(). */ +async function advance(ms: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +/** "登录" names both the tab and the submit button in the card; take the button + * inside the given container. */ +function buttonNamed(container: HTMLElement, name: string): HTMLButtonElement { + const match = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === name); + if (!match) throw new Error(`no button named ${name} in the container`); + return match; +} + +async function submitCard(email: string, password: string): Promise { + fireEvent.change(screen.getByLabelText("邮箱"), { target: { value: email } }); + fireEvent.change(screen.getByLabelText("密码"), { target: { value: password } }); + await act(async () => { + fireEvent.click(buttonNamed(screen.getByRole("tabpanel"), "登录")); + }); +} + +describe("#519 onboarding gate wiring", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("plays the splash first and reveals the login card only after the whole sequence", async () => { + render( + +
workspace body
+
, + ); + await advance(0); + expect(screen.getByRole("status", { name: SPLASH_NAME })).toBeInTheDocument(); + expect(screen.queryByRole("tablist")).toBeNull(); + // The workspace must not mount early on the cold-launch path, or the + // signed-out state would expose the internal UI. + expect(screen.queryByText("workspace body")).toBeNull(); + + await advance(FAST_TIMINGS.splashMs - 1); + expect(screen.queryByRole("tablist")).toBeNull(); + + await advance(1); + // Mid-transition: the mark is still there (docking) and the card has + // already faded in. + expect(screen.getByRole("status", { name: SPLASH_NAME })).toBeInTheDocument(); + expect(screen.getByRole("tablist", { name: TABS_NAME })).toBeInTheDocument(); + + await advance(FAST_TIMINGS.loginTransitionMs); + expect(screen.queryByRole("status", { name: SPLASH_NAME })).toBeNull(); + expect(screen.getByRole("tablist", { name: TABS_NAME })).toBeInTheDocument(); + expect(screen.queryByText("workspace body")).toBeNull(); + }); + + it("plays the splash briefly and goes straight to the workspace when a session is saved", async () => { + const adapter = createStubAuthAdapter({ storage: storedSession(NOW + 600_000), now: () => NOW }); + render( + +
workspace body
+
, + ); + await advance(0); + expect(screen.getByRole("status", { name: SPLASH_NAME })).toBeInTheDocument(); + expect(screen.queryByRole("tablist")).toBeNull(); + + await advance(FAST_TIMINGS.splashMs); + // The transition mounts the workspace (it paints its first frame under the + // overlay); the login card never appears on this path. + expect(screen.getByText("workspace body")).toBeInTheDocument(); + expect(screen.queryByRole("tablist")).toBeNull(); + + await advance(FAST_TIMINGS.workspaceTransitionMs); + expect(screen.queryByRole("status", { name: SPLASH_NAME })).toBeNull(); + expect(screen.getByText("workspace body")).toBeInTheDocument(); + }); + + it("lands an expired session on the login card with an inline notice, never a modal", async () => { + const storage = storedSession(NOW - 1_000); + const adapter = createStubAuthAdapter({ storage, now: () => NOW }); + render( + +
workspace body
+
, + ); + await advance(FAST_TIMINGS.splashMs + FAST_TIMINGS.loginTransitionMs); + expect(screen.getByText("上一次的登录已过期,请重新登录。")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).toBeNull(); + // The expired record is dropped on the spot instead of being re-judged on + // every future launch. + expect(storage.map.has("owb.onboarding.session.v1")).toBe(false); + }); + + it("finishes a reduced-motion launch inside the shortened splash budget", async () => { + const original = window.matchMedia; + window.matchMedia = ((query: string) => ({ + matches: query.includes("reduced-motion"), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + try { + render( + +
workspace body
+
, + ); + await advance(0); + expect(screen.getByRole("status", { name: SPLASH_NAME })).toBeInTheDocument(); + // Under the default timings the gate would still be on the splash here; + // reduced motion has to have decided the route already. + await advance(REDUCED_MOTION_ONBOARDING_TIMINGS.splashMs); + await advance(REDUCED_MOTION_ONBOARDING_TIMINGS.loginTransitionMs); + expect(screen.getByRole("tablist", { name: TABS_NAME })).toBeInTheDocument(); + } finally { + window.matchMedia = original; + } + }); + + it("enters the workspace after a successful sign-in", async () => { + const signIn = vi.fn().mockResolvedValue({ ok: true, session: SESSION }); + render( + +
workspace body
+
, + ); + await advance(FAST_TIMINGS.splashMs + FAST_TIMINGS.loginTransitionMs); + await submitCard("dev@example.com", "correct-horse"); + expect(signIn).toHaveBeenCalledWith({ email: "dev@example.com", password: "correct-horse" }); + await advance(0); + expect(screen.getByText("workspace body")).toBeInTheDocument(); + expect(screen.queryByRole("tablist")).toBeNull(); + }); + + it("falls back to the login card when the adapter cannot answer at all", async () => { + render( + +
workspace body
+
, + ); + await advance(FAST_TIMINGS.splashMs + FAST_TIMINGS.loginTransitionMs); + expect(screen.getByRole("tablist", { name: TABS_NAME })).toBeInTheDocument(); + expect(screen.queryByText("workspace body")).toBeNull(); + }); + + it("reports a rejected sign-in inline without leaving the card", async () => { + const signIn = vi.fn().mockResolvedValue({ ok: false, failure: "invalid_credentials" }); + render( + +
workspace body
+
, + ); + await advance(FAST_TIMINGS.splashMs + FAST_TIMINGS.loginTransitionMs); + await submitCard("dev@example.com", "nope-nope"); + await advance(0); + expect(screen.getByRole("alert")).toHaveTextContent("邮箱或密码不正确。"); + expect(screen.getByRole("tablist", { name: TABS_NAME })).toBeInTheDocument(); + expect(screen.queryByText("workspace body")).toBeNull(); + }); +}); + +describe("#519 onboarding gate under the default (unmocked) clock", () => { + it("does not mount the workspace during the splash of a cold launch", async () => { + // Real timers on purpose: this proves "the first frame of a cold launch is + // not the workspace" without depending on a fake clock. + render( + +
workspace body
+
, + ); + await act(async () => { await Promise.resolve(); }); + expect(screen.queryByText("workspace body")).toBeNull(); + expect(screen.getByRole("status", { name: SPLASH_NAME })).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/renderer/test/onboarding-login.test.tsx b/apps/desktop/renderer/test/onboarding-login.test.tsx new file mode 100644 index 00000000..cdacd9e9 --- /dev/null +++ b/apps/desktop/renderer/test/onboarding-login.test.tsx @@ -0,0 +1,173 @@ +/** + * #519 login / register card. + * + * Guards three things real products drop easily: + * 1. no autofocus until the transition is over (the keyboard must not pop up + * mid-animation); + * 2. both forms report errors inline **and** invalid input sends no request; + * 3. every user-visible string comes from the zh/en catalogs (the English + * localization is actually rendered here, not just assumed). + */ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { OwbI18nProvider } from "@roleweave/ui"; +import { LoginScreen, validateCredentials } from "../src/onboarding/LoginScreen"; +import { MIN_PASSWORD_LENGTH } from "../src/onboarding/auth-adapter"; + +function renderCard(overrides: Partial[0]> = {}) { + const props = { + settled: true, + busy: false, + failure: null, + expiredNotice: false, + onSubmit: vi.fn(), + ...overrides, + }; + return { props, ...render() }; +} + +const panel = () => screen.getByRole("tabpanel"); +const submit = () => Array.from(panel().querySelectorAll("button")).find((b) => b.type === "submit") as HTMLButtonElement; +const tab = (name: string) => screen.getByRole("tab", { name }); +const fill = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } }); + +describe("#519 credential validation", () => { + it("requires a syntactically plausible email", () => { + expect(validateCredentials("signIn", { email: "", password: "whatever" }).email).toBe("onboarding.login.errEmailRequired"); + expect(validateCredentials("signIn", { email: " ", password: "whatever" }).email).toBe("onboarding.login.errEmailRequired"); + expect(validateCredentials("signIn", { email: "dev@example", password: "whatever" }).email).toBe("onboarding.login.errEmailFormat"); + expect(validateCredentials("signIn", { email: "dev@example.com", password: "whatever" }).email).toBeUndefined(); + }); + + it("only enforces the length floor on the sign-up password", () => { + const short = "a".repeat(MIN_PASSWORD_LENGTH - 1); + expect(validateCredentials("signUp", { email: "dev@example.com", password: short }).password).toBe("onboarding.login.errPasswordShort"); + expect(validateCredentials("signUp", { email: "dev@example.com", password: "a".repeat(MIN_PASSWORD_LENGTH) }).password).toBeUndefined(); + // Sign-in only requires "not empty": password policy belongs to the server, + // and the client must not reject an existing account on its behalf. + expect(validateCredentials("signIn", { email: "dev@example.com", password: short }).password).toBeUndefined(); + expect(validateCredentials("signIn", { email: "dev@example.com", password: "" }).password).toBe("onboarding.login.errPasswordRequired"); + }); +}); + +describe("#519 login card", () => { + it("holds focus back until the transition has finished", () => { + const { rerender } = render( + , + ); + expect(document.activeElement).not.toBe(screen.getByLabelText("邮箱")); + + rerender(); + expect(document.activeElement).toBe(screen.getByLabelText("邮箱")); + }); + + it("reports empty and malformed input inline and sends nothing", () => { + const { props } = renderCard(); + fireEvent.click(submit()); + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(screen.getByText("请输入邮箱。")).toBeInTheDocument(); + expect(screen.getByText("请输入密码。")).toBeInTheDocument(); + + fill("邮箱", "not-an-email"); + fireEvent.click(submit()); + expect(screen.getByText("邮箱格式不正确。")).toBeInTheDocument(); + expect(props.onSubmit).not.toHaveBeenCalled(); + }); + + it("clears a field's error as soon as the user edits it again", () => { + renderCard(); + fireEvent.click(submit()); + expect(screen.getByText("请输入邮箱。")).toBeInTheDocument(); + fill("邮箱", "dev@example.com"); + expect(screen.queryByText("请输入邮箱。")).toBeNull(); + }); + + it("reaches sign-in and sign-up from the one card, and keeps the password out of the switch", () => { + const { props } = renderCard(); + fill("邮箱", "dev@example.com"); + fill("密码", "correct-horse"); + fireEvent.click(tab("创建账号")); + expect(tab("创建账号")).toHaveAttribute("aria-selected", "true"); + expect((screen.getByLabelText("密码") as HTMLInputElement).value).toBe(""); + expect(screen.getByText(`至少 ${MIN_PASSWORD_LENGTH} 位字符。`)).toBeInTheDocument(); + + fill("密码", "a".repeat(MIN_PASSWORD_LENGTH)); + fireEvent.click(submit()); + expect(props.onSubmit).toHaveBeenCalledWith("signUp", { email: "dev@example.com", password: "a".repeat(MIN_PASSWORD_LENGTH) }); + }); + + it("switches tabs from the keyboard", () => { + renderCard(); + fireEvent.keyDown(tab("登录"), { key: "ArrowRight" }); + expect(tab("创建账号")).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(tab("创建账号"), { key: "ArrowLeft" }); + expect(tab("登录")).toHaveAttribute("aria-selected", "true"); + }); + + it("shows the expired-session notice without blocking the card", () => { + renderCard({ expiredNotice: true }); + expect(screen.getByText("上一次的登录已过期,请重新登录。")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(submit()).toBeEnabled(); + }); + + it("maps every adapter failure to an inline alert and stays on the card", () => { + const cases = [ + ["invalid_credentials", "邮箱或密码不正确。"], + ["email_taken", "该邮箱已注册,请直接登录。"], + ["unreachable", "无法连接认证服务,请稍后重试。"], + ] as const; + for (const [failure, copy] of cases) { + const view = renderCard({ failure }); + expect(screen.getByRole("alert")).toHaveTextContent(copy); + expect(screen.queryByRole("dialog")).toBeNull(); + view.unmount(); + } + }); + + it("disables the submit button while a request is in flight", () => { + renderCard({ busy: true }); + expect(submit()).toBeDisabled(); + expect(submit()).toHaveAttribute("aria-busy", "true"); + }); + + it("keeps the forgot-password row inert until #395 wires a handler", () => { + const first = renderCard(); + expect(screen.getByRole("button", { name: "忘记密码?" })).toBeDisabled(); + first.unmount(); + + const onForgotPassword = vi.fn(); + renderCard({ onForgotPassword }); + const link = screen.getByRole("button", { name: "忘记密码?" }); + expect(link).toBeEnabled(); + fireEvent.click(link); + expect(onForgotPassword).toHaveBeenCalledTimes(1); + }); + + it("renders the English catalog for the same card", () => { + render( + + + , + ); + expect(screen.getByRole("tab", { name: "Sign in" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Create account" })).toBeInTheDocument(); + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + expect(screen.getByText("Your previous session expired. Sign in again.")).toBeInTheDocument(); + }); +}); + +/* The submit button lives inside the tabpanel; locate it by type=submit so it + cannot collide with the tab of the same name. */ +describe("#519 login card submit", () => { + it("submits the sign-in form once when Enter is pressed in a field", async () => { + const onSubmit = vi.fn(); + renderCard({ onSubmit }); + fill("邮箱", "dev@example.com"); + fill("密码", "correct-horse"); + await act(async () => { + fireEvent.submit(panel() as HTMLFormElement); + }); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main.js b/apps/desktop/src/main.js index 552ec003..638e1cab 100644 --- a/apps/desktop/src/main.js +++ b/apps/desktop/src/main.js @@ -34,6 +34,7 @@ const { } = require("./packaged-behavior-smoke.cjs"); const { awaitHarnessRelease, + layoutSmokeLoadOptions, packagedSmokeLoadOptions, runPackagedSmoke, startPackagedSmokeLifecycle, @@ -859,7 +860,19 @@ function createWindow() { ? (process.env.ORG_WORKBENCH_LAYOUT_REPORT ?? null) : null; const smokeLoad = smokeRequest === null ? null : packagedSmokeLoadOptions(entryPath, smokeRequest); - trustedRendererUrl = smokeLoad?.trustedRendererUrl ?? pathToFileURL(entryPath).toString(); + // #519: the layout harness renders the real app, so it declares itself in the + // loaded URL to skip onboarding. See layoutSmokeLoadOptions. + const layoutLoad = layoutReportPath === null ? null : layoutSmokeLoadOptions(entryPath); + trustedRendererUrl = smokeLoad?.trustedRendererUrl + ?? layoutLoad?.trustedRendererUrl + ?? pathToFileURL(entryPath).toString(); + // #519: a normal launch waits for `ready-to-show` so the window never appears + // as an empty frame before the renderer has painted the first-run splash. The + // three harness paths below keep today's visible-from-the-start behaviour on + // purpose — the external staging oracle snapshots native descendants, and + // hiding the window during a scored run would be a behaviour change in a lane + // this issue does not own. + const showsOnReady = smokeRequest === null && behaviorSmokeRequest === null && layoutReportPath === null; mainWindow = new BrowserWindow({ width: 1240, height: 800, @@ -880,6 +893,7 @@ function createWindow() { // an AC-002 "no raw hex in components" violation (there is no component // here, just Electron's own pre-paint). backgroundColor: nativeTheme.shouldUseDarkColors ? "#14151b" : "#f7f8fb", + show: !showsOnReady, webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, @@ -893,6 +907,16 @@ function createWindow() { }, }); mainWindow.setMenuBarVisibility(false); + if (showsOnReady) { + // `ready-to-show` is the documented "first frame is painted" signal; the + // did-finish-load fallback exists because a launch that fails before that + // point must not leave an invisible, unclosable window behind. + const revealOnce = () => { + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) mainWindow.show(); + }; + mainWindow.once("ready-to-show", revealOnce); + mainWindow.webContents.once("did-finish-load", revealOnce); + } // Lane A staging harness: static, opt-in, packaged-only, and confined to the // caller's freshly created OS-temp root. Source-tree dev behavior is not // reachable through this seam. @@ -1000,7 +1024,7 @@ function createWindow() { app.exit(1); }); }); - void mainWindow.loadFile(entryPath); + void mainWindow.loadFile(entryPath, layoutLoad.loadOptions); } else { void mainWindow.loadFile(entryPath); } diff --git a/apps/desktop/src/packaged-smoke.cjs b/apps/desktop/src/packaged-smoke.cjs index 6d5ae34a..9b635898 100644 --- a/apps/desktop/src/packaged-smoke.cjs +++ b/apps/desktop/src/packaged-smoke.cjs @@ -345,6 +345,26 @@ function packagedSmokeLoadOptions(entryPath, request) { }; } +// #519: the full-app layout smoke (#127 AC-004) deliberately renders the real +// renderer instead of the static marker. That renderer now opens behind the +// first-run gate, and a fresh CI install has no saved session — so the gate +// would route to the login card and the two org columns the harness measures +// would never mount inside its 5s poll budget, turning the parity job red on a +// correct build. The harness therefore declares itself in the URL exactly like +// the static smoke entry does, and the renderer skips onboarding only for that +// exact declaration. The trusted URL is built the same way as above so the +// ordinary window IPC trust check keeps matching the loaded frame. +const LAYOUT_SMOKE_QUERY_KEY = "orgWorkbenchLayoutSmoke"; + +function layoutSmokeLoadOptions(entryPath) { + const entryUrl = pathToFileURL(path.resolve(entryPath)); + entryUrl.searchParams.set(LAYOUT_SMOKE_QUERY_KEY, "1"); + return { + trustedRendererUrl: entryUrl.toString(), + loadOptions: { query: { [LAYOUT_SMOKE_QUERY_KEY]: "1" } }, + }; +} + function createPackagedSmokeLifecycle({ reportRequest, failureReport = null, onUnexpected }) { let intentionalClose = false; let failed = false; @@ -510,9 +530,11 @@ module.exports = { awaitHarnessRelease, harnessLeasePath, PACKAGED_SMOKE_QUERY_KEY, + LAYOUT_SMOKE_QUERY_KEY, closeSmokeReportReservation, createPackagedSmokeLifecycle, packagedSmokeLoadOptions, + layoutSmokeLoadOptions, packagedSmokeControlFamilies, packagedSmokeControlValue, packagedSmokeRequest, diff --git a/apps/desktop/test/onboarding-brand-mark.test.cjs b/apps/desktop/test/onboarding-brand-mark.test.cjs new file mode 100644 index 00000000..8e0c7a5e --- /dev/null +++ b/apps/desktop/test/onboarding-brand-mark.test.cjs @@ -0,0 +1,87 @@ +// The first-run surface draws the product icon itself, not a near-enough +// lookalike. +// +// The asset (branding/roleweave/roleweave-icon.svg) and the renderer (geometry +// in BrandMark.tsx + palette in onboarding.css) are split in two, because +// neither an SVG build loader nor hardcoded brand colour inside a component was +// wanted. The cost of that split is drift, so every value is pinned here: any +// edit that fails to update the other half turns the suite red. +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const repoRoot = path.join(__dirname, "..", "..", ".."); +const assetPath = path.join(repoRoot, "branding", "roleweave", "roleweave-icon.svg"); +const componentPath = path.join(__dirname, "..", "renderer", "src", "onboarding", "BrandMark.tsx"); +const cssPath = path.join(__dirname, "..", "renderer", "src", "onboarding", "onboarding.css"); + +const asset = fs.readFileSync(assetPath, "utf8"); +const component = fs.readFileSync(componentPath, "utf8"); +const css = fs.readFileSync(cssPath, "utf8"); + +test("#519 the splash mark reuses the shipped icon geometry verbatim", () => { + const viewBox = /viewBox="([^"]+)"/.exec(asset); + assert.ok(viewBox, "the brand asset must declare a viewBox"); + assert.ok(component.includes(`viewBox="${viewBox[1]}"`), `BrandMark must keep the asset viewBox (${viewBox[1]})`); + + const transform = /]*transform="([^"]+)"/.exec(asset); + assert.ok(transform, "the brand asset must declare its artwork transform"); + assert.ok(component.includes(`transform="${transform[1]}"`), `BrandMark must keep the asset transform (${transform[1]})`); + + const paths = [...asset.matchAll(/]*\bd="([^"]+)"/g)].map((match) => match[1]); + assert.equal(paths.length, 4, "the asset is expected to carry four ribbon paths"); + for (const d of paths) { + assert.ok(component.includes(d), `BrandMark is missing asset path data: ${d.slice(0, 48)}...`); + } + + // The element make-up must match too: two rects (tile + edge) and two dots. + assert.equal((component.match(/ { + // Every coloured element inside the asset's , and the token it must carry. + // weave-break-* share the canvas colour: they are the patches that punch + // holes in the weave ribbon. + const mapping = [ + ["icon-canvas", "--owb-brandmark-tile", "#141820"], + ["icon-edge", "--owb-brandmark-edge", "#2c3a52"], + ["role-ribbon", "--owb-brandmark-role", "#722ed1"], + ["weave-ribbon", "--owb-brandmark-weave", "#1677ff"], + ["weave-break-left", "--owb-brandmark-tile", "#141820"], + ["weave-break-right", "--owb-brandmark-tile", "#141820"], + ["role-node", "--owb-brandmark-node", "#ffffff"], + ["workflow-node", "--owb-brandmark-node", "#ffffff"], + ]; + + for (const [id, token, hex] of mapping) { + const element = new RegExp(`<[^>]*id="${id}"[^>]*>`).exec(asset); + assert.ok(element, `the brand asset must still declare #${id}`); + assert.ok( + element[0].toLowerCase().includes(hex), + `#${id} no longer paints ${hex} in the asset; the mapping below is stale`, + ); + assert.ok( + new RegExp(`${token}:\\s*${hex}`).test(css), + `${token} must declare the asset's own value ${hex}`, + ); + } + + // The component carries no colour of its own: colour comes only from CSS, + // geometry only from the component. + assert.ok(!/#[0-9a-fA-F]{6}\b/.test(component), "BrandMark must stay geometry-only (no hex literals)"); + assert.ok(!/\bfill="#/.test(component), "BrandMark must set no inline fill; the CSS classes own colour"); + + // Class wiring: every element class must actually be coloured by CSS, or the + // mark renders black. + for (const part of ["tile", "edge", "role", "weave", "cut", "node"]) { + assert.ok( + new RegExp(`\\.owb-brandmark__${part}\\s*[,{]`).test(css), + `.owb-brandmark__${part} must be styled in onboarding.css`, + ); + } +}); diff --git a/apps/desktop/test/onboarding-motion.test.cjs b/apps/desktop/test/onboarding-motion.test.cjs new file mode 100644 index 00000000..5f7f44cc --- /dev/null +++ b/apps/desktop/test/onboarding-motion.test.cjs @@ -0,0 +1,171 @@ +// The machine-checkable half of the #519 first-run motion. +// +// jsdom applies no real stylesheet cascade, so these invariants read the **built +// bundle**, following this repository's existing practice (contrast / +// window-controls-affordance / panel-parity): @keyframes may only drive +// compositor properties, reduced motion must actually land, and the transition +// duration must have exactly one source of truth. +// +// Negative controls (recorded in the PR ledger): swapping a keyframe's transform +// for width, or deleting the reduced-motion block, turns the matching assertion +// red. +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const rendererSrc = path.join(__dirname, "..", "renderer", "src"); + +function builtCss() { + const assetsDir = path.join(__dirname, "..", "dist", "renderer", "assets"); + const cssFiles = fs.readdirSync(assetsDir).filter((file) => file.endsWith(".css")); + assert.ok(cssFiles.length > 0, "renderer build must emit css assets"); + return cssFiles.map((file) => fs.readFileSync(path.join(assetsDir, file), "utf8")).join("\n"); +} + +/** Brace-matched body of an at-rule, tolerant of minified output. */ +function atRuleBody(css, name) { + const index = css.indexOf(`@keyframes ${name}`); + if (index < 0) return null; + const open = css.indexOf("{", index); + let depth = 0; + for (let i = open; i < css.length; i += 1) { + if (css[i] === "{") depth += 1; + else if (css[i] === "}") { + depth -= 1; + if (depth === 0) return css.slice(open + 1, i); + } + } + return null; +} + +// Compositor-friendly properties plus one timing property that has no visual +// effect of its own. +const COMPOSITOR_SAFE = new Set(["transform", "opacity", "filter", "animation-timing-function"]); + +test("#519 onboarding keyframes drive compositor-friendly properties only", () => { + const css = builtCss(); + const names = [...css.matchAll(/@keyframes\s+(owb-onboarding-[\w-]+)/g)].map((match) => match[1]); + assert.ok(names.length >= 5, `expected the onboarding keyframe family in the bundle, got ${names.join(", ")}`); + + for (const name of names) { + const body = atRuleBody(css, name); + assert.ok(body !== null, `${name} must have a parsable body`); + const declared = [...body.matchAll(/([a-z-]+)\s*:/g)].map((match) => match[1]); + assert.ok(declared.length > 0, `${name} must declare something`); + for (const property of declared) { + assert.ok( + COMPOSITOR_SAFE.has(property), + `${name} animates "${property}"; the splash runs on the compositor, so only transform/opacity/filter (plus timing) may appear. ` + + "A layout or paint property here drops frames exactly while the user is waiting.", + ); + } + // Stated as an explicit denial: these are the names a careless edit reaches + // for first. + for (const forbidden of ["width", "height", "top", "left", "margin", "letter-spacing", "background"]) { + assert.ok( + !new RegExp(`(^|;|\\{)\\s*${forbidden}\\s*:`).test(body), + `${name} must not animate ${forbidden}`, + ); + } + } +}); + +test("#519 reduced motion keeps fades and drops the transforms", () => { + const css = builtCss(); + // The bundle carries several reduced-motion blocks — the design system ships + // its own global override, and it is not the one under test. Scan every + // block and require the one that actually addresses the onboarding mark. + const blocks = []; + for (const match of css.matchAll(/@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{/g)) { + const open = css.indexOf("{", match.index); + let depth = 0; + for (let i = open; i < css.length; i += 1) { + if (css[i] === "{") depth += 1; + else if (css[i] === "}") { + depth -= 1; + if (depth === 0) { blocks.push(css.slice(open + 1, i)); break; } + } + } + } + assert.ok(blocks.length > 0, "the renderer bundle must ship at least one prefers-reduced-motion block"); + + const onboarding = blocks.filter((body) => body.includes(".owb-splash__mark")); + assert.equal( + onboarding.length, + 1, + "exactly one reduced-motion block must address the onboarding mark (the mark is the loudest motion in the sequence)", + ); + assert.match(onboarding[0], /owb-onboarding-fade-in/, "reduced motion must swap the mark animation for a fade"); + assert.match( + onboarding[0], + /transform:\s*none/, + "the docking beat must drop its transform, not just its animation name", + ); +}); + +test("#519 the transition duration has exactly one source of truth", () => { + const css = builtCss(); + assert.match(css, /var\(--owb-transition-ms/, "the stylesheet must consume --owb-transition-ms rather than hardcode a duration"); + const gate = fs.readFileSync(path.join(rendererSrc, "onboarding", "OnboardingGate.tsx"), "utf8"); + assert.match(gate, /"--owb-transition-ms"/, "the gate must publish --owb-transition-ms from the state machine's timings"); + assert.match(gate, /REDUCED_MOTION_ONBOARDING_TIMINGS/, "reduced-motion timings must live next to the default timings"); +}); + +test("#519 onboarding adds no runtime dependency", () => { + const allowed = new Set(["react", "@roleweave/ui", "@roleweave/shared"]); + const dir = path.join(rendererSrc, "onboarding"); + const files = fs.readdirSync(dir).filter((name) => name.endsWith(".ts") || name.endsWith(".tsx")); + assert.ok(files.length >= 4, "expected the onboarding module set"); + + const offenders = []; + for (const file of files) { + const source = fs.readFileSync(path.join(dir, file), "utf8"); + for (const match of source.matchAll(/(?:^|\n)\s*import\s[^"']*["']([^"']+)["']/g)) { + const specifier = match[1]; + if (specifier.startsWith(".") || specifier.startsWith("/")) continue; + if (!allowed.has(specifier)) offenders.push(`${file} -> ${specifier}`); + } + } + assert.deepEqual(offenders, [], `onboarding must not pull a new runtime dependency (issue AC): ${offenders.join(", ")}`); + + // Acceptance criterion: no extra request before the first paint. A remote + // asset reference in this stylesheet would be one. + const css = fs.readFileSync(path.join(dir, "onboarding.css"), "utf8"); + assert.ok(!/url\(\s*["']?https?:/i.test(css), "onboarding.css must not fetch a remote asset before first paint"); + assert.ok(!/@import\s/i.test(css), "onboarding.css must not @import anything"); +}); + +test("#519 the ordinary launch is the only one that waits for ready-to-show", () => { + const main = fs.readFileSync(path.join(__dirname, "..", "src", "main.js"), "utf8"); + assert.match(main, /const showsOnReady = smokeRequest === null && behaviorSmokeRequest === null && layoutReportPath === null;/, + "hiding the window must be limited to a normal launch: the staging/layout harnesses snapshot native descendants"); + assert.match(main, /show:\s*!showsOnReady/, "the window must start hidden exactly when we intend to reveal it on paint"); + assert.match(main, /once\("ready-to-show", revealOnce\)/, "ready-to-show must reveal the window"); + assert.match(main, /once\("did-finish-load", revealOnce\)/, + "a launch that never reaches ready-to-show must still become visible instead of leaving an invisible window"); +}); + +test("#519 the layout harness gets past onboarding through exactly one declared channel", () => { + const { layoutSmokeLoadOptions, LAYOUT_SMOKE_QUERY_KEY } = require("../src/packaged-smoke.cjs"); + const main = fs.readFileSync(path.join(__dirname, "..", "src", "main.js"), "utf8"); + const entry = fs.readFileSync(path.join(rendererSrc, "main.tsx"), "utf8"); + + // This is the only contact surface between this change and the #127 full-app + // layout smoke: that harness renders the real app, and a fresh CI machine has + // no session, so the gate would park it on the login card and the two columns + // being measured would never appear inside the 5s mount-poll budget — a + // correct build judged red. The harness therefore declares itself in the URL + // and the renderer honours exactly that declaration. + const options = layoutSmokeLoadOptions(path.join(rendererSrc, "..", "index.html")); + assert.deepEqual(options.loadOptions, { query: { [LAYOUT_SMOKE_QUERY_KEY]: "1" } }); + assert.match(options.trustedRendererUrl, /\?orgWorkbenchLayoutSmoke=1$/, + "the trusted renderer URL must carry the same query, otherwise every window IPC call in the harness run is rejected"); + + assert.ok(entry.includes(`"${LAYOUT_SMOKE_QUERY_KEY}"`), + `renderer/src/main.tsx must recognise the layout harness key ${LAYOUT_SMOKE_QUERY_KEY}`); + assert.match(entry, /isLayoutSmokeEntry\(location\) \|\| isPackagedSmokeEntry\(location\)|isPackagedSmokeEntry\(location\) \|\| isLayoutSmokeEntry\(location\)/, + "the root element must bypass the gate for the layout harness"); + assert.match(main, /layoutSmokeLoadOptions\(entryPath\)/, "main must build the layout load options"); + assert.match(main, /loadFile\(entryPath, layoutLoad\.loadOptions\)/, "the layout branch must load with those options"); +}); diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts index e7ff1b71..6ae68a96 100644 --- a/packages/ui/src/locales/en.ts +++ b/packages/ui/src/locales/en.ts @@ -1362,6 +1362,32 @@ export const enCatalog: Record = { "startup.service": "Starting the local service", "startup.workspace": "Restoring the workspace", "startup.organization": "Loading organization and Agent configuration", + // #519 first run: splash -> login/register card. Key prefix onboarding.* + "onboarding.splash.aria": "RoleWeave is starting", + "onboarding.splash.hint": "Preparing your workspace", + "onboarding.login.title": "Sign in to RoleWeave", + "onboarding.login.subtitle": "Sign in to open your position workspace.", + "onboarding.login.tabsAria": "Sign in or create an account", + "onboarding.login.tabSignIn": "Sign in", + "onboarding.login.tabSignUp": "Create account", + "onboarding.login.email": "Email", + "onboarding.login.emailPh": "you@example.com", + "onboarding.login.password": "Password", + "onboarding.login.passwordPh": "Enter your password", + "onboarding.login.passwordHint": "At least {count} characters.", + "onboarding.login.submitSignIn": "Sign in", + "onboarding.login.submitSignUp": "Create account", + "onboarding.login.busy": "Verifying…", + "onboarding.login.forgot": "Forgot password?", + "onboarding.login.legal": "By continuing you accept the data and collaboration rules of this deployment's organization.", + "onboarding.login.expiredNotice": "Your previous session expired. Sign in again.", + "onboarding.login.errEmailRequired": "Enter your email.", + "onboarding.login.errEmailFormat": "That email address is not valid.", + "onboarding.login.errPasswordRequired": "Enter your password.", + "onboarding.login.errPasswordShort": "Use at least {count} characters.", + "onboarding.login.errInvalidCredentials": "That email or password is not correct.", + "onboarding.login.errEmailTaken": "That email already has an account. Sign in instead.", + "onboarding.login.errUnreachable": "The authentication service is unreachable. Try again.", "misc.sseReconnecting": "Reconnecting the event stream…", "misc.engineUnavailable": "Engine unavailable", "misc.recheckAvailability": "Check again", diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts index 5a1a6268..17cab32a 100644 --- a/packages/ui/src/locales/zh.ts +++ b/packages/ui/src/locales/zh.ts @@ -1365,6 +1365,32 @@ export const zhCatalog: Record = { "startup.service": "正在启动本地服务", "startup.workspace": "正在恢复工作区", "startup.organization": "正在读取组织与 Agent 配置", + // #519 首启:启动画 → 登录 / 注册卡。key 前缀 onboarding.* + "onboarding.splash.aria": "RoleWeave 正在启动", + "onboarding.splash.hint": "正在准备你的工作区", + "onboarding.login.title": "登录 RoleWeave", + "onboarding.login.subtitle": "登录后进入你的岗位工作区。", + "onboarding.login.tabsAria": "登录或创建账号", + "onboarding.login.tabSignIn": "登录", + "onboarding.login.tabSignUp": "创建账号", + "onboarding.login.email": "邮箱", + "onboarding.login.emailPh": "you@example.com", + "onboarding.login.password": "密码", + "onboarding.login.passwordPh": "输入密码", + "onboarding.login.passwordHint": "至少 {count} 位字符。", + "onboarding.login.submitSignIn": "登录", + "onboarding.login.submitSignUp": "创建账号", + "onboarding.login.busy": "正在验证…", + "onboarding.login.forgot": "忘记密码?", + "onboarding.login.legal": "继续即表示你同意本部署所在组织的数据与协作规范。", + "onboarding.login.expiredNotice": "上一次的登录已过期,请重新登录。", + "onboarding.login.errEmailRequired": "请输入邮箱。", + "onboarding.login.errEmailFormat": "邮箱格式不正确。", + "onboarding.login.errPasswordRequired": "请输入密码。", + "onboarding.login.errPasswordShort": "密码至少 {count} 位。", + "onboarding.login.errInvalidCredentials": "邮箱或密码不正确。", + "onboarding.login.errEmailTaken": "该邮箱已注册,请直接登录。", + "onboarding.login.errUnreachable": "无法连接认证服务,请稍后重试。", "misc.sseReconnecting": "事件流重连中…", "misc.engineUnavailable": "引擎不可用", "misc.recheckAvailability": "重新检查",