Skip to content
Clone

feat(web/app): add the workspace entry, tokens and route tree

Mount the migrated workspace on /app behind the existing multi-page host:
split design tokens into primitive/semantic layers, keep the persistent
workspace layout so section switches do not remount the shell, and put the
invite route outside that layout. Providers live inside the router so they can
navigate.
ZhenYi committed
parent 8f26a37 commit 71e93e6 Browse files

12 files changed

+1860 −0

File tree 12
Added view/app/src/providers/index.ts View view/app/src/providers/index.ts
@@ -0,0 +1,2 @@
1export * from './theme-context'
2export * from './theme-provider'
Added view/app/src/providers/theme-context.ts View view/app/src/providers/theme-context.ts
@@ -0,0 +1,31 @@
1import { getLocale, translate } from '@app/i18n'
2import type { Palette, PaletteMode } from '@app/lib'
3import { createContext, useContext } from 'react'
4
5/**
6 * The appearance contract every themed surface reads from: which palette is
7 * painted, which light/dark variant is live, and the base64 CSS payload that
8 * produced it.
9 */
10export interface ThemeContextValue {
11 palette: Palette
12 paletteId: string
13 /** Requested mode (the `.dark` class state). */
14 mode: PaletteMode
15 /** Variant the palette actually rendered in. */
16 variant: PaletteMode
17 /** Base64 of the injected stylesheet, as persisted locally. */
18 encoded: string
19 palettes: Palette[]
20 setPalette: (id: string) => void
21 resetPalette: () => void
22 setMode: (mode: PaletteMode) => void
23}
24
25export const ThemeContext = createContext<ThemeContextValue | null>(null)
26
27export function useThemeContext(): ThemeContextValue {
28 const value = useContext(ThemeContext)
29 if (!value) throw new Error(translate(getLocale(), 'ui.themeContextMissing'))
30 return value
31}
Added view/app/src/providers/theme-provider.tsx View view/app/src/providers/theme-provider.tsx
@@ -0,0 +1,29 @@
1import { PALETTES, type PaletteMode, resetPalette, setPalette, setTheme, usePalette, useTheme } from '@app/lib'
2import { type ReactNode, useMemo } from 'react'
3import { ThemeContext, type ThemeContextValue } from './theme-context'
4
5/**
6 * Publishes the palette store to the tree. The store itself lives in
7 * `shared/lib/palettes` (plain module + `useSyncExternalStore`), so themed
8 * surfaces outside React — the injected `<style>` and the boot sequence — read
9 * the same state without a provider in scope.
10 */
11export function ThemeProvider({ children }: { children: ReactNode }) {
12 const palette = usePalette()
13 const mode = useTheme()
14 const value = useMemo<ThemeContextValue>(
15 () => ({
16 palette: palette.palette,
17 paletteId: palette.id,
18 mode,
19 variant: palette.variant,
20 encoded: palette.encoded,
21 palettes: PALETTES,
22 setPalette,
23 resetPalette,
24 setMode: (next: PaletteMode) => setTheme(next),
25 }),
26 [palette, mode],
27 )
28 return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
29}
Added view/app/src/providers/toast-provider.tsx View view/app/src/providers/toast-provider.tsx
@@ -0,0 +1,106 @@
1import { X } from 'lucide-react'
2import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'
3
4export interface ToastAction {
5 label: string
6 onClick: () => void
7}
8
9export interface ToastInput {
10 title: string
11 description?: string
12 action?: ToastAction
13 /** 毫秒;0 表示不自动关闭。 */
14 timeout?: number
15}
16
17interface Toast extends ToastInput {
18 id: number
19}
20
21interface ToastApi {
22 push(input: ToastInput): number
23 dismiss(id: number): void
24}
25
26const ToastContext = createContext<ToastApi | null>(null)
27
28export function ToastProvider({ children }: { children: ReactNode }) {
29 const [items, setItems] = useState<Toast[]>([])
30 const seq = useRef(0)
31 const timers = useRef(new Map<number, number>())
32
33 const dismiss = useCallback((id: number) => {
34 setItems((current) => current.filter((item) => item.id !== id))
35 const timer = timers.current.get(id)
36 if (timer) {
37 window.clearTimeout(timer)
38 timers.current.delete(id)
39 }
40 }, [])
41
42 const push = useCallback(
43 (input: ToastInput) => {
44 seq.current += 1
45 const id = seq.current
46 setItems((current) => [...current.slice(-4), { ...input, id }])
47 const timeout = input.timeout ?? 6_000
48 if (timeout > 0) {
49 const timer = window.setTimeout(() => dismiss(id), timeout)
50 timers.current.set(id, timer)
51 }
52 return id
53 },
54 [dismiss],
55 )
56
57 const value = useMemo<ToastApi>(() => ({ push, dismiss }), [push, dismiss])
58
59 return (
60 <ToastContext.Provider value={value}>
61 {children}
62 <div className="pointer-events-none fixed inset-x-0 top-3 z-[70] flex flex-col items-center gap-2 px-3 sm:items-end sm:pr-4">
63 {items.map((item) => (
64 <div
65 key={item.id}
66 role="status"
67 className="pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border border-shell-hairline bg-shell-surface px-3.5 py-3 shadow-lg"
68 >
69 <div className="min-w-0 flex-1">
70 <p className="truncate text-[13px] font-semibold text-foreground">{item.title}</p>
71 {item.description ? (
72 <p className="mt-0.5 line-clamp-2 text-[12px] text-shell-ink-muted">{item.description}</p>
73 ) : null}
74 {item.action ? (
75 <button
76 type="button"
77 onClick={() => {
78 item.action?.onClick()
79 dismiss(item.id)
80 }}
81 className="mt-2 rounded-md bg-shell-action px-2.5 py-1 text-[12px] font-semibold text-white"
82 >
83 {item.action.label}
84 </button>
85 ) : null}
86 </div>
87 <button
88 type="button"
89 aria-label="关闭"
90 onClick={() => dismiss(item.id)}
91 className="rounded-md p-1 text-shell-ink-muted hover:bg-shell-field"
92 >
93 <X className="h-3.5 w-3.5" />
94 </button>
95 </div>
96 ))}
97 </div>
98 </ToastContext.Provider>
99 )
100}
101
102const NOOP: ToastApi = { push: () => 0, dismiss: () => {} }
103
104export function useToast(): ToastApi {
105 return useContext(ToastContext) ?? NOOP
106}
Added view/app/src/routing/deferred-routes.tsx View view/app/src/routing/deferred-routes.tsx
@@ -0,0 +1,47 @@
1import { useT } from '@app/i18n'
2import { Loader2 } from 'lucide-react'
3import { lazy, type ReactNode, Suspense } from 'react'
4
5const WorkspaceRouteContent = lazy(() =>
6 import('./workspace-route').then((route) => ({ default: route.WorkspaceRouteContent })),
7)
8const NotFoundPage = lazy(() => import('@app/pages/not-found').then((page) => ({ default: page.NotFoundPage })))
9const SpaceInvitePage = lazy(() => import('@app/pages/invite').then((page) => ({ default: page.SpaceInvitePage })))
10
11function RoutePending() {
12 const { t } = useT()
13 return (
14 <main className="grid min-h-dvh place-items-center bg-background text-foreground" role="status">
15 <Loader2 size={22} className="animate-spin text-primary" aria-hidden />
16 <span className="sr-only">{t('common.action.loading')}</span>
17 </main>
18 )
19}
20
21function DeferredRoute({ children }: { children: ReactNode }) {
22 return <Suspense fallback={<RoutePending />}>{children}</Suspense>
23}
24
25export function WorkspaceRoute() {
26 return (
27 <DeferredRoute>
28 <WorkspaceRouteContent />
29 </DeferredRoute>
30 )
31}
32
33export function SpaceInviteRoute() {
34 return (
35 <DeferredRoute>
36 <SpaceInvitePage />
37 </DeferredRoute>
38 )
39}
40
41export function NotFoundRoute() {
42 return (
43 <DeferredRoute>
44 <NotFoundPage />
45 </DeferredRoute>
46 )
47}
Added view/app/src/routing/root-layout.tsx View view/app/src/routing/root-layout.tsx
@@ -0,0 +1,16 @@
1import { MessagePipelineProvider } from '@app/domains/chat/model/message-pipeline'
2import { ToastProvider } from '@app/providers/toast-provider'
3import { RealtimeProvider } from '@app/services/realtime/context'
4import { Outlet } from 'react-router-dom'
5
6export function RootLayout() {
7 return (
8 <RealtimeProvider>
9 <ToastProvider>
10 <MessagePipelineProvider>
11 <Outlet />
12 </MessagePipelineProvider>
13 </ToastProvider>
14 </RealtimeProvider>
15 )
16}
Added view/app/src/routing/router.tsx View view/app/src/routing/router.tsx
@@ -0,0 +1,33 @@
1import { createBrowserRouter, Navigate } from 'react-router-dom'
2import { NotFoundRoute, SpaceInviteRoute, WorkspaceRoute } from './deferred-routes'
3import { RootLayout } from './root-layout'
4
5export const router = createBrowserRouter([
6 {
7 path: '/',
8 element: <RootLayout />,
9 children: [
10 { index: true, element: <Navigate to="/app" replace /> },
11 { path: 'app', element: <Navigate to="/app/@me" replace /> },
12 { path: 'app/invites/accept', element: <SpaceInviteRoute /> },
13 {
14 path: 'app',
15 element: <WorkspaceRoute />,
16 children: [
17 { path: 'explore' },
18 { path: '@me' },
19 { path: '@me/notify' },
20 { path: '@me/chat/:roomId?' },
21 { path: '@me/board/:boardId?' },
22 { path: '@me/repo/:repoName?' },
23 { path: ':spaceName' },
24 { path: ':spaceName/chat/:roomId?' },
25 { path: ':spaceName/board/:boardId?' },
26 { path: ':spaceName/repo/:repoName?' },
27 { path: ':spaceName/members' },
28 ],
29 },
30 { path: '*', element: <NotFoundRoute /> },
31 ],
32 },
33])
Added view/app/src/routing/workspace-route.tsx View view/app/src/routing/workspace-route.tsx
@@ -0,0 +1,15 @@
1import { installVoiceSession } from '@app/domains/chat'
2import { prefetchWorkspaceBootstrap, WorkspaceShell } from '@app/pages/workspace'
3import { VoiceHost } from '@app/services/voice'
4
5installVoiceSession()
6prefetchWorkspaceBootstrap()
7
8export function WorkspaceRouteContent() {
9 return (
10 <>
11 <VoiceHost />
12 <WorkspaceShell />
13 </>
14 )
15}
Added view/app/src/styles/workspace.css View view/app/src/styles/workspace.css
@@ -0,0 +1,1448 @@
1/* The workspace uses one visual language across every feature and overlay. */
2.workspace-shell,
3.workspace-overlay {
4 --primary: var(--shell-action);
5 --primary-foreground: #fff;
6 --foreground: var(--shell-ink);
7 --card: var(--shell-surface);
8 --muted: var(--shell-field);
9 --muted-foreground: var(--shell-ink-muted);
10 --border: var(--shell-hairline);
11 --ring: var(--shell-focus);
12 color: var(--shell-ink);
13 font-size: 14px;
14 line-height: 1.6;
15}
16.workspace-shell {
17 --nav-rail: 76px;
18 --nav-side: 280px;
19 --nav-motion: 230ms;
20 --nav-ease: cubic-bezier(0.22, 0.68, 0.28, 1);
21 display: flex;
22 width: 100%;
23 height: 100dvh;
24 overflow: hidden;
25 background: var(--shell-sidebar);
26}
27/*
28 * Collapsing the sidebar animates the width of the navigation column and
29 * clips it, instead of toggling `display: none` on the sidebar. The sidebar
30 * keeps its own width inside that clip, so its contents never reflow while
31 * the column closes — only the main column relayouts, which it must anyway.
32 */
33.workspace-navigation {
34 display: flex;
35 flex-direction: column;
36 flex-shrink: 0;
37 overflow: hidden;
38 width: calc(var(--nav-rail) + var(--nav-side));
39 background: linear-gradient(to right, var(--shell-rail) 0 var(--nav-rail), var(--shell-sidebar) var(--nav-rail) 100%);
40 transition: width var(--nav-motion) var(--nav-ease);
41}
42.workspace-navigation-columns {
43 display: flex;
44 flex: 1;
45 min-height: 0;
46}
47.workspace-rail {
48 display: flex;
49 width: var(--nav-rail);
50 flex-shrink: 0;
51 flex-direction: column;
52 align-items: center;
53 gap: 14px;
54 overflow-y: auto;
55 padding: 20px 0;
56 background: var(--shell-rail);
57}
58.workspace-sidebar {
59 display: flex;
60 flex-direction: column;
61 width: var(--nav-side);
62 border-right: 1px solid var(--shell-hairline);
63 background: var(--shell-sidebar);
64 transition:
65 opacity calc(var(--nav-motion) * 0.45) ease calc(var(--nav-motion) * 0.4),
66 visibility 0s;
67}
68.workspace-sidebar-header {
69 display: flex;
70 align-items: center;
71 justify-content: space-between;
72 gap: 8px;
73 min-height: 76px;
74 padding: 14px 20px;
75 border-bottom: 1px solid var(--shell-hairline);
76}
77.workspace-sidebar-header > div {
78 min-width: 0;
79}
80/* The community header borrows Discord's compact-cover treatment: a real
81 * uploaded banner when available, a stable colour cover otherwise, and the
82 * community identity kept legible over both. */
83.workspace-sidebar-header:has(.space-community-header) {
84 position: relative;
85 align-items: stretch;
86 min-height: 132px;
87 padding: 0;
88 gap: 0;
89}
90.workspace-sidebar-header:has(.space-community-header) > .space-community-header {
91 width: 100%;
92}
93.workspace-sidebar-header:has(.space-community-header) .workspace-drawer-close {
94 position: absolute;
95 z-index: 42;
96 top: 10px;
97 right: 10px;
98 color: #fff;
99 background: rgb(10 14 24 / 28%);
100 backdrop-filter: blur(8px);
101}
102.workspace-sidebar-header:has(.space-community-header) .workspace-drawer-close:hover {
103 color: #fff;
104 background: rgb(10 14 24 / 50%);
105}
106.space-community-header {
107 position: relative;
108 z-index: 1;
109 min-width: 0;
110 min-height: 132px;
111 isolation: isolate;
112}
113.space-community-banner {
114 position: absolute;
115 inset: 0;
116 z-index: -1;
117 overflow: hidden;
118 background:
119 radial-gradient(132px 88px at 8% -14%, hsl(var(--space-banner-hue) 94% 82% / 88%), transparent 72%),
120 radial-gradient(142px 112px at 104% 2%, hsl(calc(var(--space-banner-hue) + 46) 78% 58% / 90%), transparent 72%),
121 linear-gradient(145deg, hsl(var(--space-banner-hue) 62% 42%), hsl(calc(var(--space-banner-hue) + 26) 70% 27%));
122}
123.space-community-banner::before,
124.space-community-banner::after {
125 position: absolute;
126 inset: 0;
127 content: "";
128 pointer-events: none;
129}
130.space-community-banner::before {
131 background: linear-gradient(
132 116deg,
133 rgb(255 255 255 / 14%) 0 12%,
134 transparent 12% 42%,
135 rgb(255 255 255 / 9%) 42% 52%,
136 transparent 52%
137 );
138 mix-blend-mode: soft-light;
139}
140.space-community-banner::after {
141 background: linear-gradient(180deg, rgb(8 12 20 / 4%) 0%, rgb(8 12 20 / 15%) 44%, rgb(8 12 20 / 74%) 100%);
142}
143.space-community-banner > img {
144 width: 100%;
145 height: 100%;
146 object-fit: cover;
147 transform: scale(1.02);
148}
149.space-community-menu-trigger {
150 position: relative;
151 z-index: 1;
152 display: flex;
153 width: 100%;
154 min-height: 132px;
155 align-items: flex-end;
156 gap: 10px;
157 padding: 15px 16px;
158 color: #fff;
159 text-align: left;
160 text-shadow: 0 1px 2px rgb(0 0 0 / 40%);
161 transition: background 160ms;
162}
163.space-community-menu-trigger:hover,
164.space-community-menu-trigger[aria-expanded="true"] {
165 background: rgb(7 10 16 / 15%);
166}
167.space-community-avatar {
168 display: grid;
169 width: 42px;
170 height: 42px;
171 flex: 0 0 auto;
172 place-items: center;
173 overflow: hidden;
174 border: 2px solid rgb(255 255 255 / 74%);
175 border-radius: 13px;
176 background: rgb(255 255 255 / 18%);
177 box-shadow: 0 3px 12px rgb(0 0 0 / 28%);
178 font-size: 17px;
179 font-weight: 700;
180 text-shadow: none;
181}
182.space-community-avatar > img {
183 width: 100%;
184 height: 100%;
185 object-fit: cover;
186}
187.space-community-name {
188 display: block;
189 overflow: hidden;
190 color: #fff;
191 font-size: 15px;
192 font-weight: 700;
193 line-height: 1.2;
194 text-overflow: ellipsis;
195 white-space: nowrap;
196}
197.space-community-members {
198 display: inline-flex;
199 align-items: center;
200 min-height: 18px;
201 margin-top: 4px;
202 padding: 1px 6px;
203 border: 1px solid rgb(255 255 255 / 20%);
204 border-radius: 999px;
205 background: rgb(255 255 255 / 16%);
206 color: rgb(255 255 255 / 92%);
207 font-size: 10px;
208 font-weight: 700;
209 line-height: 1;
210 text-shadow: none;
211}
212.space-community-members:hover {
213 background: rgb(255 255 255 / 28%);
214}
215.space-community-menu-trigger svg {
216 color: rgb(255 255 255 / 88%);
217}
218.workspace-nav-caption {
219 display: flex;
220 align-items: center;
221 justify-content: space-between;
222 padding: 18px 16px 8px 20px;
223 color: var(--shell-ink-muted);
224 font-size: 12px;
225 font-weight: 600;
226 letter-spacing: 0.06em;
227}
228.workspace-nav-pinned {
229 flex-shrink: 0;
230 padding: 10px 12px 2px;
231}
232.workspace-nav {
233 flex: 1;
234 overflow-y: auto;
235 padding: 0 12px 20px;
236}
237/* User bar at the foot of the navigation column: docked into the bottom-left
238 corner, floating clear of the rail above and of the channel list on its
239 right — the space it leaves shows the rail and sidebar surfaces as-is. */
240.workspace-navigation-footer {
241 display: flex;
242 min-height: 60px;
243 flex-shrink: 0;
244 align-items: center;
245 gap: 10px;
246 margin: 8px 8px 0 0;
247 padding: 12px 16px;
248 border-top-right-radius: 12px;
249 background: var(--shell-surface);
250 box-shadow: var(--shell-shadow);
251}
252.workspace-main {
253 position: relative;
254 display: flex;
255 flex: 1;
256 min-width: 0;
257 min-height: 0;
258 flex-direction: column;
259 background: var(--shell-surface);
260}
261.workspace-main:focus {
262 outline: none;
263}
264.workspace-icon-button {
265 display: inline-flex;
266 align-items: center;
267 justify-content: center;
268 width: 36px;
269 height: 36px;
270 flex-shrink: 0;
271 border-radius: 10px;
272 color: var(--shell-ink-muted);
273 transition:
274 background 160ms,
275 color 160ms;
276}
277.workspace-icon-button:hover {
278 background: var(--shell-active);
279 color: var(--shell-ink);
280}
281/* Three footer controls instead of two: the row tightens to keep the name legible. */
282.workspace-icon-button.is-compact {
283 width: 32px;
284 height: 32px;
285 border-radius: 9px;
286}
287/* A toggle that is "off" (muted microphone, deafened headphones) reads red. */
288.workspace-icon-button.is-off {
289 color: var(--error-foreground);
290 background: color-mix(in srgb, var(--destructive) 14%, transparent);
291}
292.workspace-icon-button.is-off:hover {
293 background: color-mix(in srgb, var(--destructive) 22%, transparent);
294 color: var(--error-foreground);
295}
296.workspace-collapse {
297 width: 28px;
298 height: 28px;
299}
300.workspace-expand {
301 position: absolute;
302 z-index: 15;
303 top: 20px;
304 left: 14px;
305 background: var(--shell-field);
306 opacity: 0;
307 visibility: hidden;
308 transform: translateX(-8px) scale(0.94);
309 transition:
310 opacity 120ms ease,
311 transform 180ms ease,
312 background 160ms,
313 color 160ms,
314 visibility 0s linear 120ms;
315}
316.navigation-collapsed .workspace-expand {
317 opacity: 1;
318 visibility: visible;
319 transform: none;
320 transition:
321 opacity 180ms ease calc(var(--nav-motion) * 0.5),
322 transform 300ms var(--nav-ease) calc(var(--nav-motion) * 0.45),
323 background 160ms,
324 color 160ms,
325 visibility 0s;
326}
327/*
328 * Directory column (explore): the rail is the whole navigation, so the space
329 * sidebar is dropped and the navigation column narrows to the rail width. The
330 * mobile drawer keeps the same rule; there is nothing behind it to open.
331 */
332.rail-only .workspace-navigation {
333 width: var(--nav-rail);
334}
335.rail-only .workspace-sidebar {
336 display: none;
337}
338.navigation-collapsed .workspace-navigation {
339 width: var(--nav-rail);
340}
341.navigation-collapsed .workspace-sidebar {
342 opacity: 0;
343 visibility: hidden;
344 transition:
345 opacity calc(var(--nav-motion) * 0.45) ease,
346 visibility 0s linear calc(var(--nav-motion) * 0.45);
347}
348.navigation-collapsed .workspace-navigation-footer,
349.rail-only .workspace-navigation-footer {
350 display: none;
351}
352/*
353 * The collapsed-nav expand button floats over the top-left of the content
354 * column, so the bar underneath it reserves that gutter. The button is
355 * `main`'s own first child, which rules out a structural `first-child`
356 * match, and the chat header sits four levels deep — so the two bars that
357 * can lead the column are named here.
358 */
359.workspace-main :is(.shell-top-bar, .chat-header) {
360 transition: padding-left var(--nav-motion) var(--nav-ease);
361}
362.navigation-collapsed .workspace-main :is(.shell-top-bar, .chat-header) {
363 padding-left: 64px;
364}
365/*
366 * The global search box is a fixed overlay pinned to the top-right of the
367 * window, so every bar that can lead the content column reserves a gutter for
368 * it — otherwise it sits on top of the bar's own actions. Below `lg` the
369 * overlay collapses to its icon, so the gutter stays small enough that the bar
370 * keeps a usable title. The overlay also centres on the bar's own height
371 * (76px, 36px box), so it never drifts off the bar's optical centre line.
372 */
373@media (min-width: 768px) {
374 .workspace-main :is(.shell-top-bar, .chat-header) {
375 padding-right: 64px;
376 }
377}
378@media (min-width: 1024px) {
379 .workspace-main :is(.shell-top-bar, .chat-header) {
380 padding-right: calc(clamp(200px, 22vw, 300px) + 28px);
381 }
382}
383/*
384 * An open chat panel owns the same top-right corner, so the overlay steps left
385 * by the panel's live width — otherwise it lands inside a resized detail pane.
386 * Below 1201px the panel is a modal drawer behind a scrim, and the trigger
387 * hides with the page it covers.
388 */
389@media (max-width: 1200px) {
390 body:has(.chat-side-panel) .global-search-trigger {
391 display: none;
392 }
393}
394@media (min-width: 1201px) {
395 body:has(.chat-side-panel) .global-search-trigger {
396 right: calc(var(--global-chat-panel-width, 380px) + 16px);
397 }
398}
399/*
400 * A docked detail pane pulls the search box off the window corner and into the
401 * chat column, so the header has to reserve the field's gutter. Below the width
402 * that can host both, that reservation starved the very header it floats over —
403 * at a 1243px window the column is 539px, the gutter took 302px of it and the
404 * title collapsed to zero width, stacking the topic one character per line in a
405 * 300px-tall bar. Under that width the trigger renders as its icon only (same
406 * affordance as the bar's other actions, still opens the palette on click) and
407 * the header reserves just the icon.
408 */
409@media (min-width: 1201px) and (max-width: 1599px) {
410 body:has(.chat-side-panel) .global-search-trigger {
411 width: 36px;
412 justify-content: center;
413 padding-inline: 0;
414 }
415 body:has(.chat-side-panel) .global-search-trigger > :is(span, kbd) {
416 display: none;
417 }
418 body:has(.chat-side-panel) .workspace-main .chat-header {
419 padding-right: 64px;
420 }
421}
422@media (min-width: 1201px) {
423 /* A custom-width detail pane becomes the reading focus. Hide the global
424 * command until that pane closes, and release its former header gutter.
425 * Declared after the width ladder above so it wins at every width. */
426 body:has(.chat-panel-resized) .global-search-trigger {
427 display: none;
428 }
429 body:has(.chat-panel-resized) .workspace-main .chat-header {
430 padding-right: 24px;
431 }
432}
433.global-search-overlay {
434 animation: global-search-fade 140ms ease-out both;
435}
436.global-search-dialog {
437 animation: global-search-pop 180ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
438 transform-origin: 50% 0;
439}
440@keyframes global-search-fade {
441 from {
442 opacity: 0;
443 }
444 to {
445 opacity: 1;
446 }
447}
448@keyframes global-search-pop {
449 from {
450 opacity: 0;
451 transform: translateY(-10px) scale(0.985);
452 }
453 to {
454 opacity: 1;
455 transform: translateY(0) scale(1);
456 }
457}
458@media (prefers-reduced-motion: reduce) {
459 .global-search-overlay,
460 .global-search-dialog {
461 animation: none;
462 }
463}
464.workspace-mobile-bar,
465.workspace-drawer-close,
466.workspace-scrim {
467 display: none;
468}
469.workspace-skip {
470 position: fixed;
471 left: 12px;
472 top: -80px;
473 z-index: 100;
474 padding: 10px 16px;
475 border-radius: 10px;
476 color: white;
477 background: var(--shell-action);
478}
479.workspace-skip:focus {
480 top: 12px;
481}
482.shell-top-bar {
483 display: flex;
484 align-items: center;
485 gap: 12px;
486 min-height: 76px;
487 flex-shrink: 0;
488 padding: 14px 24px;
489 border-bottom: 1px solid var(--shell-hairline);
490}
491.shell-top-icon {
492 display: grid;
493 place-items: center;
494 width: 38px;
495 height: 38px;
496 flex-shrink: 0;
497 border-radius: 12px;
498 color: var(--shell-ink);
499 background: transparent;
500}
501.shell-top-icon svg {
502 color: inherit;
503}
504.shell-top-actions {
505 display: flex;
506 flex-shrink: 0;
507 align-items: center;
508 gap: 10px;
509 color: var(--shell-ink-muted);
510}
511.workspace-nav-row {
512 position: relative;
513 min-height: 32px;
514 margin-bottom: 2px;
515 gap: 10px;
516 border-radius: 8px;
517 transition:
518 background 160ms,
519 color 160ms;
520}
521.workspace-nav-row:is(a, button, [data-kind="folder"]):hover {
522 background: var(--shell-active);
523}
524/* Selected rows sit two shades below the hover gray instead of flipping to white. */
525.workspace-nav-row[aria-current="page"] {
526 color: var(--shell-ink);
527 background: color-mix(in srgb, var(--shell-ink) 4%, var(--shell-tile));
528 font-weight: 600;
529}
530.workspace-nav-row[aria-current="page"] > span:first-child {
531 color: var(--shell-ink-muted);
532}
533.workspace-nav-row[data-kind="folder"] {
534 margin-top: 12px;
535}
536/* Rail tiles rest as circles and square off on hover or when they own the route. */
537.workspace-rail a,
538.workspace-rail button {
539 border-radius: 9999px;
540 transition:
541 background 160ms,
542 color 160ms,
543 border-color 160ms,
544 border-radius 200ms,
545 box-shadow 160ms;
546}
547.workspace-rail a:hover,
548.workspace-rail button:hover,
549.workspace-rail a[aria-current="page"] {
550 border-radius: 16px;
551}
552.workspace-rail a[aria-current="page"] {
553 color: var(--shell-ink);
554}
555/* The dashed create / discover ring follows the same morph, without changing its fill. */
556.rail-dashed-ring-shape {
557 rx: 22px;
558 transition: rx 200ms;
559}
560.workspace-rail a:hover .rail-dashed-ring-shape,
561.workspace-rail button:hover .rail-dashed-ring-shape,
562.workspace-rail a[aria-current="page"] .rail-dashed-ring-shape {
563 rx: 14px;
564}
565/* Keyboard focus for controls. */
566.workspace-shell :is(button, a, summary, [role="button"], [role="option"]):focus-visible,
567.workspace-overlay :is(button, a, summary, [role="button"], [role="option"]):focus-visible {
568 outline: 2px solid color-mix(in srgb, var(--shell-focus) 70%, transparent);
569 outline-offset: 2px;
570}
571
572/*
573 * Fields: one hairline, real corners, and a soft ring on focus. The 2px
574 * near-black frame these used to get read as an error state rather than a
575 * focused input, and the bare `<input>` inside an icon row has no radius of
576 * its own, so the browser outline drew square corners.
577 */
578.workspace-shell :is(input, select, textarea):not([type="checkbox"]):not([type="radio"]):not(.bg-transparent),
579.workspace-overlay :is(input, select, textarea):not([type="checkbox"]):not([type="radio"]):not(.bg-transparent) {
580 border-width: 1px;
581 border-radius: 9px;
582}
583.workspace-shell :is(input, select, textarea):not(.bg-transparent):focus,
584.workspace-overlay :is(input, select, textarea):not(.bg-transparent):focus {
585 border-color: var(--shell-tile);
586 outline: none;
587 box-shadow: 0 0 0 3px color-mix(in srgb, var(--shell-focus) 20%, transparent);
588}
589.workspace-shell :is(input, select, textarea):not(.bg-transparent):hover:not(:focus),
590.workspace-overlay :is(input, select, textarea):not(.bg-transparent):hover:not(:focus) {
591 border-color: color-mix(in srgb, var(--shell-tile) 70%, var(--shell-hairline));
592}
593/* A transparent field is bare: its rounded wrapper owns the border and ring. */
594.workspace-shell .bg-transparent:focus-visible,
595.workspace-overlay .bg-transparent:focus-visible {
596 outline: none;
597}
598.workspace-shell :is(button, [role="button"]):not(:disabled),
599.workspace-overlay :is(button, [role="button"]):not(:disabled) {
600 cursor: pointer;
601 touch-action: manipulation;
602}
603.workspace-shell :is(button, input, select, textarea),
604.workspace-overlay :is(button, input, select, textarea) {
605 font-family: inherit;
606}
607.workspace-shell .bg-primary.font-mono,
608.workspace-overlay .bg-primary.font-mono {
609 font-family: var(--font-sans);
610 letter-spacing: normal;
611}
612.workspace-shell ::selection,
613.workspace-overlay ::selection {
614 background: var(--shell-active);
615 color: var(--shell-ink);
616}
617.workspace-shell *,
618.workspace-overlay * {
619 scrollbar-width: thin;
620 scrollbar-color: var(--shell-tile) transparent;
621}
622.workspace-overlay {
623 background: rgb(18 20 22 / 46%);
624 backdrop-filter: blur(5px);
625}
626.workspace-dialog {
627 max-height: calc(100dvh - 32px);
628 overflow-y: auto;
629 border: 1px solid var(--shell-hairline);
630 border-radius: 20px;
631 padding: 28px;
632 box-shadow: 0 24px 80px rgb(15 17 19 / 20%);
633}
634.workspace-dialog h2 {
635 font-size: 20px;
636 line-height: 1.4;
637}
638.workspace-dialog :is(input, select, textarea) {
639 border-width: 1px;
640 border-radius: 10px;
641 font-size: 14px;
642}
643.workspace-dialog :is(input:not([type="checkbox"]), select) {
644 min-height: 42px;
645}
646.workspace-dialog .dialog-close {
647 display: grid;
648 place-items: center;
649 width: 32px;
650 height: 32px;
651 border-radius: 10px;
652 background: var(--shell-field);
653}
654.settings-dialog {
655 display: flex;
656 position: relative;
657 width: min(1320px, calc(100vw - 3rem));
658 height: min(860px, calc(100dvh - 3rem));
659 overflow: hidden;
660 border: 1px solid var(--shell-hairline);
661 border-radius: 18px;
662 background: var(--shell-surface);
663 box-shadow: 0 24px 80px rgb(15 17 19 / 22%);
664}
665.settings-nav {
666 display: flex;
667 width: 264px;
668 flex-shrink: 0;
669 flex-direction: column;
670 gap: 2px;
671 overflow-y: auto;
672 padding: 16px 12px 20px;
673 background: var(--shell-sidebar);
674 border-right: 1px solid var(--shell-hairline);
675}
676.settings-nav-body {
677 display: flex;
678 min-height: 0;
679 flex: 1;
680 flex-direction: column;
681 gap: 2px;
682}
683.settings-nav-title {
684 padding: 8px 10px 12px;
685 font-size: 16px;
686 font-weight: 700;
687 color: var(--shell-ink);
688}
689.settings-nav-caption {
690 padding: 14px 10px 4px;
691 font-size: 11px;
692 font-weight: 700;
693 letter-spacing: 0.06em;
694 color: var(--shell-ink-muted);
695}
696.settings-nav-item {
697 display: flex;
698 width: 100%;
699 min-height: 36px;
700 align-items: center;
701 gap: 10px;
702 padding: 7px 10px;
703 border-radius: 8px;
704 color: var(--shell-ink-muted);
705 font-size: 14px;
706 text-align: left;
707 transition:
708 background 160ms,
709 color 160ms;
710}
711.settings-nav-item:hover {
712 background: var(--shell-active);
713 color: var(--shell-ink);
714}
715.settings-nav-item[aria-current="page"] {
716 background: var(--shell-active);
717 color: var(--shell-ink);
718 font-weight: 600;
719}
720/* Destructive sections read red wherever they sit in a settings nav. */
721.settings-nav-item.settings-nav-danger,
722.settings-nav-item.settings-nav-danger[aria-current="page"] {
723 color: #f23f43;
724}
725.settings-nav-item.settings-nav-danger:hover {
726 background: color-mix(in srgb, #f23f43 10%, transparent);
727 color: #f23f43;
728}
729.settings-pane {
730 display: flex;
731 flex: 1;
732 min-width: 0;
733 flex-direction: column;
734}
735.settings-pane-header {
736 display: flex;
737 min-height: 60px;
738 align-items: center;
739 justify-content: space-between;
740 gap: 12px;
741 padding: 10px 16px 10px 28px;
742 border-bottom: 1px solid var(--shell-hairline);
743}
744.settings-pane-header h2 {
745 min-width: 0;
746 overflow: hidden;
747 font-size: 17px;
748 font-weight: 700;
749 text-overflow: ellipsis;
750 white-space: nowrap;
751}
752.settings-content {
753 flex: 1;
754 min-width: 0;
755 min-height: 0;
756 overflow-y: auto;
757 scrollbar-gutter: stable;
758 padding: 32px 40px 56px;
759}
760.settings-content > section {
761 max-width: 720px;
762}
763/* Swapped panes cross-fade in place: no movement, no empty flash. */
764.settings-swap {
765 position: relative;
766}
767.settings-swap-out {
768 position: absolute;
769 top: 0;
770 left: 0;
771 right: 0;
772 pointer-events: none;
773 animation: settings-swap-out 200ms ease both;
774}
775.settings-swap-in {
776 animation: settings-swap-in 200ms ease both;
777}
778@keyframes settings-swap-in {
779 from {
780 opacity: 0;
781 }
782 to {
783 opacity: 1;
784 }
785}
786@keyframes settings-swap-out {
787 from {
788 opacity: 1;
789 }
790 to {
791 opacity: 0;
792 }
793}
794/* The pane header already names the section, so the body keeps only its note. */
795.settings-content > section > .settings-section-head > h2 {
796 display: none;
797}
798.settings-content :is(input, select, textarea) {
799 border-width: 1px;
800 border-radius: 10px;
801 min-height: 40px;
802 font-size: 14px;
803}
804.settings-content input[type="checkbox"] {
805 min-height: 0;
806}
807.settings-close {
808 flex-shrink: 0;
809 background: var(--shell-field);
810}
811/*
812 * Settings lists (user / space / repo): one card per entry, its inline editor
813 * revealed below. Surfaces stay neutral — selection is shown by weight and a
814 * hairline, never by a tint, so a page of rows does not turn into a colour chart.
815 */
816.settings-row {
817 display: flex;
818 min-width: 0;
819 align-items: center;
820 gap: 12px;
821}
822/*
823 * A card is filled rather than outlined onto the same colour as the dialog, so
824 * its padding reads as part of the container. Everything inside steps back up
825 * to the dialog surface, and `--muted` is remapped so a secondary button keeps
826 * a hover state darker than the card instead of the same colour as it.
827 */
828.settings-card {
829 --card: var(--shell-surface);
830 --muted: var(--shell-active);
831 --input: var(--shell-tile);
832 border: 1px solid var(--shell-hairline);
833 border-radius: 14px;
834 background: var(--shell-field);
835}
836.settings-card > .settings-row {
837 padding: 12px 14px;
838}
839.settings-card :is(input, select, textarea):not([type="color"]) {
840 background: var(--shell-surface);
841}
842.settings-card .settings-chip {
843 background: var(--shell-surface);
844}
845.settings-card .settings-segment-group {
846 background: var(--shell-active);
847}
848.settings-card .settings-segment[aria-pressed="true"] {
849 background: var(--shell-surface);
850}
851.settings-card .settings-facts code {
852 background: var(--shell-surface);
853}
854.settings-card.is-danger {
855 border-color: color-mix(in srgb, #f23f43 45%, var(--shell-hairline));
856 background: color-mix(in srgb, #f23f43 7%, var(--shell-field));
857 padding: 16px;
858}
859.settings-subpanel {
860 padding: 14px 14px 16px;
861 border-top: 1px solid var(--shell-hairline);
862}
863.settings-subpanel.is-flat {
864 border-top: 0;
865}
866.settings-disclose {
867 display: inline-flex;
868 align-items: center;
869 gap: 4px;
870 padding: 4px 9px;
871 border-radius: 8px;
872 color: var(--shell-ink-muted);
873 font-size: 12.5px;
874 transition:
875 background 160ms,
876 color 160ms;
877}
878.settings-disclose:hover {
879 color: var(--shell-ink);
880 background: color-mix(in srgb, var(--shell-ink) 8%, transparent);
881}
882.settings-disclose svg {
883 transition: transform 180ms;
884}
885.settings-segment-group {
886 display: inline-flex;
887 gap: 2px;
888 padding: 2px;
889 border-radius: 10px;
890 background: var(--shell-field);
891}
892.settings-segment {
893 min-height: 28px;
894 padding: 3px 10px;
895 border-radius: 8px;
896 color: var(--shell-ink-muted);
897 font-size: 12.5px;
898 transition:
899 background 160ms,
900 color 160ms,
901 box-shadow 160ms;
902}
903.settings-segment:hover:not(:disabled) {
904 color: var(--shell-ink);
905}
906.settings-segment[aria-pressed="true"] {
907 color: var(--shell-ink);
908 font-weight: 600;
909 background: var(--shell-surface);
910 box-shadow: inset 0 0 0 1px var(--shell-hairline);
911}
912.settings-segment:disabled {
913 opacity: 0.55;
914}
915.settings-chip {
916 padding: 4px 10px;
917 border: 1px solid var(--shell-hairline);
918 border-radius: 999px;
919 background: var(--shell-field);
920 color: var(--shell-ink-muted);
921 font-size: 12px;
922 transition:
923 border-color 160ms,
924 background 160ms,
925 color 160ms;
926}
927.settings-chip:hover {
928 color: var(--shell-ink);
929}
930.settings-chip.is-on {
931 color: var(--shell-ink);
932 font-weight: 600;
933 background: var(--shell-surface);
934 border-color: var(--shell-ink-muted);
935}
936.settings-facts {
937 display: grid;
938 gap: 9px;
939 margin: 0;
940}
941.settings-facts > div {
942 display: flex;
943 flex-wrap: wrap;
944 align-items: baseline;
945 gap: 10px;
946}
947.settings-facts dt {
948 width: 96px;
949 flex-shrink: 0;
950 color: var(--shell-ink-muted);
951 font-size: 12px;
952}
953.settings-facts dd {
954 display: flex;
955 align-items: center;
956 gap: 8px;
957 margin: 0;
958 font-size: 13px;
959}
960.settings-facts code {
961 padding: 2px 6px;
962 border-radius: 5px;
963 background: var(--shell-field);
964 font-family: var(--font-mono);
965 font-size: 11.5px;
966}
967.settings-danger-btn {
968 background: #f23f43;
969 color: #fff;
970}
971.settings-danger-btn:hover {
972 background: color-mix(in srgb, #f23f43 88%, #000);
973}
974.board-toolbar {
975 padding: 16px 24px;
976 gap: 12px;
977 border-bottom: 1px solid var(--shell-hairline);
978}
979.board-canvas {
980 min-height: 0;
981 padding: 20px 24px;
982 background: var(--shell-field);
983}
984.board-track {
985 gap: 18px;
986}
987.repo-toolbar {
988 display: flex;
989 flex-wrap: wrap;
990 align-items: center;
991 gap: 12px;
992 padding: 12px 24px 0;
993 border-bottom: 1px solid var(--shell-hairline);
994}
995.repo-toolbar-refs {
996 display: flex;
997 min-width: 0;
998 flex-shrink: 0;
999 flex-wrap: wrap;
1000 gap: 8px;
1001}
1002.repo-toolbar-clone {
1003 display: flex;
1004 flex-shrink: 0;
1005 align-items: center;
1006 gap: 8px;
1007}
1008.repo-toolbar-clone.empty .repo-overview-toggle {
1009 margin-left: auto;
1010}
1011.repo-toolbar-tabs {
1012 display: flex;
1013 min-width: 0;
1014 max-width: 100%;
1015 align-items: center;
1016 overflow-x: auto;
1017 padding-left: 12px;
1018 border-left: 1px solid var(--shell-hairline);
1019 overscroll-behavior-x: contain;
1020 scrollbar-width: none;
1021}
1022.repo-toolbar-tabs::-webkit-scrollbar {
1023 display: none;
1024}
1025.repo-toolbar-tabs > nav {
1026 flex: 0 0 auto;
1027}
1028.repo-content-layout {
1029 display: flex;
1030 flex: 1;
1031 min-height: 0;
1032 gap: 24px;
1033 padding: 16px 24px 24px;
1034 background: var(--shell-field);
1035}
1036.repo-content-layout > aside {
1037 width: 260px;
1038}
1039.repo-overview-toggle {
1040 display: none;
1041}
1042@media (max-width: 1200px) {
1043 .workspace-shell {
1044 --nav-rail: 68px;
1045 --nav-side: 224px;
1046 }
1047
1048 .repo-content-layout > aside {
1049 display: none;
1050 }
1051 .repo-toolbar-tabs {
1052 padding-left: 0;
1053 border-left: 0;
1054 }
1055 .repo-content-layout.overview-open > aside {
1056 display: block;
1057 width: 260px;
1058 }
1059 .repo-overview-toggle {
1060 display: flex;
1061 }
1062}
1063/* Appearance picker: swatch cards for the base64-persisted palettes. */
1064.palette-grid {
1065 display: grid;
1066 grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
1067 gap: 8px;
1068}
1069.palette-card {
1070 display: flex;
1071 flex-direction: column;
1072 gap: 5px;
1073 padding: 9px 10px;
1074 border: 1px solid var(--shell-hairline);
1075 border-radius: 12px;
1076 background: var(--shell-surface);
1077 text-align: left;
1078 transition:
1079 border-color 160ms,
1080 box-shadow 160ms;
1081}
1082.palette-card:hover {
1083 border-color: var(--shell-tile);
1084}
1085.palette-card.is-active {
1086 border-color: var(--shell-ink-muted);
1087 box-shadow: inset 0 0 0 1px var(--shell-ink-muted);
1088}
1089.palette-swatches {
1090 display: flex;
1091 height: 22px;
1092 gap: 3px;
1093}
1094.palette-swatches > i {
1095 flex: 1;
1096 border: 1px solid rgb(0 0 0 / 8%);
1097 border-radius: 4px;
1098}
1099.palette-swatches > i:first-child {
1100 flex: 2.4;
1101}
1102.palette-name {
1103 font-size: 13px;
1104 font-weight: 600;
1105}
1106.palette-origin {
1107 font-size: 11px;
1108 color: var(--shell-ink-muted);
1109}
1110.palette-meta {
1111 display: flex;
1112 flex-wrap: wrap;
1113 align-items: center;
1114 gap: 8px;
1115 margin-top: 10px;
1116 font-size: 12px;
1117 color: var(--shell-ink-muted);
1118}
1119.palette-meta code {
1120 max-width: 100%;
1121 overflow: hidden;
1122 padding: 2px 6px;
1123 border-radius: 5px;
1124 background: var(--shell-field);
1125 font-family: var(--font-mono);
1126 font-size: 11px;
1127}
1128.palette-restore {
1129 color: var(--shell-ink);
1130 text-decoration: underline;
1131 text-underline-offset: 2px;
1132}
1133
1134@media (max-width: 760px) {
1135 .workspace-shell {
1136 --nav-rail: 64px;
1137 flex-direction: column;
1138 }
1139 .workspace-mobile-bar {
1140 display: flex;
1141 min-height: 52px;
1142 flex-shrink: 0;
1143 align-items: center;
1144 gap: 10px;
1145 padding: 0 12px;
1146 border-bottom: 1px solid var(--shell-hairline);
1147 }
1148 .workspace-navigation,
1149 .navigation-collapsed .workspace-navigation {
1150 position: fixed;
1151 inset: 0 auto 0 0;
1152 z-index: 45;
1153 width: min(338px, calc(100vw - 44px));
1154 visibility: hidden;
1155 transform: translateX(-100%);
1156 transition:
1157 transform var(--nav-motion) var(--nav-ease),
1158 visibility 0s linear var(--nav-motion);
1159 }
1160 .navigation-open .workspace-navigation {
1161 visibility: visible;
1162 transform: translateX(0);
1163 box-shadow: 20px 0 60px rgb(15 17 19 / 18%);
1164 transition-delay: 0s, 0s;
1165 }
1166 .workspace-sidebar,
1167 .navigation-collapsed .workspace-sidebar {
1168 display: flex;
1169 flex: 1;
1170 min-width: 0;
1171 width: auto;
1172 opacity: 1;
1173 visibility: visible;
1174 }
1175 .rail-only .workspace-sidebar,
1176 .rail-only .navigation-collapsed .workspace-sidebar {
1177 display: none;
1178 }
1179 .rail-only .workspace-navigation,
1180 .rail-only.navigation-open .workspace-navigation {
1181 width: 64px;
1182 }
1183 .workspace-sidebar-header {
1184 padding: 14px;
1185 }
1186 .workspace-sidebar-header:has(.space-community-header) {
1187 min-height: 124px;
1188 padding: 0;
1189 }
1190 .space-community-header,
1191 .space-community-menu-trigger {
1192 min-height: 124px;
1193 }
1194 .workspace-rail {
1195 width: 64px;
1196 }
1197 .workspace-drawer-close,
1198 .workspace-scrim {
1199 display: flex;
1200 }
1201 .workspace-collapse,
1202 .workspace-expand {
1203 display: none;
1204 }
1205 .workspace-scrim {
1206 position: fixed;
1207 inset: 0;
1208 z-index: 40;
1209 background: rgb(15 17 19 / 42%);
1210 backdrop-filter: blur(3px);
1211 }
1212 .shell-top-bar,
1213 .navigation-collapsed .workspace-main :is(.shell-top-bar, .chat-header) {
1214 min-height: 68px;
1215 padding: 12px 16px;
1216 }
1217 .shell-top-actions {
1218 gap: 6px;
1219 }
1220 .shell-top-icon {
1221 width: 32px;
1222 height: 32px;
1223 border-radius: 10px;
1224 }
1225 .workspace-overlay {
1226 padding: 12px;
1227 }
1228 .workspace-dialog {
1229 padding: 22px;
1230 max-height: calc(100dvh - 24px);
1231 }
1232 .settings-dialog {
1233 width: 100%;
1234 height: calc(100dvh - 24px);
1235 border-radius: 16px;
1236 flex-direction: column;
1237 }
1238 .settings-nav {
1239 width: 100%;
1240 flex-direction: row;
1241 align-items: center;
1242 gap: 4px;
1243 padding: 12px 14px;
1244 border-right: 0;
1245 border-bottom: 1px solid var(--shell-hairline);
1246 overflow-x: auto;
1247 flex-shrink: 0;
1248 }
1249 .settings-nav-title {
1250 padding: 0 10px 0 2px;
1251 font-size: 15px;
1252 white-space: nowrap;
1253 }
1254 .settings-nav-caption {
1255 display: none;
1256 }
1257 .settings-nav-item {
1258 width: auto;
1259 flex-shrink: 0;
1260 white-space: nowrap;
1261 }
1262 .settings-pane-header {
1263 min-height: 56px;
1264 padding: 10px 12px 10px 18px;
1265 }
1266 .settings-content {
1267 padding: 20px 18px 32px;
1268 }
1269 .settings-row {
1270 flex-wrap: wrap;
1271 align-items: flex-start;
1272 }
1273 .settings-card > .settings-row {
1274 padding: 12px;
1275 }
1276 .settings-subpanel {
1277 padding: 12px;
1278 }
1279 .settings-facts dt {
1280 width: 100%;
1281 }
1282 .board-toolbar {
1283 padding: 12px 16px;
1284 }
1285 .board-canvas {
1286 padding: 16px;
1287 }
1288 .repo-toolbar {
1289 padding: 10px 16px 0;
1290 }
1291 .repo-toolbar-refs {
1292 flex: 1 1 100%;
1293 min-width: 0;
1294 }
1295 .repo-toolbar-clone {
1296 max-width: 100%;
1297 flex-wrap: wrap;
1298 margin-left: 0;
1299 }
1300 .repo-content-layout {
1301 padding: 12px 16px 16px;
1302 }
1303 .repo-content-layout.overview-open {
1304 flex-direction: column;
1305 overflow-y: auto;
1306 }
1307 .repo-content-layout.overview-open > section {
1308 flex: none;
1309 overflow: visible;
1310 }
1311 .repo-content-layout.overview-open > aside {
1312 width: 100%;
1313 overflow: visible;
1314 }
1315 .workspace-shell input,
1316 .workspace-shell textarea,
1317 .workspace-overlay input,
1318 .workspace-overlay textarea,
1319 .workspace-overlay select {
1320 font-size: 16px;
1321 }
1322}
1323@media (prefers-reduced-motion: reduce) {
1324 .workspace-shell *,
1325 .workspace-overlay * {
1326 animation-duration: 0.01ms !important;
1327 animation-iteration-count: 1 !important;
1328 transition-duration: 0.01ms !important;
1329 transition-delay: 0s !important;
1330 scroll-behavior: auto !important;
1331 }
1332}
1333
1334/* 用户与流水线产出的 Markdown(描述、评论、release 正文)。语义与 README 的
1335 * markdown-body 一致,但作用域是应用内部,不依赖服务端片段样式。 */
1336.gitdata-markdown {
1337 font-size: 13px;
1338 line-height: 1.65;
1339 color: var(--shell-ink);
1340 word-break: break-word;
1341}
1342.gitdata-markdown-compact {
1343 font-size: 12.5px;
1344}
1345.gitdata-markdown > :first-child {
1346 margin-top: 0;
1347}
1348.gitdata-markdown > :last-child {
1349 margin-bottom: 0;
1350}
1351.gitdata-markdown h1,
1352.gitdata-markdown h2 {
1353 border-bottom: 1px solid var(--shell-hairline);
1354 padding-bottom: 0.25em;
1355 margin: 0.9em 0 0.4em;
1356 font-weight: 700;
1357}
1358.gitdata-markdown h1 {
1359 font-size: 1.35em;
1360}
1361.gitdata-markdown h2 {
1362 font-size: 1.2em;
1363}
1364.gitdata-markdown h3 {
1365 font-size: 1.08em;
1366 font-weight: 700;
1367 margin: 0.9em 0 0.35em;
1368}
1369.gitdata-markdown h4,
1370.gitdata-markdown h5,
1371.gitdata-markdown h6 {
1372 font-weight: 700;
1373 margin: 0.8em 0 0.3em;
1374}
1375.gitdata-markdown p {
1376 margin: 0.5em 0;
1377}
1378.gitdata-markdown ul,
1379.gitdata-markdown ol {
1380 padding-left: 1.5em;
1381 margin: 0.5em 0;
1382}
1383.gitdata-markdown ul {
1384 list-style: disc;
1385}
1386.gitdata-markdown ol {
1387 list-style: decimal;
1388}
1389.gitdata-markdown li {
1390 margin: 0.15em 0;
1391}
1392.gitdata-markdown blockquote {
1393 margin: 0.6em 0;
1394 padding: 0.15em 0.9em;
1395 border-left: 3px solid var(--shell-active);
1396 color: var(--shell-ink-muted);
1397}
1398.gitdata-markdown a {
1399 color: inherit;
1400 text-decoration: underline;
1401 text-underline-offset: 3px;
1402}
1403.gitdata-markdown code {
1404 background: var(--shell-field);
1405 border: 1px solid var(--shell-hairline);
1406 border-radius: 4px;
1407 padding: 0.05em 0.35em;
1408 font-family: ui-monospace, monospace;
1409 font-size: 0.92em;
1410}
1411.gitdata-markdown pre {
1412 background: var(--shell-field);
1413 border: 1px solid var(--shell-hairline);
1414 border-radius: 8px;
1415 padding: 10px 12px;
1416 overflow-x: auto;
1417 margin: 0.6em 0;
1418}
1419.gitdata-markdown pre code {
1420 background: none;
1421 border: none;
1422 padding: 0;
1423 font-size: 12px;
1424}
1425.gitdata-markdown table {
1426 border-collapse: collapse;
1427 margin: 0.6em 0;
1428 display: block;
1429 overflow-x: auto;
1430}
1431.gitdata-markdown th,
1432.gitdata-markdown td {
1433 border: 1px solid var(--shell-hairline);
1434 padding: 5px 9px;
1435}
1436.gitdata-markdown th {
1437 background: var(--shell-field);
1438 font-weight: 600;
1439}
1440.gitdata-markdown img {
1441 max-width: 100%;
1442 border-radius: 6px;
1443}
1444.gitdata-markdown hr {
1445 border: none;
1446 border-top: 1px solid var(--shell-hairline);
1447 margin: 1em 0;
1448}
Added view/app/src/tokens/primitive.css View view/app/src/tokens/primitive.css
@@ -0,0 +1,75 @@
1@custom-variant dark (&:where(.dark, .dark *));
2
3:root {
4 --background: #f2f3f0;
5 --foreground: #111111;
6 --card: #ffffff;
7 --muted: #f2f3f0;
8 --muted-foreground: #666666;
9 --primary: #ff8400;
10 --primary-foreground: #111111;
11 --secondary: #e7e8e5;
12 --secondary-foreground: #111111;
13 --border: #cbccc9;
14 --input: #cbccc9;
15 --ring: #666666;
16 --destructive: #d93c15;
17 --success: #dfe6e1;
18 --success-foreground: #004d1a;
19 --warning: #e9e3d8;
20 --warning-foreground: #804200;
21 --error: #e5dcda;
22 --error-foreground: #8c1c00;
23 --info: #dfdfd6;
24 --info-foreground: #000066;
25
26 /* Workspace surfaces and text are paired for light and dark appearance. */
27 --shell-rail: #f1f2f2;
28 --shell-sidebar: #fafafa;
29 --shell-surface: #ffffff;
30 --shell-field: #f5f5f4;
31 --shell-active: #eceeed;
32 --shell-tile: #e3e5e5;
33 --shell-board: #f5f5f4;
34 --shell-action: #2f3438;
35 --shell-focus: #8d949c;
36 --shell-ink: #23272b;
37 --shell-ink-muted: #687078;
38 --shell-hairline: #e1e3e3;
39 --shell-shadow: 0 6px 24px rgb(24 28 31 / 6%);
40}
41
42.dark {
43 color-scheme: dark;
44 --shell-rail: #151718;
45 --shell-sidebar: #1a1c1e;
46 --shell-surface: #202224;
47 --shell-field: #282a2d;
48 --shell-active: #34373a;
49 --shell-tile: #3c4044;
50 --shell-board: #1b1d1f;
51 --shell-action: #565e67;
52 --shell-focus: #7f8791;
53 --shell-ink: #eceeef;
54 --shell-ink-muted: #b1b5ba;
55 --shell-hairline: #3a3d40;
56 --shell-shadow: 0 6px 24px rgb(0 0 0 / 16%);
57 --background: #111111;
58 --foreground: #ffffff;
59 --card: #1a1a1a;
60 --muted: #2e2e2e;
61 --muted-foreground: #b8b9b6;
62 --secondary: #2e2e2e;
63 --secondary-foreground: #ffffff;
64 --border: #2e2e2e;
65 --input: #2e2e2e;
66 --destructive: #ff5c33;
67 --success: #222924;
68 --success-foreground: #b6ffce;
69 --warning: #291c0f;
70 --warning-foreground: #ff8400;
71 --error: #24100b;
72 --error-foreground: #ff5c33;
73 --info: #222229;
74 --info-foreground: #b2b2ff;
75}
Added view/app/src/tokens/semantic.css View view/app/src/tokens/semantic.css
@@ -0,0 +1,52 @@
1@theme inline {
2 --color-background: var(--background);
3 --color-foreground: var(--foreground);
4 --color-card: var(--card);
5 --color-muted: var(--muted);
6 --color-muted-foreground: var(--muted-foreground);
7 --color-primary: var(--primary);
8 --color-primary-foreground: var(--primary-foreground);
9 --color-secondary: var(--secondary);
10 --color-secondary-foreground: var(--secondary-foreground);
11 --color-border: var(--border);
12 --color-input: var(--input);
13 --color-ring: var(--ring);
14 --color-destructive: var(--destructive);
15 --color-success: var(--success);
16 --color-success-foreground: var(--success-foreground);
17 --color-warning: var(--warning);
18 --color-warning-foreground: var(--warning-foreground);
19 --color-error: var(--error);
20 --color-error-foreground: var(--error-foreground);
21 --color-info: var(--info);
22 --color-info-foreground: var(--info-foreground);
23
24 --color-shell-rail: var(--shell-rail);
25 --color-shell-sidebar: var(--shell-sidebar);
26 --color-shell-surface: var(--shell-surface);
27 --color-shell-field: var(--shell-field);
28 --color-shell-active: var(--shell-active);
29 --color-shell-tile: var(--shell-tile);
30 --color-shell-board: var(--shell-board);
31 --color-shell-action: var(--shell-action);
32 --color-shell-ink: var(--shell-ink);
33 --color-shell-ink-muted: var(--shell-ink-muted);
34 --color-shell-hairline: var(--shell-hairline);
35
36 --font-mono: "JetBrains Mono", ui-monospace, monospace;
37 --font-sans:
38 BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", Arial, "PingFang SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
39
40 --radius-card: 16px;
41 --radius-field: 10px;
42}
43
44@layer base {
45 html {
46 font-family: var(--font-sans);
47 -webkit-font-smoothing: antialiased;
48 }
49 body {
50 @apply bg-background text-foreground;
51 }
52}
Added view/app/src/bootstrap.tsx View view/app/src/bootstrap.tsx
@@ -0,0 +1,6 @@
1import { ThemeProvider } from '@app/providers'
2import type { ReactNode } from 'react'
3
4export function AppProviders({ children }: { children: ReactNode }) {
5 return <ThemeProvider>{children}</ThemeProvider>
6}