feat(frontend): observatory workspace skin - #4915
Conversation
3d0d9ec to
a25bbfe
Compare
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed the full diff at a25bbfe (this PR currently carries the whole #4914 base rework plus the observatory layer, so I reviewed both). The observatory skin layer itself looks solid: lazy chunking via observatory-lazy.tsx, ssr: false everywhere heavy, consistent prefers-reduced-motion handling, SSR-safe storage access, an anti-FOUC boot script in layout.tsx, and real unit tests for storage/theme transitions. The findings below are mostly in the shared base-rework files: one correctness bug that breaks skill toggling for zh-CN users, an e2e suite that this diff breaks, a silent feature removal in the memory page, and a couple of smaller regressions in the scheduled-task schedule input.
| [skills, filter], | ||
| () => | ||
| skills | ||
| .filter((skill) => skill.category === filter) |
There was a problem hiding this comment.
Bug: the localized display name is used as the skill identifier. localizeSkill overwrites skill.name with the Chinese display name (e.g. deep-research → 深度研究), but skill.name is then consumed as identity downstream: the Switch calls enableSkill({ skillName: skill.name, ... }) (line 244), which issues PUT /api/skills/{skillName}. In the zh-CN locale every toggle on a mapped skill will PUT to /api/skills/深度研究 and fail. It's also the React key.
Suggestion: keep the original name for identity and localize only at render time, e.g. have localizeSkill return { ...skill, displayName, description } (or look up SKILL_ZH[skill.name] inside the JSX for the title/description only). Side note: SKILL_ZH hardcodes zh copy in the component while this same PR adds proper i18n keys in locales/zh-CN.ts for everything else — if the map stays, it would be more consistent to move the strings into the locale files.
There was a problem hiding this comment.
Skill localization moved out of this PR to #4922, where the identifier bug is fixed (raw skill.name kept as identity, displayName used only for the title).
| <Button | ||
| size="sm" | ||
| onClick={() => router.push("/workspace/scheduled-tasks/new")} | ||
| data-testid="scheduled-task-create-toggle" |
There was a problem hiding this comment.
This rework breaks frontend/tests/e2e/scheduled-tasks.spec.ts, which isn't updated in this PR (and the e2e-tests workflow runs on frontend/** changes, so CI will be red). Concretely:
scheduled-task-create-formtestid is gone — creation moved to the new/workspace/scheduled-tasks/newpage; the spec's "user can create a scheduled task from the page" test fills the old inline form.scheduled-task-detailtestid is gone — detail is now a Dialog, and it only renders after clicking a card (detailOpenstarts false, andselectedTaskno longer auto-selects the first task). The pause/trigger/filter-fallback tests all assume the detail pane is visible on load.- Even the first test ("page is reachable from sidebar") asserts
scheduled-task-runscontains "0 runs", but that testid now only exists inside the closed dialog.
The spec needs rewriting for the new flow (navigate to /new to create; click a card to open the dialog). Repo convention is that reworks ship with their tests — this probably belongs to the #4914 base layer, but it has to land somewhere before merge.
There was a problem hiding this comment.
The scheduled-tasks rework lives in the base layer #4914, where scheduled-tasks.spec.ts was rewritten for the card-grid/detail-dialog UI.
| <h3 className="text-base font-medium"> | ||
| {t.settings.memory.markdown.facts} | ||
| </h3> | ||
| {/* Facts list */} |
There was a problem hiding this comment.
The memory page no longer renders learned summaries at all — only facts survive. summariesToMarkdown / buildMemorySectionGroups / the summaries card were deleted, so users can no longer see work context / personal context / top-of-mind / history summaries anywhere in the UI (only via JSON export). This isn't mentioned in the PR description, and it leaves orphaned i18n keys behind (summaryReadOnly, filterAll/filterFacts/filterSummaries, and most of settings.memory.markdown.* — only markdown.table.* and markdown.facts are still referenced).
If dropping the summaries view is a deliberate product decision for the rework, please call it out in the description and clean up the dead keys; if not, the summaries block needs to be restored alongside the facts list.
There was a problem hiding this comment.
The memory page rework is part of #4914 (base layer), not this observatory-only PR.
| "America/Chicago", | ||
| "America/Los_Angeles", | ||
| // 只保留最常用的几个时区,避免选项过多难以选择。 | ||
| const COMMON_TIMEZONES: Array<{ value: string; label: string }> = [ |
There was a problem hiding this comment.
Suggestion: this replaces the previous full timezone list (Intl.supportedValuesOf("timeZone") with a FALLBACK_TIMEZONES fallback) with a fixed set of 10 zones plus the currently-selected one. A user in, say, Australia/Sydney or America/Denver who wants a task to run in a zone other than their browser-detected default can no longer select it. If the motivation is only the overwhelming dropdown length, a searchable combobox (or keeping supportedValuesOf with the common zones pinned to the top) preserves the old capability. Also, the zh labels are hardcoded here — the non-zh branch falls back to the raw IANA id — while the rest of this PR routes copy through locales/zh-CN.ts.
There was a problem hiding this comment.
The timezone dropdown belongs to #4914, which restores the full Intl.supportedValuesOf list behind a common/all toggle.
| /> | ||
| </div> | ||
| <Input | ||
| type="time" |
There was a problem hiding this comment.
Minor UX trap from splitting the old datetime-local input: the time field is optional, and a date with an empty time still produces a non-empty run_at (via runAtLocal = "YYYY-MM-DD"), so the create button enables and zonedLocalToUtcIso silently schedules it at 00:00 in the selected timezone. The old input couldn't represent that state. Consider requiring the time (add onceTime to validDate) or defaulting it to a visible value so the user sees when the task will actually fire.
There was a problem hiding this comment.
The one-time scheduling input lives in #4914 (base layer).
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [selectedTask?.id]); | ||
|
|
||
| const statusFilters = [ |
There was a problem hiding this comment.
Nit: this rework orphans a fair bit of dead code worth sweeping up — @/core/scheduled-tasks/recipes is no longer imported anywhere, describeSchedule in cron.ts is now only referenced by its own unit test (the schedule preview line was removed from the form), and several i18n keys are unused after the redesign: scheduledTasks.recipes.*, filters.allStatuses/allTypes/cron/once/completed/failed, and workspace.visitGithub/reportIssue/about (the menu now uses the new github key).
There was a problem hiding this comment.
Dead-code sweep for recipes/describeSchedule/i18n keys is tracked in #4914.
a25bbfe to
65222e1
Compare
fb3cfe1 to
d5cc199
Compare
willem-bd
left a comment
There was a problem hiding this comment.
Follow-up review at head 65222e1, scoped to the observatory-layer files this PR adds on top of the #4914 base (the earlier findings on the shared base-rework files still stand). The layering itself is well done — clean lazy chunking, consistent prefers-reduced-motion handling, SSR-safe storage access, and unit tests for storage/theme transitions. But at this head the workspace does not render at all: SkinProvider is defined yet never mounted anywhere in the tree, so every useSkin() consumer (starting with PageEnter, which wraps all workspace children) throws during render. Plus one hydration-mismatch bug in AsterismMark and two smaller notes below.
| <GatewayOfflineBanner gatewayUnavailable={gatewayUnavailable} /> | ||
| <ModelLoadErrorBanner gatewayUnavailable={gatewayUnavailable} /> | ||
| {children} | ||
| <PageEnter>{children}</PageEnter> |
There was a problem hiding this comment.
Blocker: SkinProvider is never mounted, so every /workspace/* route throws during render. At this head, SkinProvider is defined in frontend/src/core/skins/context.tsx but a tree-wide grep shows no file renders it — yet PageEnter calls useSkin() unconditionally, and WorkspaceContent wraps all workspace children in <PageEnter> (here) and also renders ObservatoryLazy (line 45). useSkin() throws "useSkin must be used within SkinProvider" when the context is null, so the whole workspace crashes server- and client-side for every user, in both skins (same for InputBox, Welcome, and the settings appearance page, which all call useSkin()). Typecheck/lint can't catch this. Fix: mount <SkinProvider> above these consumers — e.g. wrap the tree in WorkspaceContent (around SidebarProvider) or in app/layout.tsx next to the boot script.
There was a problem hiding this comment.
Fixed in 35b57c2 — SkinProvider is now mounted in the workspace and showcase layouts.
| title?: string; | ||
| }) { | ||
| const mark = useMemo( | ||
| () => MARKS[Math.floor(Math.random() * MARKS.length)] ?? MARKS[0], |
There was a problem hiding this comment.
Hydration mismatch: Math.random() runs during render on both the server and the client. AsterismMark is statically imported by welcome.tsx and appearance-settings-page.tsx, so it is server-rendered; the server picks one MARKS entry and hydration picks another, which disagrees on viewBox, the path d, and the star circles — React 18 will log a hydration error and force a client re-render of the subtree (with a visible flip of the asterism). Since the component is pure decoration, either pick deterministically (e.g. derive the index from a stable input like the pathname passed as a prop) or choose the mark after mount (useEffect + useState) and render a fixed default during SSR.
There was a problem hiding this comment.
Fixed in 613f827 — AsterismMark no longer calls Math.random() during render (deterministic selection after mount), removing the hydration mismatch.
| <div className="obs-welcome-kicker text-primary mb-2 flex items-center justify-center"> | ||
| <AsterismMark className="h-7 w-16" /> | ||
| </div> | ||
| <h1 className="obs-welcome-title font-display text-[2.6rem] leading-[1.15] font-bold tracking-tight"> |
There was a problem hiding this comment.
Nit: font-display is a no-op here — Tailwind v4 only generates font-{name} utilities from theme vars, and only --font-sans is defined in globals.css (no --font-display), so this class is never emitted. Either add --font-display to the theme if a display face was intended for the observatory title, or drop the class.
There was a problem hiding this comment.
Fixed in 613f827 — dropped the no-op font-display class.
| } | ||
| frame = window.requestAnimationFrame(tick); | ||
| }; | ||
| frame = window.requestAnimationFrame(tick); |
There was a problem hiding this comment.
Perf note: this requestAnimationFrame(tick) runs unconditionally, and corner-constellations.tsx (line 101) uses the same pattern — so two rAF loops run for the entire session while the observatory skin is active, mapping over the star arrays on every frame even when everything has settled and no pointer has moved for minutes. Consider stopping the loop once moved is false and re-arming it from the pointermove listener (and on resize / skin change) so an idle observatory workspace costs ~0 CPU instead of two perpetual frame callbacks.
There was a problem hiding this comment.
Fixed in 613f827 — pointer-play now stops its rAF loop when idle and re-arms from pointermove.
d5cc199 to
35b57c2
Compare
|
Thanks for the scoped follow-up. All four observatory findings are addressed:
The base-rework items referenced in the earlier review were resolved in the #4914 base layer (StatusBadge dot, memory import validation, e2e specs, timezone list, queryError gating) and are now present in this branch after the rebase. |
willem-bd
left a comment
There was a problem hiding this comment.
Follow-up review at head 613f827. The pushes since the last round resolved the three previously-flagged observatory-layer issues - SkinProvider is now mounted in both workspace and showcase layouts (35b57c2), AsterismMark picks its mark deterministically during render and randomizes only in an effect (613f827), and the FOUC boot script landed (6a0c38d). The base-rework findings from the first round were addressed on the #4914 side (timezone dropdown with full zone list + e2e fixes in 40bcb8e, memory/settings feedback in 640e8db) and are being tracked there. Three smaller residual notes below: the corner-constellations rAF loop is the one half of the earlier perf note that the idle rAF stop commit didn't cover, the PageEnter conditional wrapper remounts the whole workspace subtree, and the FOUC boot script duplicates the storage-key constant.
| const box = svgRef.current?.getBoundingClientRect(); | ||
| if (box) onStars?.(next.map((star) => toScreen(star, box))); | ||
| } | ||
| frame = window.requestAnimationFrame(tick); |
There was a problem hiding this comment.
Residual from the earlier rAF perf note: 613f827 ("idle rAF stop") gated pointer-play's re-arm on moved, but this loop still calls requestAnimationFrame(tick) unconditionally at the end of tick() - so while the observatory skin is active this runs a full map over HOMES + goalOf distance math every frame for the entire session, even after the stars have settled. Same fix applies: move the re-arm inside if (moved) (the initial frame = window.requestAnimationFrame(tick) arm can stay, or an occasional low-rate keepalive tick can be used if you want the loop to wake on the next pointermove - pointer-play re-arms from its pointermove listener via arm(), which this component could mirror using its existing localRef).
There was a problem hiding this comment.
Fixed in 3dd922a — corner-constellations now stops its rAF loop when idle instead of running every frame.
| }, [pathname, skin]); | ||
|
|
||
| if (skin !== "observatory") { | ||
| return children; |
There was a problem hiding this comment.
Suggestion: switching the wrapper shape on skin (<div> in observatory vs. children directly in classic) unmounts and remounts the entire workspace subtree whenever skin changes. That happens not only when the user switches skins in settings - for observatory users it happens on every page load too, because SkinProvider starts in classic and flips to the stored skin in a useLayoutEffect after hydration, so the tree renders unwrapped first and then remounts inside the obs-page-enter div (losing any component-local state below, e.g. input drafts, and paying a double mount of the whole workspace). Rendering the wrapper <div> unconditionally and toggling only the class (obs-page-enter + is-on vs. a plain min-h-0 flex-1) keeps the element type stable across skin changes and costs classic users nothing measurable.
There was a problem hiding this comment.
Fixed in 3dd922a — page-enter now renders an unconditional wrapper and toggles only the class, so the subtree no longer remounts on skin change.
| dangerouslySetInnerHTML={{ | ||
| __html: `(function () { | ||
| try { | ||
| var s = localStorage.getItem("deerflow.skin"); |
There was a problem hiding this comment.
Nit: the boot script hardcodes "deerflow.skin" and "observatory", duplicating SKIN_STORAGE_KEY and the SkinId values from core/skins/types.ts. If either ever changes (or a third skin joins), this guard silently stops matching and the FOUC protection quietly regresses with no type/lint error to catch it. Interpolating the constants keeps a single source of truth: __html: \(function(){try{var s=localStorage.getItem(${JSON.stringify(SKIN_STORAGE_KEY)});if(s===${JSON.stringify("observatory")}){...}catch(e){}})();`` - it's still a static string at build time, so there's no cost to the pre-hydration path.
There was a problem hiding this comment.
Fixed in 3dd922a — the boot script now interpolates SKIN_STORAGE_KEY and the observatory value from the shared constants.
willem-bd
left a comment
There was a problem hiding this comment.
Follow-up pass at 613f827 (head unchanged since the last round). One new finding this round: the observatory skin clips document-level scrolling, which breaks pages that rely on viewport scroll — details inline on the sidebar-inset rule. The three residual notes from the last round (unconditional rAF in corner-constellations.tsx, subtree remount on skin flip in page-enter.tsx, hardcoded skin constants in the layout.tsx boot script) still stand as posted; the base-rework findings remain tracked on #4914.
| animation: obs-rise 0.7s ease both; | ||
| } | ||
| [data-skin="observatory"] [data-slot="sidebar-inset"] { | ||
| overflow: hidden !important; |
There was a problem hiding this comment.
Bug: observatory disables document scrolling, so pages without their own scroll container lose everything below the fold. With the observatory skin active, three layers clip at once: this overflow: hidden !important on [data-slot="sidebar-inset"], [data-skin="observatory"] .obs-page-enter { overflow: hidden; ... } (line 831 — it stays hidden after the enter animation, only opacity is animated), and html[data-skin="observatory"] body.obs-lock-scroll { overflow: hidden } (line 1008), which ObservatoryLazy (observatory-lazy.tsx:23) applies for the entire session, not just during the opening animation. Nothing in the workspace can then scroll via the document.
Pages that own a scroller are fine (chat thread, /workspace/scheduled-tasks/new has overflow-y-auto on its main), but /workspace/scheduled-tasks has no internal scroll container — neither here nor on main (WorkspaceContainer/WorkspaceBody don't scroll; the card grid relies on viewport scroll, which is exactly how it works under the classic skin today). With more tasks than fit the viewport, the grid is simply cut off with no scrollbar and no way to reach the rest. Even a single screen of content loses the bottom sliver, because WorkspaceContainer is h-screen inside the flex-1 min-h-0 page-enter box that is already offset by the banner height.
Two possible fixes: give the wrapper a real scroller once the enter animation finishes (e.g. overflow-y: auto on .obs-page-enter.is-on after the animation, or an obs-scroll region inside), or scope the !important clip / body lock to the opening and theme-transition windows only. Worth checking the other non-chat workspace routes for the same pattern before merge.
There was a problem hiding this comment.
Fixed in 3dd922a — removed the redundant overflow: hidden on .obs-page-enter and scoped the body lock to the opening window, restoring document scroll.
willem-bd
left a comment
There was a problem hiding this comment.
Follow-up at 3dd922a — all four findings from the last round verified fixed: the boot script now interpolates SKIN_STORAGE_KEY from the shared constants (types.ts is a pure constants module, so importing it into the server layout is fine), corner-constellations stops its rAF once the stars settle and re-arms from pointermove (onStars is a stable useCallback in observatory-overlays, so no effect churn), page-enter renders an unconditional wrapper and toggles only the class (no subtree remount on skin flip), and document scroll is restored — the sidebar-inset !important clip and .obs-page-enter overflow are gone, and the body lock is now gated on html.obs-is-opening, which observatory-opening.tsx only sets during the 2.8s first-visit opening and cleans up reliably (the remaining overflow: hidden rules sit on fixed overlay layers only). One small residual of the idle-stop change noted inline; the base-rework items stay tracked on #4914/#4922.
| if (box) onStars?.(next.map((star) => toScreen(star, box))); | ||
| frame = window.requestAnimationFrame(tick); | ||
| } else { | ||
| frame = 0; |
There was a problem hiding this comment.
Small residual of the idle-stop change: while the loop is stopped here, the screen-space positions pushed via onStars go stale after a window resize or sidebar open/close. The settle branch never re-emits, and re-arming from pointermove only recomputes when a star actually displaces (pointer within 70 viewBox units of a home), so the corner link line in PointerPlay — extraStars are viewport coords drawn in the fixed obs-pointer-layer — can anchor to outdated coordinates until the pointer pushes a corner star. PointerPlay handles this itself via its resize listener + ResizeObserver (pointer-play.tsx:190); this component could mirror it: window.addEventListener("resize", arm) (with cleanup) plus re-emitting onStars with the fresh svgRef box in this settle branch. Cosmetic only, fine to fold into a follow-up.
There was a problem hiding this comment.
Fixed in 956dfad: corner-constellations now mirrors PointerPlay — a window resize listener (with cleanup) re-emits onStars with the fresh svgRef bounding box and re-arms the rAF loop, so the corner link line cannot anchor to stale coordinates after a resize or sidebar toggle.
willem-bd
left a comment
There was a problem hiding this comment.
Follow-up at 956dfad. The head commit (re-anchor corner stars on window resize) resolves the last residual cleanly — since .obs-corner-sky is a fixed full-viewport container, window resize is the only thing that moves its box, and the new handler both re-emits fresh screen coords and re-arms the loop; the settle branch now also emits an initial projection on mount, so PointerPlay gets the corner stars before the first pointer interaction. CI is green including e2e. Two small new findings below: two observatory class names that never had any CSS behind them, and the boot script painting the observatory palette onto routes that don't mount SkinProvider. Also a docs note, no code change needed: the PR description says selecting the observatory card triggers the horizon animation, but at this head the horizon only plays on theme switches — SkinCard wires onSelect={setSkin} directly with no playObservatoryHorizon call.
| <PromptInput | ||
| className={cn( | ||
| "bg-background/85 relative z-10 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl", | ||
| skin === "observatory" && "obs-input-shell", |
There was a problem hiding this comment.
Nit: obs-input-shell has no CSS behind it — at this head no stylesheet in the repo defines a .obs-input-shell selector (it's not in globals.css, and it never was, even in the first observatory-layer commit af67d96), so this class currently does nothing even though the PR description lists it as the observatory input-shell treatment. Same for obs-skin-card--observatory in appearance-settings-page.tsx (line 261): .obs-skin-card is styled, but the --observatory modifier has no rule. Either add the intended rules or drop the dead class names so the skin hooks that exist match the ones that are styled.
There was a problem hiding this comment.
Fixed in 00961a9 - dropped the no-op obs-input-shell class in input-box.tsx and the obs-skin-card--observatory modifier in appearance-settings-page.tsx. The remaining skin hooks (.obs-skin-card / .obs-theme-card under [data-skin="observatory"]) are the ones that are actually styled.
| try { | ||
| var s = localStorage.getItem(${JSON.stringify(SKIN_STORAGE_KEY)}); | ||
| if (s === ${JSON.stringify("observatory")}) { | ||
| document.documentElement.setAttribute("data-skin", ${JSON.stringify("observatory")}); |
There was a problem hiding this comment.
Suggestion: because this boot script runs on every route while the [data-skin="observatory"] token block in globals.css (line 478) overrides --background/--primary/--border etc. at the document root, an observatory user also gets the observatory palette on routes that never mount SkinProvider — the auth pages ((auth)/login, (auth)/setup use bg-background/border-border/text-muted-foreground), the landing page header, and blog — where none of the observatory layout/motifs exist and there's no way to switch the skin back. If the skin is meant to be workspace+showcase only, consider scoping the token overrides (e.g. under the workspace/showcase shells) or clearing the attribute in those layouts; if it's meant to be app-wide, mounting SkinProvider there too would at least make it consistent.
There was a problem hiding this comment.
Fixed in 00961a9 - scoped the palette to workspace/showcase only. The root boot script now applies data-skin only when the pathname is under /workspace or /showcase (via a shared SKIN_SCOPED_PREFIXES constant), and a new SkinRouteGuard client component clears the attribute on client-side navigation to public routes, so auth/landing/blog/docs never inherit the observatory palette.
00961a9 to
61bb616
Compare
Make the new warm-cream workspace appearance the default DeerFlow look, including refreshed message, welcome, and settings chrome.
Stack Observatory on the new classic workspace look as an opt-in skin, keeping classic as the default.
e3652d0 to
6ecc744
Compare
…ain them Keep inbound thread_id for Back/Cancel, but send create success to the submitted context: global list for fresh_thread_per_run, or the chosen target thread for reuse_thread. Cover both branches in e2e.
6ecc744 to
cdd48b1
Compare
Keep the redesigned memory facts list from this PR. Main's Streamdown sanitization on memory summaries does not apply because this branch no longer renders summaries via SafeStreamdown. Auto-merged chat-page (ThreadSubagentBatches), i18n, and mock-api branch coverage from bytedance#4983/bytedance#4998/bytedance#4987.
cdd48b1 to
96d3213
Compare
Keep the card+dialog scheduled-tasks UI from this PR. Port bytedance#5064 duplication into the create page via query params (title/prompt/context/schedule), restore schedule-preview via describeSchedule, and adapt the duplicate e2e to open the detail dialog then navigate to /new.
96d3213 to
2b78c56
Compare
# Conflicts: # frontend/src/components/workspace/input-box.tsx
2b78c56 to
36b430c
Compare
Honor an explicit context_mode when duplicating, and pass the source task id plus sessionStorage instead of serializing the prompt into the query string.
36b430c to
c9182a7
Compare
c9182a7 to
3f8d266
Compare
…llout
The describe("describeSchedule") block (once/daily en+zh, weekly en+zh,
weekly-no-weekdays fallback, hourly, monthly, custom) was accidentally
deleted during a rebase onto main while buildOnceRunAtLocal was extracted.
Restore it so the locale branches of describeSchedule stay covered.
…idle rAF thresholds, horizon timer cleanup, remove dead ws-intro
…elcome title under reduced-motion
…, debug meter css, unused skin vars, dead i18n keys)
…om during render), idle rAF stop, drop no-op font-display
…stable page-enter wrapper, DRY boot script
… workspace/showcase Remove the no-op obs-input-shell and obs-skin-card--observatory classes that had no CSS behind them, and gate the root-layout boot script on workspace/showcase pathnames so the observatory palette no longer leaks to auth/landing/blog/docs. A SkinRouteGuard clears data-skin on client-side navigation to public routes.
3f8d266 to
3ff11f5
Compare
概述
在 PR #4914(经典皮肤 + 前端重构)之上,叠加观星台专属皮肤内容。观星台是 deerflow 的第二个工作区皮肤,提供深蓝星空主题与全套动效。
改动内容
换肤入口与代码分割
observatory-lazy.tsx:观星台专属组件的next/dynamic懒加载入口(ssr: false),在workspace-content.tsx挂载core/skins/types.ts/storage.ts:SKIN_IDS扩展至["classic", "observatory"],新增hasPlayedObservatoryOpening/markObservatoryOpeningPlayed(开场动画记忆)动效(观星台皮肤专属)
page-enter.tsx在观星台下为路由切换注入obs-page-enter过渡动画;经典皮肤直接透传(无动画)theme-transition.ts的applyObservatoryTheme在切换明/暗主题时触发地平线动画(playObservatoryHorizon:to-night/to-dawn),主题切换延迟 900ms 执行 + 2s 淡出(obs-theme-fading)observatory-opening.tsx首次进入观星台时的星空开场(localStorage 记忆,仅播一次)workspace-intro.tsx/workspace-intro-event.ts进入工作区的引入过渡idle-meteors.tsx页面闲置45秒后,背景流星划过corner-constellations.tsx四角星座连线pointer-play.tsx鼠标移动时的交互星点settings-ornament.tsx设置面板星尘装饰asterism-mark.tsx欢迎页星群标识组件接入点
input-box.tsx:观星台下挂载ObservatoryOpening,输入外壳套用obs-input-shellwelcome.tsx:观星台欢迎语 +AsterismMarkmessage.tsx:观星台消息气泡变体(左侧描边、透明底)appearance-settings-page.tsx:经典 + 观星台双卡片网格,选中观星台时触发地平线动画;settings-dialog.tsx应用观星台类名样式
globals.css:观星台专属 CSS 块([data-skin="observatory"]选择器),含深蓝配色、obs-page-enter/obs-theme-fading/ws-intro动效、布局微调en-US.ts/zh-CN.ts/types.ts新增skins.observatory(含toggleSky/nightLabel/dawnLabel)测试
storage.test.ts:双皮肤存储测试theme-transition.test.ts:地平线动画 / 主题解析测试截图
验证
pnpm typecheck通过pnpm lint通过pnpm format通过依赖