feat: merge workspace web UX demo

This commit is contained in:
2026-09-18 17:41:10 +09:00
53 changed files with 3586 additions and 490 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"fmt": {
"proseWrap": "never"
}
}
+10 -6
View File
@@ -19,12 +19,16 @@ It is not a dumping ground for external research, old plans, API inventories, or
11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
14. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
15. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
16. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
17. [`development/validation.md`](development/validation.md) — how to check changes.
18. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — the canonical SQLite baseline, compatibility fence, and manual dogfooding-data repair procedure.
19. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。
14. [`development/ui-ux/design-language.md`](development/ui-ux/design-language.md) — Workspace Webの視覚・interaction原則、文法、token、汎用component。
15. [`development/ui-ux/product-ux.md`](development/ui-ux/product-ux.md) — Workspace resource、navigation IA、resource固有pattern。
16. [`development/ui-ux/application-architecture.md`](development/ui-ux/application-architecture.md) — application shell、Header、Sidebar、nested override architecture。
17. [`development/ui-ux/visual-review.md`](development/ui-ux/visual-review.md) — Web UI実装者がbefore/afterを目視し、完了前に残すvisual validation evidence。
18. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
19. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
20. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
21. [`development/validation.md`](development/validation.md) — how to check changes.
22. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
23. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。
## What belongs here
@@ -1,78 +0,0 @@
# Workspace web design system
This document defines the visual rules for `web/workspace`. The current authority for tokens and reusable page/sidebar styling is `web/workspace/src/app.css`.
## Design position
Workspace web should read as a control surface, not a set of detached widgets. Group information primarily through spacing, typography, and text contrast. Use borders, rounded rectangles, shadows, and filled panels only when they clarify hierarchy that spacing cannot express.
## Palette
Colors are defined as CSS custom properties in OKLCH. The palette supports light and dark modes through `prefers-color-scheme`.
Rules:
- Background and layout surfaces use zero chroma: `oklch(... 0 0)`.
- Primary text and code text are near-neutral warm colors. Because CSS OKLCH exposes chroma (`C`) rather than saturation directly, encode the “about 5% saturation” intent as very low warm chroma, around `C = 0.01` to `0.012`.
- Muted text reduces lightness/chroma before introducing new hues.
- Accent/status colors are semantic exceptions. They should mark state, focus, or navigation, not decorate containers.
- Do not introduce raw hex/rgb colors in Workspace web components. Add or reuse a token in `app.css`.
Core tokens:
```css
--bg
--bg-raised
--bg-subtle
--line
--line-strong
--text
--text-strong
--text-muted
--text-faint
--code
--accent
--success
--warning
--danger
```
## Layout and grouping
Prefer vertical rhythm and text hierarchy over card chrome.
- Page sections are separated by whitespace and a light top rule.
- Navigation selection uses a left rule rather than filled pills.
- Nested records use indentation or top rules, not repeated rounded containers.
- Shadows are avoided in the base system.
- Rounded corners are reserved for small controls where hit area shape matters.
## Typography
- Headings and primary labels use `--text-strong`.
- Body text uses `--text`.
- Metadata, helper text, timestamps, and table headings use `--text-muted` or `--text-faint`.
- Uppercase labels are acceptable for small metadata labels only; avoid large all-caps UI blocks.
## Component styling boundary
`app.css` owns the shared visual language:
- reset/base body styles
- OKLCH tokens
- layout primitives
- page cards/sections
- sidebar/navigation sections
- tables, kanban lists, diagnostics, record bodies
Svelte components should keep local styles only when a behavior is truly component-specific. If a style affects color, spacing, borders, text hierarchy, or repeated record layout, it belongs in `app.css`.
## Adding new UI
When adding Workspace web UI:
1. Start with semantic HTML and existing classes from `app.css`.
2. Use spacing and text contrast first.
3. Use a border only when the boundary carries meaning.
4. Use background fills only for page-level surfaces or read-only code/record bodies.
5. If a new color is needed, define it as an OKLCH token and document why the existing semantic tokens are insufficient.
@@ -0,0 +1,104 @@
# Workspace Web Application Architecture
この文書は、Workspace Webのapplication shell、Header、Sidebar、nested
layoutのcomposition authorityを定義する。Navigation
IAは[`product-ux.md`](product-ux.md)、視覚とinteractionの共通規則は[`design-language.md`](design-language.md)、具体的なsource
pathとCSS
ownershipは[`../../../web/workspace/README.md`](../../../web/workspace/README.md)を参照する。
## Application shell
root layoutが唯一のapplication shellを所有する。
- Sidebar frame
- global fallback navigation
- Header frame
- main content
- SidebarとHeaderのoverride context
page routeはapplication shell、global Header、Sidebar、fold
controlを再実装しない。Pageのmarkupは標準shellのmainへ配置するcontentから始める。
## Header composition
Headerはroot shellが一度だけrenderする。Nested
layoutは`HeaderOverride`を通じて現在scopeのlocation contentを登録する。
- page componentはHeader frameを作らない。
- 最も深いactive overrideを表示する。
- layoutが破棄されたら直前のoverrideへ戻る。
- overrideがなければroot fallbackを表示する。
- route titleをmain contentへ複製しない。
## Sidebar composition
### Root
root shellは`SidebarFrame``GlobalSidebar`を一度だけrenderし、root
`SidebarController`を提供する。Overrideがない場合、`GlobalNavSections`がfallbackになる。
### Workspace
Workspace layoutは、親のroot
slotへ`WorkspaceSidebar`を登録する。同時に、さらに深いlayoutが使う新しい`SidebarController`をcontextへ設定する。
`WorkspaceSidebar`はWorkspace shortcut headerを保持し、その下へchild
slotをrenderする。Child overrideがない場合だけ、Workspace navigation
fallbackを表示する。
### Settings
Settings layoutは、Workspace layoutが提供したchild
slotへ`SettingsSidebar`を登録する。Workspace shortcut headerは残り、Workspace
navigation fallbackだけがSettings navigationへ置き換わる。
さらに深いscopeも同じcontractを使う。
## Override stack
`HeaderOverride``SidebarOverride`は、layout lifecycleにbindしたLIFO
stackとして扱う。
1. Layout mount時にparent controllerへregisterする。
2. Controllerは最も新しいactive registrationを表示する。
3. Nested layoutはchild controllerを新しく提供する。
4. Layout destroy時に自分のregistrationをremoveする。
5. 一つ前のregistrationがあれば復元する。
6. Stackが空ならfallbackへ戻る。
同じregistrationを複数回removeしても結果を変えない。古いcleanupが新しいactive
registrationを削除してはならない。
## Ownership
- `SidebarFrame`だけが`aside`、外枠、scroll領域、fold controlを所有する。
- `GlobalSidebar``WorkspaceSidebar``SettingsSidebar`は各scopeのcontentを所有する。
- `SidebarOverride`を登録できるのはscopeを定義するlayoutだけとする。
- page component、一時的なwidget、dialogはSidebarを差し替えない。
- child layoutは親navigationをcopyせず、自分のsidebar layerだけをregisterする。
- Global、Workspace、Settingsを一つのsnippetへ平坦化しない。
## Data authority
Sidebar compositionはnavigation
structureを決めるが、Workspace、permission、Worker
stateのauthorityにはならない。
- Workspace identityとpermissionはBackend projectionを使う。
- Worker listとstateはWorkspace protocol projectionを使う。
- route pathやdisplay labelからauthorityを推測しない。
- unavailable dataを架空の正常stateで置き換えない。
- permissionにより利用できないnavigationは、権威あるpermission
projectionに基づいて除外する。
## Design-lab
認証不要のstatic design-labはroot shellを再利用し、実際と同じnested
controllerとoverride stackを構成する。
Live Workspace protocolへbindするresource listだけは、同じscope layer内のstatic
fixtureへ置き換えてよい。Static fixtureへ別scopeのnavigationを混ぜず、production
componentと同じ順序、state表現を使う。
Design-lab固有のswitch controlでSidebarを差し替えず、route
hierarchyによってlayout lifecycleを発生させる。
+196
View File
@@ -0,0 +1,196 @@
# Workspace Web Design Language
この文書は、Workspace Webのrendered resultが満たす設計規則を定義する。Product固有のresource構成は[`product-ux.md`](product-ux.md)、application shellとSidebar slotの実装構造は[`application-architecture.md`](application-architecture.md)、CSSとsource配置は[`../../../web/workspace/README.md`](../../../web/workspace/README.md)、visual review手順は[`visual-review.md`](visual-review.md)をauthorityとする。
一つの規則は最も具体的な一箇所だけをauthorityとする。具体的な二案の一方を棄却できない文は規則として残さない。
## 1. Principles
複数の規則や要求が競合した場合だけ、この章の優先原理を使う。
### Task before explanation
次の有効なactionへ到達する前の操作、scroll、常設説明が少ない案を選ぶ。ただし、説明を省くと対象、結果、permission、不可逆性を誤認する場合は説明をactionより前に置く。
### Meaning before decoration
情報の関係、重要度、操作可能性、stateのいずれも変えない視覚差を追加しない。
### Authority before convenience
Backend authorityが確定していないdata、state、permission、actionを、UIの都合で補完または推測する案を棄却する。
## 2. Semantics / Grammar
### Information structure
- pageと、page内で一つのtaskまたは判断を完結させる領域をscopeとする。
- 各scopeで最も強い視覚要素はheading、現在判断が必要なattention、filled actionのいずれか一つだけにする。同じ強さのheading、attention、filled actionを複数置かない。
- hierarchyをcolorだけで表現しない。DOMと画面上の配置順を一致させた上で、size、weight、contrastを使う。
- scopeを識別するprimary titleは、一つのscopeにつき一度だけ表示する。
- actionは、そのactionが影響するscope内に置く。
- DTOにfieldが存在するという理由だけで表示せず、current taskの判断またはactionを変える値だけを常設する。
**Disclosure**
- Raw dataやデバッグ情報など、通常は必要としない詳細な情報は折りたたみ領域に隠す。
- 作成または編集のformを表示用screenへ常時同居させず、明示的なactionで開く折りたたみ領域または別pageに置く。
### Interaction and state
- actionにはbutton、navigationにはlink、選択には対応するform controlを使う。
- interactive elementはhover、focus、active、disabledを視覚的に区別する。
- staticなstatus、key、metadataをbuttonやpillの形にしない。
- clickable rowは遷移先を一つだけ持ち、row全体で同じ遷移を実行する。
- permissionがないactionをdisabled controlとして表示しない。
**State and operation**
- stateの違いによってユーザーの判断または利用できるactionが変わる場合、その違いを一つの表示へ統合しない。
- UI stateはそのstateを所有するauthorityから取得し、別domainやtransportのsignalから推測しない。
- 非同期mutationでは楽観的更新か悲観的更新を選ぶ。楽観的更新は暫定stateを明示し、failure時のrollbackまたは再取得を定義できる場合だけ使う。それ以外は確定済みstateを維持し、operationのpending stateを別に表示する。
- loading、failure、unavailableの表示は利用不能になった最小のscopeだけを置き換え、利用可能な親scopeとsibling scopeを残す。
- destructive actionは対象と結果を明記し、復元できない場合だけconfirmationを要求する。
### Language / Copy
- title、navigation label、control labelで明らかな内容を説明文で繰り返さない。
- 常設proseは、対象またはactionの選択を変えるwarning、permission、validation constraint、error recoveryに必要な内容だけにする。
- button labelには具体的なactionを表す動詞を使い、`Submit``OK`を使わない。
- identifier、API名、type名を別の語へ言い換えない。
### Responsive behavior
- page全体にhorizontal scrollを発生させない。
- wrappingまたはstack化によって情報の対応関係が失われる場合だけ、その領域内にbounded horizontal scrollを持たせる。それ以外のside-by-side layoutは一列にする。
- primary actionを初期viewportから追い出さない。
- contentをtruncateする場合、完全な値をcopyまたはdetailで取得できるようにする。
### Accessibility
- action、navigation、form controlを非semantic elementだけで実装しない。
- すべてのcontrolにaccessible nameを与える。
- keyboard focusを不可視にしない。
- tab orderを画面上の順序と一致させる。
- stateをcolorだけで区別しない。
- horizontal scroll regionをkeyboard focus可能にし、accessible nameを与える。
## 3. Primitives / Tokens
### Color / Theme
colorはCSS custom propertyをauthorityとし、通常のWorkspace colorはOKLCHで定義する。lightとdarkは同じsemantic token名を使う。
```css
--bg
--bg-raised
--bg-subtle
--line
--line-strong
--text
--text-strong
--text-muted
--text-faint
--code
--accent
--accent-muted
--success
--warning
--danger
--interactive-hover
--interactive-selected
--shadow-overlay
```
- `--bg``--bg-raised``--bg-subtle`へhueを持たせない。
- accentはfocus、current navigation、current selectionだけに使い、通常本文や通常actionの背景へ使わない。
- success、warning、dangerを、それぞれ対応するstate以外へ使わない。
- componentへraw colorを追加せず、上記semantic tokenを使う。
- ConsoleとterminalのANSI paletteを通常のstatusやformへ流用しない。
### Typography
font sizeは、その領域でユーザーが行う読み方によって選ぶ。
- defaultは`14px / 20px`とし、個々のtextを読むこと自体がtaskの中心になる領域に使う。
- 同じ構造の反復を一覧として走査する領域と、位置、順序、形、選択状態を中心に識別するUI chromeには`12px / 16px`を使う。textを読まなければactionの意味や結果を判断できない場合は`14px / 20px`を使う。
- `24px / 32px`はページまたはdocument内で唯一かつ最大のheadingだけに使う。
- 収めるため、または情報を弱く見せるためにfont sizeを下げない。`12px`未満と`13px`を使わない。
- 機械的な値にはmonospace familyを使う。
```css
--font-sans
--font-mono
--font-size-title /* 24px */
--line-height-title /* 32px */
--font-size-body /* 14px */
--line-height-body /* 20px */
--font-size-compact /* 12px */
--line-height-compact /* 16px */
```
### Spacing / Shape
```css
--space-1 /* 4px */
--space-2 /* 8px */
--space-3 /* 12px */
--space-4 /* 16px */
--space-5 /* 24px */
--space-6 /* 32px */
--radius-soft
```
- 一つの境界にspacing、line、surface backgroundを重ねず、primary cueを一つだけ使う。
- 汎用的な`Card` primitiveを設けない。
- nested surfaceでparentと同じ境界表現を繰り返さない。
- shadowは通常flowから浮くmenu、popover、Tooltipだけに使う。
- radiusはinteractive controlだけに使う。
### Motion
- motion完了をactionの受付、state change、content理解の条件にしない。
- 繰り返しまたは連続animationを、`prefers-reduced-motion: reduce`で停止する。
## 4. Components / Patterns
### Border / Structural edge
- `Bevel`は1pxの単色borderを描くvisual wrapperであり、照明、raisedinset、ridgegrooveを表現しない。
- `Bevel`自体へbackgroundやsurface toneを与えず、必要なbackgroundは内側のsemantic childが持つtext/content areaへ適用する。
- 隣接するsurfaceでは、実際に境界となる辺だけを有効にする。
- standaloneな閉じた領域は全辺を`Bevel`で囲う。Desktop shellではHeaderのbottom edgeとSidebarのright edgeだけを有効にし、viewport外周のtopleft edgeを描かない。
- `BevelLine`は開いた領域内の1px separatorだけに使う。同じlayerではheading、本文、Lineの端を揃え、Lineだけに端方向のpaddingやmarginを追加しない。
- nested layerでは親layoutがそのlayer全体へinline方向の余白を与え、content、row、Lineを一緒に移動する。Lineだけを短くしない。
- control自身のborder、focus ring、status marker、table gridを`Bevel`または`BevelLine`で置き換えない。
### Sidebar
- Desktop幅は`clamp(220px, 20vw, 280px)`とする。
- navigation contentだけをscrollさせる。
- Desktopのfold controlはSidebar下部、Mobileのfold controlはHeaderに置く。
- MobileでSidebarを表示するときはHeader下の残りviewport全体を使い、main contentと同時表示しない。
- navigationとscope control以外を置かない。
- navigation linkはSidebar幅全体を使うflat rowとする。
- active routeは一つの視覚cueと`aria-current="page"`で示す。
- iconだけでnavigation labelを置き換えない。
- fold controlは一つだけ置き、current stateに対応するaccessible nameを使う。
### Tooltip / Contextual help
- operationやtable全体を説明する常設proseを置く前に、button labelとcolumn headingだけで識別できるようにする。
- labelだけでは表現できない補助説明だけを、対象へbindしたTooltipに入れる。
- pointer hoverとkeyboard focusの両方で開く。
- triggerとTooltipを`aria-describedby`で関連付ける。
- hoverはdelay後、focusは直ちに表示する。
- `Escape`で閉じ、triggerからfocusを移動しない。
- 複数paragraph、form、link、buttonを入れない。
- error、permission、validation constraint、不可逆操作の結果、current stateをTooltipへ入れない。
- touch環境ではTooltipだけを説明への到達手段にしない。
### Generic component patterns
- **Form field**: label、control、constraintまたはhelp、field errorの順に配置する。
- **Key-value**: 一つのresourceの属性には`dl`を使う。
Design-labではcomponent名と実際のsampleだけを表示する。規則とrationaleをUIへ書かない。
+143
View File
@@ -0,0 +1,143 @@
# Workspace Web Product UX
この文書は、Workspace Webで扱うresource、navigationのinformation
architecture、resourceごとの主要taskを定義する。見せ方の共通文法は[`design-language.md`](design-language.md)、application
shellとSidebar
slotは[`application-architecture.md`](application-architecture.md)を参照する。
## Product position
Workspace
Webは、Workspace内の権威ある状態を確認し、注意が必要な箇所を判断し、一度に一つの操作を行うための作業面である。Marketing
dashboard、analytics画面、Backend DTOのraw viewerにはしない。
基本的な操作の流れは次とする。
```text
scopeを選ぶ
→ resourceを選ぶ
→ current stateを確認する
→ 次の有効なactionを行う
→ 結果を確認する
```
## Resource
主要resourceは次のとおり。
- Workspace
- Ticket
- Objective
- Merge Request
- Memory
- Worker
- Workdir
- Runtime
- Repository
通常の表示とURLにはcanonical resource keyを使う。内部UUIDをlabelやfallback
linkとして露出しない。
## Navigation IA
### Global
Workspaceを選ぶ前のscopeを扱う。
- Workspace catalog
- Account
- Device Login
- Workspace作成
### Workspace
一つのWorkspace内で日常的に扱うresourceを置く。
1. Tickets
2. Objectives
3. Merge Requests
4. Memory
- Document
- Staging
5. Workers
### Settings
Workspaceの管理と構成を扱う。
- Runtimes
- Configuration Sources
- Repositories
- Repository Access
- Profile Sources
- Workspace Identity
`Runtimes``Repositories``Repository Access`をWorkspace primary
navigationへ混ぜない。permissionにより利用できないSettings itemは表示しない。
## Page model
各routeは、主要な目的を一つだけ持つ。その目的は「Ticketを選ぶ」「このWorkerを確認する」「Repository
accessを設定する」のように、動詞と対象を一つずつ使って表現できなければならない。
Workspace pageは、次のいずれかを基本形とする。
1. **一覧またはboard** — resourceを探し、注意が必要な項目を見つける。
2. **詳細** — 一つのresourceを理解し、現在実行できる次の操作を行う。
3. **formまたはeditor** — 範囲の明確な設定変更を行う。
4. **Console** — 一つのWorkerを操作しながら、履歴とcurrent controlを確認する。
同じ目的を支える場合に限り、一つのrouteで複数の基本形を組み合わせてよい。
## Product pattern
### Tickets
- boardを一つの横方向の作業面として扱う。
- laneはworkflow stateを表し、隣接laneとの関係を維持する。
- Ticketはlane内でDesign Languageの`Resource row` patternを使う。
- Ticket key、title、優先判断を変えるmetadataだけを表示する。
- blockerとattentionはtextでも示す。
- narrow viewportでは、bounded horizontal scrollでlane比較を維持する。
### Resource index
- 同じfieldをresource間で比較する場合、flat listまたはtableを使う。
- 人が読むnameとcanonical keyを最初のcolumnに置く。
- columnは判断に必要な値だけに絞る。
- provider detail、revision、digestはdetailまたはtechnical disclosureへ移す。
### Resource detail
- identity、current state、attentionを最初に置く。
- narrativeまたはhistoryをprimary領域に置く。
- relation、target、review、workflowが別のauthorityを持つ場合、それぞれを独立したsectionにする。
- raw metadataはtechnical disclosureへ置く。
### Settingsとform
- 一つのSettings routeは一つの設定関心事を所有する。
- edit actionの前または同時に、現在有効な値を示す。
- 一つの判断に必要なfieldをgroup化する。
- narrow viewportでは一列にする。
- form内でfilled actionにするのはsave actionだけとする。
### Worker Console
- transcriptをprimary surfaceとする。
- direct SubWorkerを表示している場合も、Composerとrun
controlは親Workerへbindしたままにする。
- OverviewとNormalはpresentationだけを変え、authorityを変えない。
- command outputはterminalに近いcompactな表示とし、prose messageから分離する。
- live stateは権威あるWorker snapshotから導出する。
- connection stateやtransient eventでcurrent stateを置き換えない。
- hidden reasoningとraw system promptはdebug modeでも表示しない。
## Stateとpermission
- 存在しない、閲覧できない、読み込みに失敗したWorkspaceを別の状態として扱う。
- 別のWorkspaceへ暗黙に切り替えない。
- owner-only mutation controlをnon-ownerへ表示しない。
- read-onlyの場合は、有用なread viewを残す。
- Workerのdomain stateとWebSocket connection stateを混同しない。
- Mergeなど不可逆または重要なoperationは、対象と結果を明示してから実行する。
+272
View File
@@ -0,0 +1,272 @@
# Web visual review
この文書は、Web UIを変更する実装者が完了前に行うvisual
reviewの必須手順を定義する。Visual reviewは任意のpolish工程ではなく、type
check、linter、testと並ぶvalidationである。
[`design-language.md`](design-language.md)はWorkspace
Webが満たす視覚とinteractionの共通規則を定義する。Product固有の構成は[`product-ux.md`](product-ux.md)を参照する。[`tools/web-ux/README.md`](../../../tools/web-ux/README.md)はcapture
toolの操作方法を定義する。この文書は、誰が、いつ、何を目視し、どの証拠を残さなければ実装完了とみなせないかを定義する。
## 実装者の責任
Visual
reviewは、後段のReviewerやユーザーへ最初の発見を委ねない。UIを変更した実装者自身が、同じ変更の中で次を行う。
1. 変更前をcaptureする。
2. 変更後を同じ条件でcaptureする。
3. screenshotとbrowser evidenceを自分で確認する。
4. 発見した問題を修正する。
5. 修正後を再captureして確認する。
6. 変更と無関係な既存問題は、根拠を付けて分離する。
Agentが実装者の場合、そのAgent自身が`ViewImage`または同等のVLM入力で実画面を確認する。captureを生成しただけ、別のAgentへ画像を渡しただけ、HTTP
200を確認しただけでは完了しない。
## 適用範囲
次の変更ではvisual reviewを必須とする。
- page、layout、navigation、Sidebar、Header、Console、dialog
- componentのmarkupまたはstyle
- color、typography、spacing、border、radius、shadow、theme token
- copy、label、status、empty/loading/error/permission表示
- responsive breakpoint、container、overflow、scroll
- 表示dataの追加、削除、並び替え、progressive disclosure
- icon、focus、keyboard interaction、motion
- UIへ影響するgenerated typeまたはAPI projection
型、Backend、CLI、文書だけの変更でrendered Web
UIが変わらない場合は省略できる。省略する場合は、UIへ影響しない理由をvalidation
evidenceへ一文で残す。
## 必須の確認条件
変更したsurfaceに関係する条件だけを選ぶが、都合のよい一画面だけに限定しない。
### Persona
権限やdataが表示を変える場合は、該当するpersonaを分ける。
- owner
- non-owner
- anonymous
- read-onlyまたは操作権限なし
owner表示だけでpermission-aware UIを承認しない。
### Viewport
最低限、次を確認する。
- desktop: `1440px`幅を基準
- narrow: Sidebarとmainが競合する幅。現在は`768px`を基準
- mobile: `390px`幅を基準
`320px`対応、table、long form、Console、Sidebar
foldなどが変更対象なら、その境界も追加する。media
queryのviewport幅だけでなく、Sidebarやpanelを差し引いた実際のcontent幅を確認する。
### Theme
color、border、background、focus、status、code、terminalを変更した場合はlightとdarkの両方を確認する。片方のthemeで読めることを、もう片方のcontrastの証拠にしない。
### Data state
該当するstateを明示的に用意する。
- representative data
- empty
- loading
- error
- permission deniedまたはread-only
- long title、long key、long error、複数行content
- collectionの最小件数と、実際に起こりうる多い件数
happy pathだけでcomponentを承認しない。
## 目視確認の観点
### 情報のまとまり
- 同じ判断に使う情報がproximity、alignment、typographyによって一つのgroupに見えるか。
- 別の意味を持つ情報が、十分な距離または一つの明確なboundaryで分離されているか。
- 親子、peer、metadata、actionの関係が、DOMを読まなくても判別できるか。
- card、background、border、shadow、余白を重ねて同じgroupingを重複表現していないか。
- page title、Header、breadcrumb、section
heading、説明文が同じ意味を繰り返していないか。
### Separatorとborder
画面上のすべての線について、何と何を区切る線か説明できなければならない。
- Headerの終端、peer row間、section開始、table
cellなど、線の役割が一つに定まっているか。
- 近接した二本の線が同じboundaryを重複表現していないか。
- 線の上下の余白から、その線が前後どちらのgroupに属するか分かるか。
- すべてのsectionへ機械的に同じ線を置き、hierarchyを平坦化していないか。
- borderがなくてもspacingだけで十分な箇所へ線を追加していないか。
### Spacingとdensity
- 余白がtoken scaleに沿い、近い関係ほど狭く、別groupほど広くなっているか。
- labelとvalue、headingとcontent、rowとrow、sectionとsectionの間に一貫した大小関係があるか。
- 線の前後へ同程度の大きな余白を置き、boundaryの所属を曖昧にしていないか。
- 初期viewportにprimary contentまたは主要操作が存在するか。
- 空きすぎた領域が情報不足やhierarchyの弱さを隠していないか。
- 密度を上げるためにfont、touch target、line heightを過度に縮めていないか。
### Wrapping、overflow、可変長content
- 実際に起こりうる最長のlabel、title、key、ref、error、翻訳で確認したか。
- 一行を前提とするcontrolが意図せず二行にならないか。
- button labelが折り返されたとき、row heightとalignmentが壊れないか。
- title、metadata、code、URLが隣接actionを押し出さないか。
- truncateする場合、完全な値へaccessできるか。
- horizontal
scrollはtable、board、terminalなど、その意味を所有する領域だけに閉じているか。
- page全体へhorizontal overflowが発生していないか。
- `overflow-wrap`だけで問題を隠さず、可読性と比較可能性を維持しているか。
### Responsive layout
- DOM順、視覚順、keyboard順が一致しているか。
- Sidebar、Header、primary contentの順序がmobileでも目的に合うか。
- Sidebarだけで初期viewportを占有していないか。
- breakpoint直前と直後の両方で、content幅とcontrol配置が成立するか。
- viewport
queryだけでなく、containerの実幅に応じて一列化またはscrollを選べているか。
- desktopの比較関係を、理由なくmobile cardへ変換していないか。
### Typographyとcopy
- route titleが画面上で一度だけ表示されているか。
- page purpose、componentの存在理由、設計意図を説明するmeta
copyが残っていないか。
- proseが判断、warning、constraint、permission、recoveryのいずれかに実際に必要か。
- heading level、font size、weight、colorが同じhierarchyで揃っているか。
- metadataがbodyより強く見えていないか。
- uppercase、letter spacing、monospaceを装飾目的で多用していないか。
### State、permission、action
- loading、empty、error、permission denied、unavailableが見分けられるか。
- statusがcolorだけに依存していないか。
- 利用できないowner-only actionをdisabledで並べるのではなく、page
compositionから除外できているか。
- filled primary actionが同じscopeに複数ないか。
- destructive actionが通常のprimary pathと誤認されないか。
- errorとrecovery actionが、失敗したsurfaceの近くにあるか。
### Interactionとaccessibility
- keyboard focusが全controlで見えるか。
- focus順が画面の作業順と一致するか。
- fold、disclosure、dialog、menuを操作した後にfocusが失われないか。
- hoverでしか得られない必須情報がないか。
- scroll region、icon-only control、status markerにaccessible
nameまたはtextがあるか。
- reduced motionで意味が失われないか。
## 実行手順
### 1. Scenarioを決める
変更対象route、persona、viewport、theme、data state、capture
pointを列挙する。既存scenarioが目的を満たす場合は再利用し、満たさない場合は同じ変更内で追加または更新する。
### 2. 変更前をcaptureする
`tools/web-ux`を使い、変更前のsourceとscenarioを固定する。出力はrepositoryのproduction
sourceへ混ぜず、`target/web-ux`などの開発artifact領域へ置く。
### 3. 変更前を目視する
既存問題も含め、変更対象付近のgrouping、separator、spacing、wrapping、responsive、stateを確認する。変更前を見ずに、変更後の印象だけで改善を主張しない。
### 4. 実装する
発見した問題と、適用するUX規則を対応付けて修正する。screenshotだけを整えるfixture固有hackや、productionと異なるshell、CSS、data
authorityを作らない。
### 5. 同条件で再captureする
persona、route、viewport、theme、data
stateを揃える。変更後だけ別の好条件へ変えない。
### 6. Screenshotとreview contextを確認する
実装者自身が画像を開き、`review-context.json`も読む。次を確認する。
- document status
- visible UI error
- console error
- page error
- failed request
- accessibility snapshot
- screenshot hashとcapture point
HTTP 200でも画面に`401 Unauthorized`などが表示されていれば失敗とする。
### 7. Compareする
before/afterを比較し、差分が意図した領域に限定されているか確認する。pixel
differenceの大小だけで合否を決めない。意図しない移動、折返し、欠落、色変化を目視する。
### 8. Testとevidenceを揃える
source test、component test、E2E、type checkを実行し、visual
evidenceと一緒に記録する。test成功はvisual reviewの代替ではなく、visual
review成功もtestの代替ではない。
## 合格条件
次をすべて満たした場合だけvisual reviewを合格とする。
- 実装者自身がbefore/afterを目視した。
- 必要なpersona、viewport、theme、data stateを確認した。
- grouping、separator、spacing、wrapping、responsiveを確認した。
- visible、console、page、request errorが解決または明確にdispositionされた。
- 発見した新規問題を修正し、修正後を再captureした。
- 意図しないvisual regressionがない。
- artifact path、scenario、確認結果をhandoffへ記録した。
次は不合格である。
- capture commandが成功しただけ。
- screenshotを生成したが開いていない。
- desktopだけ、ownerだけ、happy pathだけを見た。
- 後段Reviewerまたはユーザーが目視する前提でhandoffした。
- 「好みの問題」として、説明できないseparator、spacing、overflowを未確認のまま残した。
- testまたはlinterが成功したため、visual reviewを省略した。
## Evidenceの記録
handoffには最低限、次を記録する。
```text
Visual review
- Scenario: <scenario path>
- Before: <run id or artifact path>
- After: <run id or artifact path>
- Personas: <list>
- Viewports: <list>
- Themes: <list>
- Data states: <list>
- Findings fixed: <bounded list>
- Known issues: <bounded list with disposition>
- Result: pass | fail
```
画像をTicketやGitへ大量にcommitしない。artifact pathとsource
revisionを結び付け、必要なreviewerが同じ条件を再現できるようにする。認証profile、cookie、token、secret、private
response bodyをartifactやhandoffへ含めない。
## Toolの位置付け
`tools/web-ux`はbrowser起動、scenario再現、capture、review
context、compareを提供する。Toolは視覚的な良否を自動決定しない。
Linterが構文規則を検査し、testが特定のbehaviorを検査するのと同様に、visual
reviewはrendered
resultの関係性を検査する。三者は補完関係であり、どれか一つで他を代替しない。
+14
View File
@@ -32,6 +32,20 @@ cargo check --workspace
Avoid repository-wide formatting churn when a validation failure is caused by pre-existing unrelated formatting.
## Web UI changes
Rendered Web UI changes require implementer-owned visual review in addition to type check, linter, and test. Follow [`ui-ux/visual-review.md`](ui-ux/visual-review.md).
Minimum evidence includes:
- before/after capture under the relevant persona, viewport, theme, and data state;
- the implementers own inspection of grouping, separator meaning, spacing, wrapping, overflow, responsive order, and visible errors;
- `review-context.json` inspection;
- disposition of every observed problem;
- artifact paths and a final `pass | fail` result.
A successful capture command, HTTP 200, screenshot generation, or later Reviewer inspection does not replace this validation.
## Work item checks
Run:
+4
View File
@@ -4,6 +4,10 @@
real Web Workspace. It does not add a Yoi product Skill, Flow, Runtime capability, or browser
automation route.
The required implementer-owned review workflow and pass/fail policy are defined in
[`../../docs/development/ui-ux/visual-review.md`](../../docs/development/ui-ux/visual-review.md). This
README documents the tool; running the tool is not itself visual review completion.
The workbench produces a **review context bundle** rather than treating a screenshot as evidence by
itself. Every capture records the persona, route, viewport, theme, intended user goal, expected data
state, sanitized document URL/status, console/page/request failures, screenshot hashes, an
+2 -1
View File
@@ -14,6 +14,7 @@
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts"
},
"fmt": {
"lineWidth": 100
"lineWidth": 100,
"proseWrap": "never"
}
}
+45
View File
@@ -55,3 +55,48 @@ the Vite dev server proxy plus the Rust backend command above.
deno task check
deno task build
```
## Design and architecture authority
- [`../../docs/development/ui-ux/design-language.md`](../../docs/development/ui-ux/design-language.md): visual and interaction language.
- [`../../docs/development/ui-ux/product-ux.md`](../../docs/development/ui-ux/product-ux.md): resource and navigation IA.
- [`../../docs/development/ui-ux/application-architecture.md`](../../docs/development/ui-ux/application-architecture.md): shell and nested override architecture.
- [`../../docs/development/ui-ux/visual-review.md`](../../docs/development/ui-ux/visual-review.md): implementer-owned visual validation.
This README owns frontend implementation guidance and concrete source paths. It does not redefine product or visual rules from those documents.
## CSS ownership
`src/app.css` is the global foundation and owns only:
- font imports;
- cascade layer order;
- semantic tokens and light/dark theme;
- reset and base element typography;
- global focus and text-selection behavior.
It must not own page layout, feature class selectors, table or definition-list layout, button variants, status presentation, helper spacing, or anchor color. Navigation, inline links, and action links own their color in the relevant component stylesheet.
Feature styles are owned by their implementation area:
- `src/lib/workspace/styles/workspace-pages.css`: Workspace main-content primitives.
- `src/lib/workspace/styles/tickets.css`: Ticket surfaces.
- `src/lib/workspace/styles/workers.css`: Worker surfaces.
- `src/lib/workspace/styles/settings.css`: Settings, Account, and form surfaces.
- `src/lib/workspace/styles/workspace-catalog.css`: Workspace catalog.
- `src/lib/workspace/sidebar/sidebar.css`: Sidebar components.
- Svelte-local styles: behavior-specific presentation that is not reused outside that component.
Use semantic tokens from `app.css`. Do not introduce component-local color, spacing, font, or z-index systems.
## Source map
- `src/routes/+layout.svelte`: root application shell and override contexts.
- `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration.
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
- `src/lib/workspace/header/`: Header frame, context, and overrides.
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
- `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`.
- `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom.
When one of these paths changes, update this source map in the same change. Stable architecture and UX authority remain in `docs/`; this map follows the current implementation.
+4 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
@@ -32,5 +32,8 @@
"svelte-check": "npm:svelte-check@4.3.4",
"typescript": "npm:typescript@5.9.3",
"vite": "npm:vite@7.2.7"
},
"fmt": {
"proseWrap": "never"
}
}
+80 -132
View File
@@ -6,16 +6,20 @@
@import "@fontsource/ibm-plex-mono/latin-500.css";
@import "@fontsource/ibm-plex-mono/latin-600.css";
@import "@fontsource/ibm-plex-mono/latin-700.css";
@layer reset, tokens, base, layout, components;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
textarea,
@@ -23,9 +27,11 @@
font: inherit;
}
}
@layer tokens {
:root {
color-scheme: light dark;
--bg: oklch(98.5% 0 0);
--bg-raised: oklch(96% 0 0);
--bg-subtle: oklch(93% 0 0);
@@ -41,6 +47,28 @@
--success: oklch(48% 0.11 145);
--warning: oklch(62% 0.12 85);
--danger: oklch(54% 0.14 25);
--interactive-hover: oklch(94.5% 0 0);
--interactive-selected: oklch(93% 0.02 230);
--shadow-overlay: 0 12px 32px oklch(0% 0 0 / 0.14);
--font-sans: "Gen Interface JP", sans-serif;
--font-mono: "IBM Plex Mono", monospace;
--font-size-title: 24px;
--line-height-title: 32px;
--font-size-body: 14px;
--line-height-body: 20px;
--font-size-compact: 12px;
--line-height-compact: 16px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--radius-soft: 8px;
--tui-green: #76946a;
--tui-red: #c34043;
--tui-yellow: #c0a36e;
@@ -49,19 +77,8 @@
--tui-magenta: #957fb8;
--tui-gray: #727169;
--tui-dark-gray: #54546d;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--radius-soft: 8px;
--radius-panel: 12px;
--interactive-hover: oklch(94.5% 0 0);
--interactive-selected: oklch(93% 0.02 230);
--font-sans: "Gen Interface JP", sans-serif;
--font-mono: "IBM Plex Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: oklch(16% 0 0);
@@ -81,21 +98,23 @@
--danger: oklch(76% 0.14 25);
--interactive-hover: oklch(29% 0 0);
--interactive-selected: oklch(30% 0.025 230);
--shadow-overlay: 0 14px 36px oklch(0% 0 0 / 0.38);
}
}
}
@layer base {
html {
font-size: 14px;
font-size: var(--font-size-body);
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
line-height: var(--line-height-body);
}
a {
color: var(--accent);
}
h1,
h2,
h3,
@@ -104,127 +123,56 @@
dd {
overflow-wrap: anywhere;
}
h1,
h2,
h3 {
margin-top: 0;
color: var(--text-strong);
margin-top: 0;
}
p {
line-height: 1.55;
}
code,
pre {
font-family: var(--font-mono);
}
code {
color: var(--code);
}
small {
color: var(--text-muted);
display: block;
margin-top: var(--space-1);
}
}
@layer layout {
.grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
min-width: 0;
}
.runtime {
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
}
.stack {
display: grid;
gap: var(--space-4);
}
}
@layer components {
.section-note,
.section-state,
.muted {
color: var(--text-muted);
font-size: 0.82rem;
}
.section-note,
.section-state {
margin: 0;
line-height: 1.45;
}
.card {
min-width: 0;
padding: 0;
}
dl {
display: grid;
gap: var(--space-1);
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: minmax(6.5rem, 10rem) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
dt {
color: var(--text-faint);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
dd {
min-width: 0;
margin: 0;
}
dd small {
display: inline;
margin-top: 0;
margin-left: var(--space-2);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
th {
color: var(--text-faint);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.inline-link,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
border-radius: 999px;
border: 1px solid var(--line);
padding: 0.4rem 0.75rem;
background: var(--bg);
color: var(--accent);
font-size: 0.85rem;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.secondary-button:hover,
.inline-link:hover {
border-color: var(--line-strong);
background: var(--bg-raised);
h1 {
font-size: var(--font-size-title);
line-height: var(--line-height-title);
}
h2,
h3 {
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
p {
line-height: var(--line-height-body);
}
code,
pre {
color: var(--code);
font-family: var(--font-mono);
}
small {
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
}
:where(
a,
button,
input,
select,
textarea,
summary,
[tabindex]
):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
::selection {
background: var(--interactive-selected);
color: var(--text-strong);
}
}
@@ -96,7 +96,7 @@
display: block;
margin-bottom: 0.15rem;
color: var(--alert-color);
font-size: 0.82rem;
font-size: var(--font-size-body);
letter-spacing: 0.02em;
text-transform: uppercase;
}
@@ -105,8 +105,8 @@
margin: 0;
overflow-wrap: anywhere;
color: var(--text);
font-size: 0.9rem;
line-height: 1.35;
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
.workspace-alert-dismiss {
@@ -118,7 +118,7 @@
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 1.2rem;
font-size: var(--font-size-body);
line-height: 1;
}
@@ -586,7 +586,7 @@
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-subtle));
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
@@ -192,7 +192,7 @@
.task-reminder-summary {
margin: 0;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
line-height: 1.55;
white-space: pre-line;
}
@@ -203,7 +203,7 @@
gap: var(--space-2);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.78rem;
font-size: var(--font-size-compact);
font-variant-numeric: tabular-nums;
font-weight: 750;
}
@@ -213,7 +213,7 @@
padding-left: 1.25rem;
color: var(--tui-dark-gray);
font-family: var(--font-mono);
font-size: 0.75rem;
font-size: var(--font-size-compact);
}
.compaction-summary,
@@ -227,15 +227,15 @@
display: block;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.7rem;
font-size: var(--font-size-compact);
}
.compaction-error {
color: var(--tui-error);
color: var(--danger);
}
.activity-summary {
font-size: 14px;
font-size: var(--font-size-body);
}
.task-reminder-summary {
@@ -245,14 +245,14 @@
}
.console-line.error .activity-summary {
color: var(--tui-error);
color: var(--danger);
}
.run-stats {
margin: 0;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
@@ -294,7 +294,7 @@
min-width: 0;
margin: 0;
font-family: var(--font-mono);
font-size: 12px;
font-size: var(--font-size-compact);
line-height: 1.1;
overflow-x: auto;
white-space: pre;
@@ -313,7 +313,7 @@
align-items: baseline;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.88rem;
font-size: var(--font-size-compact);
font-weight: 750;
}
@@ -329,7 +329,7 @@
.tool-status {
flex: 0 0 auto;
color: var(--tui-dark-gray);
font-size: 0.72rem;
font-size: var(--font-size-compact);
white-space: nowrap;
}
@@ -357,7 +357,7 @@
justify-content: space-between;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
font-weight: 750;
}
@@ -367,7 +367,7 @@
border-radius: 0.65rem;
color: var(--text);
font-family: var(--font-mono);
font-size: 0.78rem;
font-size: var(--font-size-compact);
line-height: 1.45;
margin: 0.6rem 0 0;
overflow-x: auto;
@@ -424,7 +424,7 @@
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: 0.68rem;
font-size: var(--font-size-compact);
font-weight: 750;
opacity: 0;
pointer-events: none;
@@ -443,7 +443,7 @@
border-left: 2px solid var(--line);
padding-left: 0.6rem;
color: var(--text-muted);
font-size: 0.84rem;
font-size: var(--font-size-compact);
}
.message-detail p {
@@ -124,7 +124,7 @@
gap: 0.1rem;
min-width: 0;
padding-inline: 0.75rem;
font-size: 0.8rem;
font-size: var(--font-size-compact);
line-height: 1.35;
}
@@ -237,7 +237,7 @@
.task-pane h3 {
margin: 0 0 1rem;
color: var(--accent);
font-size: 0.9rem;
font-size: var(--font-size-body);
}
.task-empty {
@@ -262,8 +262,8 @@
}
.task-description {
font-size: 0.8rem;
line-height: 1.45;
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
@media (max-width: 900px) {
@@ -158,8 +158,8 @@
text-align: left;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-panel) 92%, transparent);
box-shadow: var(--shadow-soft);
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
box-shadow: var(--shadow-overlay);
opacity: 1;
padding: 0.25rem 0.45rem;
transform: none;
@@ -69,7 +69,7 @@
:global(.rich-markdown h4) {
margin: 0.7rem 0 0.35rem;
color: var(--text-strong);
font-size: 1rem;
font-size: var(--font-size-body);
}
:global(.rich-markdown blockquote) {
@@ -52,7 +52,7 @@
min-height: 1.35rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.74rem;
font-size: var(--font-size-compact);
font-variant-numeric: tabular-nums;
}
</style>
@@ -78,6 +78,134 @@ Deno.test("workspace feature css is owned outside app css", async () => {
);
});
Deno.test("workspace app css is limited to global foundation", async () => {
const appCss = await Deno.readTextFile(
new URL("./../../../app.css", import.meta.url),
);
const workspacePagesCss = await Deno.readTextFile(
new URL(
"./../styles/workspace-pages.css",
import.meta.url,
),
);
const settingsCss = await Deno.readTextFile(
new URL("./../styles/settings.css", import.meta.url),
);
const sidebarCss = await Deno.readTextFile(
new URL("./../sidebar/sidebar.css", import.meta.url),
);
for (
const selector of [
".card",
".stack",
".section-note",
".section-state",
".muted",
".table-wrap",
".inline-link",
".secondary-button",
]
) {
assert(
!appCss.includes(selector),
`app.css must not own feature selector ${selector}`,
);
}
assert(
!/^\s*a\s*\{[^}]*\bcolor\s*:/ms.test(appCss),
"app.css must not override component-owned link colors",
);
for (
const legacyToken of [
"--surface",
"--border",
"--bg-panel",
"--interactive-muted",
"--radius-card",
"--radius-panel",
"--shadow-soft",
"--tui-error",
]
) {
assert(
!appCss.includes(legacyToken),
`app.css must not retain legacy token ${legacyToken}`,
);
}
assert(
workspacePagesCss.includes(".main-content .card") &&
workspacePagesCss.includes(".main-content table") &&
workspacePagesCss.includes(".main-content dl"),
"workspace content primitives must be scoped to the main content owner",
);
assert(
settingsCss.includes(".main-content .inline-link") &&
settingsCss.includes(".main-content .secondary-button"),
"settings CSS must own its link and button variants",
);
assert(
sidebarCss.includes(".section-state"),
"sidebar CSS must own sidebar status presentation",
);
});
Deno.test("design lab demonstrates nested sidebar slots without flattening levels", async () => {
const workspaceLayout = await Deno.readTextFile(
new URL(
"./../../../routes/design-lab/workspace-web-ux/+layout.svelte",
import.meta.url,
),
);
const settingsLayout = await Deno.readTextFile(
new URL(
"./../../../routes/design-lab/workspace-web-ux/settings/+layout.svelte",
import.meta.url,
),
);
assert(
workspaceLayout.includes("createOverrideStack<SidebarSnippet>"),
"Workspace design-lab layout must provide a child sidebar slot",
);
assert(
workspaceLayout.includes("<WorkspaceSidebarFixture"),
"Workspace design-lab layout must register its WorkspaceSidebar fixture",
);
assert(
workspaceLayout.includes("content={sidebarContent}"),
"Workspace design-lab layout must expose its child override slot",
);
assert(
!workspaceLayout.includes("<GlobalNavSections"),
"Workspace design-lab layout must not flatten Global navigation",
);
assert(
!workspaceLayout.includes("<SettingsSidebar"),
"Workspace design-lab layout must not flatten Settings navigation",
);
assert(
settingsLayout.includes("createOverrideStack<SidebarSnippet>"),
"Settings design-lab layout must provide a child sidebar slot",
);
assert(
settingsLayout.includes("<SettingsSidebarFixture"),
"Settings design-lab layout must register its SettingsSidebar fixture",
);
assert(
settingsLayout.includes("controller={parentSidebarController}"),
"Settings design-lab layout must register into its parent slot",
);
assert(
!settingsLayout.includes("<WorkspaceSidebar"),
"Settings design-lab layout must not duplicate WorkspaceSidebar",
);
});
Deno.test("workspace Worker list lives on the dedicated Workers page", async () => {
const workspacePage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
@@ -420,7 +548,7 @@ Deno.test("Worker Console overview activity summaries use 14px text", async () =
assert(
consoleLine.includes(".activity-summary {") &&
consoleLine.includes("font-size: 14px;"),
consoleLine.includes("font-size: var(--font-size-body);"),
"Overview activity summaries such as ran command counts should render at 14px",
);
});
@@ -443,7 +571,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
consoleLine.includes(
".console-line.tool.tool-bash .console-plain-text",
) &&
consoleLine.includes("font-size: 12px;") &&
consoleLine.includes("font-size: var(--font-size-compact);") &&
consoleLine.includes("line-height: 1.1;") &&
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
consoleLine.includes("<RichMarkdown text={item.body || '—'} />") &&
@@ -920,10 +1048,12 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
);
assert(
sidebarFrame.includes("let folded = $state(false)") &&
sidebarFrame.includes("folded = $bindable(false)") &&
sidebarFrame.includes("sidebar-fold-button") &&
sidebarFrame.includes("Fold sidebar") &&
sidebarFrame.includes("Unfold sidebar") &&
rootLayout.includes("let sidebarFolded = $state(false)") &&
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") &&
!workspaceLayout.includes("sidebarFolded") &&
!workspaceLayout.includes("onToggleFold") &&
!sidebar.includes("folded?: boolean") &&
@@ -61,7 +61,7 @@
gap: 0.55rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.84rem;
font-size: var(--font-size-compact);
line-height: 1;
}
@@ -65,7 +65,7 @@
'.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
'.cm-fixed-schema-wrapper': {
color: 'var(--text-muted)',
backgroundColor: 'var(--interactive-muted)',
backgroundColor: 'var(--interactive-hover)',
fontWeight: '600',
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
@@ -1,46 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import './sidebar.css';
type Props = {
children: Snippet<[]>;
folded?: boolean;
};
let { children }: Props = $props();
let folded = $state(false);
let { children, folded = $bindable(false) }: Props = $props();
function toggleFold() {
folded = !folded;
}
</script>
<aside class="sidebar-frame" class:folded aria-label="Sidebar">
{#if !folded}
<div class="sidebar-frame-content">
{@render children()}
</div>
{/if}
<div class="sidebar-control-row">
<button
class="sidebar-fold-button"
type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
aria-expanded={!folded}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
onclick={toggleFold}
>
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
<div class="sidebar-frame" class:folded>
<Bevel as="div" class="sidebar-frame__bevel" top={false} bottom={false} left={false} fill>
<aside class="sidebar-frame__surface" aria-label="Sidebar">
{#if !folded}
<div class="sidebar-frame-content">
{@render children()}
</div>
{/if}
</button>
</div>
</aside>
<div class="sidebar-control-row">
<button
class="sidebar-fold-button"
type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
aria-expanded={!folded}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
onclick={toggleFold}
>
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
{/if}
</button>
</div>
</aside>
</Bevel>
</div>
@@ -5,16 +5,27 @@
--sidebar-item-hover: oklch(96% 0 0);
--sidebar-item-active: oklch(92.5% 0 0);
display: grid;
grid-column: 1;
grid-row: 1 / 3;
display: flex;
flex-direction: column;
width: clamp(220px, 20vw, 280px);
min-width: 0;
min-height: 0;
overflow: hidden;
}
.sidebar-frame .bevel.sidebar-frame__bevel {
width: 100%;
min-width: 0;
min-height: 0;
}
.sidebar-frame__surface {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
padding-block: var(--space-4);
border-right: 1px solid var(--line);
}
@media (prefers-color-scheme: dark) {
.sidebar-frame {
@@ -25,7 +36,6 @@
.sidebar-frame.folded {
width: max-content;
overflow: hidden;
padding-inline: 0;
}
.sidebar-frame-content {
flex: 1 1 auto;
@@ -112,7 +122,7 @@
border-radius: var(--radius-soft);
padding: 0.45rem 0.6rem;
color: var(--text-strong);
font-size: 1.05rem;
font-size: var(--font-size-compact);
font-weight: 800;
line-height: 1.25;
text-overflow: ellipsis;
@@ -139,7 +149,7 @@
background: transparent;
color: var(--text-strong);
font: inherit;
font-size: 1.05rem;
font-size: var(--font-size-compact);
font-weight: 800;
line-height: 1.25;
text-align: left;
@@ -174,7 +184,7 @@
padding: 0.35rem 0.45rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.84rem;
font-size: var(--font-size-compact);
font-weight: 650;
line-height: 1;
}
@@ -207,7 +217,7 @@
border-radius: calc(var(--radius-soft) + 0.2rem);
padding: var(--space-2);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
box-shadow: var(--shadow-overlay);
}
.workspace-menu-item {
display: flex;
@@ -217,7 +227,7 @@
border-radius: var(--radius-soft);
padding: var(--space-2);
color: var(--text-strong);
font-size: 0.82rem;
font-size: var(--font-size-compact);
font-weight: 650;
line-height: 1.3;
text-decoration: none;
@@ -247,7 +257,7 @@
gap: var(--space-2);
padding: var(--space-1) var(--space-2) 0;
color: var(--text-faint);
font-size: 0.68rem;
font-size: var(--font-size-compact);
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -259,7 +269,7 @@
place-items: center;
border-radius: var(--radius-soft);
color: var(--text-muted);
font-size: 1rem;
font-size: var(--font-size-body);
line-height: 1;
text-decoration: none;
}
@@ -281,16 +291,22 @@
.workspace-menu-status {
margin: var(--space-1) var(--space-2);
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
line-height: 1.35;
}
.workspace-menu-status.error {
color: var(--danger);
}
.section-state {
margin: var(--space-2) 0 0;
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: 1.35;
}
.workspace-status {
margin: var(--space-1) 0 0;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
line-height: 1.35;
}
.workspace-status.error,
@@ -311,7 +327,7 @@
.sidebar-fold-button {
border: 1px solid var(--line);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
box-shadow: var(--shadow-overlay);
color: var(--text-strong);
cursor: pointer;
}
@@ -349,7 +365,7 @@
.sidebar-nav-section__header,
.sidebar-section-label {
color: var(--text-faint);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 750;
letter-spacing: 0.11em;
text-transform: uppercase;
@@ -373,7 +389,7 @@
}
.section-count {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
line-height: 1;
}
.nav-list,
@@ -392,7 +408,7 @@
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-soft);
color: var(--text-strong);
font-size: 0.9rem;
font-size: var(--font-size-compact);
text-align: left;
text-decoration: none;
transition: background-color 140ms ease, color 140ms ease;
@@ -465,7 +481,7 @@
border-radius: var(--radius-soft);
padding: var(--space-1);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
box-shadow: var(--shadow-overlay);
}
.worker-actions-menu button {
border: 0;
@@ -474,7 +490,7 @@
background: transparent;
color: var(--text-strong);
font: inherit;
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 650;
line-height: 1.2;
text-align: left;
@@ -535,7 +551,7 @@
align-items: center;
justify-content: center;
width: 0.75rem;
font-size: 0.7rem;
font-size: var(--font-size-compact);
line-height: 1;
}
.worker-status-spinner.is-subworker {
@@ -547,7 +563,7 @@
min-width: 0;
overflow: hidden;
color: inherit;
font-size: 0.78rem;
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 1.1rem;
text-overflow: ellipsis;
@@ -560,7 +576,7 @@
overflow: hidden;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.66rem;
font-size: var(--font-size-compact);
line-height: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
@@ -608,13 +624,18 @@
}
@media (max-width: 760px) {
.sidebar-frame,
.sidebar-frame.folded {
.sidebar-frame {
z-index: 20;
grid-column: 1;
grid-row: 1;
width: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
grid-row: 2;
width: 100%;
height: 100%;
}
.sidebar-frame.folded {
display: none;
}
.sidebar-control-row {
display: none;
}
}
}
@@ -1,5 +1,40 @@
@layer reset, tokens, base, layout, components;
@layer components {
.main-content .muted {
color: var(--text-muted);
}
.main-content .inline-link {
color: var(--accent);
font-weight: 700;
text-underline-offset: 0.15em;
}
.main-content .secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: 0.45rem 0.75rem;
background: var(--bg-raised);
color: var(--text-strong);
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.main-content .secondary-button:hover:not(:disabled),
.main-content .secondary-button:focus-visible:not(:disabled) {
border-color: var(--accent);
background: var(--interactive-hover);
}
.main-content .secondary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.settings-button {
text-decoration: none;
cursor: pointer;
@@ -32,17 +67,25 @@
}
.badge {
display: inline-flex;
flex: 0 0 auto;
border-radius: 999px;
padding: 0.35rem 0.65rem;
background: var(--bg-subtle);
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.badge::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.badge.warning {
color: var(--warning);
}
@@ -62,7 +105,6 @@
max-width: 24rem;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: var(--text-muted);
}
@@ -83,7 +125,6 @@
min-width: min(16rem, 100%);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
@@ -128,7 +169,6 @@
.settings-pattern {
padding: var(--space-4);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
@@ -162,7 +202,7 @@
.account-details dt {
color: var(--text-muted);
font-size: 0.8rem;
font-size: var(--font-size-compact);
font-weight: 700;
text-transform: uppercase;
}
@@ -173,7 +213,7 @@
}
.account-details code {
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.account-panel {
@@ -192,7 +232,7 @@
display: grid;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-body);
}
.settings-runtime-form button,
@@ -223,7 +263,7 @@
border: 1px solid var(--line);
border-radius: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
background: color-mix(in srgb, var(--text-strong) 3%, transparent);
}
.settings-runtime-table-wrap {
@@ -249,7 +289,7 @@
.settings-runtime-table th {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
letter-spacing: 0.05em;
text-transform: uppercase;
}
@@ -288,12 +328,12 @@
.settings-runtime-detail-row td {
padding-top: 0;
background: rgba(255, 255, 255, 0.025);
background: color-mix(in srgb, var(--text-strong) 2.5%, transparent);
}
.settings-muted-action {
color: var(--text-muted);
font-size: 0.82rem;
font-size: var(--font-size-compact);
}
.settings-action-row {
@@ -325,18 +365,18 @@
}
.settings-diagnostics-list li.error {
border-color: rgba(255, 99, 99, 0.55);
border-color: color-mix(in srgb, var(--danger) 55%, transparent);
}
.settings-diagnostics-list li.warning {
border-color: rgba(255, 205, 86, 0.55);
border-color: color-mix(in srgb, var(--warning) 55%, transparent);
}
.settings-test-result {
display: grid;
gap: 0.3rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.04);
background: color-mix(in srgb, var(--text-strong) 4%, transparent);
padding: 0.75rem;
}
.settings-test-result.failed {
@@ -374,7 +414,7 @@
.runtime-detail-grid dt {
margin-bottom: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
@@ -433,7 +473,7 @@
background: var(--bg-raised);
color: var(--text-strong);
font-family: var(--font-mono);
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.runtime-public-key {
@@ -468,7 +508,7 @@
.runtime-trust-form label,
.runtime-revoke-row label {
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
font-weight: 700;
}
@@ -503,7 +543,7 @@
.runtime-trust-comparison dt {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 700;
}
@@ -559,7 +599,7 @@
.runtime-audit-table th {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
letter-spacing: 0.05em;
text-transform: uppercase;
}
@@ -589,7 +629,6 @@
min-width: min(16rem, 100%);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-section-card:hover,
@@ -628,7 +667,7 @@
display: grid;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.82rem;
font-size: var(--font-size-body);
}
.settings-form input,
.settings-form select,
@@ -685,7 +724,6 @@
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-profile-list strong,
@@ -721,29 +759,35 @@
}
.settings-identity-list dt {
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.settings-identity-list dd {
margin: 0;
color: var(--text-strong);
}
.section-status-pill {
display: inline-flex;
width: fit-content;
border-radius: 999px;
padding: 0.25rem 0.55rem;
background: var(--bg-subtle);
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 800;
text-transform: uppercase;
}
.section-status-pill::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.config-source-shell {
display: grid;
grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
min-height: 40rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.config-source-tree {
@@ -784,7 +828,7 @@
}
.config-source-tree nav button small {
color: var(--text-muted);
font-size: 0.65rem;
font-size: var(--font-size-compact);
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -799,10 +843,13 @@
padding: var(--space-3);
border-top: 1px solid var(--line);
}
.config-source-create label,
.config-source-create label {
color: var(--text-muted);
font-size: var(--font-size-body);
}
.config-source-workbench__header span {
color: var(--text-muted);
font-size: 0.75rem;
font-size: var(--font-size-compact);
}
.config-source-create input,
.config-source-actions input {
@@ -861,7 +908,7 @@
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--line);
color: var(--text-muted);
font-size: 0.8rem;
font-size: var(--font-size-compact);
}
.config-source-conflict {
display: flex;
@@ -924,7 +971,6 @@
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-form h2 {
@@ -934,7 +980,7 @@
display: grid;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-body);
font-weight: 700;
}
.settings-repository-form input {
@@ -963,7 +1009,6 @@
.settings-repository-table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-table {
@@ -979,7 +1024,7 @@
}
.settings-repository-table th {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
text-transform: uppercase;
}
.settings-repository-table tbody tr:last-child td {
@@ -10,7 +10,7 @@
border-radius: 0.55rem;
padding: 0.5rem 0.8rem;
font: inherit;
font-size: 0.82rem;
font-size: var(--font-size-compact);
font-weight: 700;
text-decoration: none;
cursor: pointer;
@@ -26,9 +26,9 @@
color: var(--text-strong);
}
.workspace-danger-button {
border: 1px solid #a63f3f;
background: #a63f3f;
color: #fff;
border: 1px solid var(--danger);
background: var(--danger);
color: var(--bg-raised);
}
.workspace-primary-button:disabled,
.workspace-secondary-button:disabled,
@@ -38,7 +38,7 @@
}
.workspace-back-link {
color: var(--text-muted);
font-size: 0.82rem;
font-size: var(--font-size-compact);
text-decoration: none;
}
.workspace-callout {
@@ -47,8 +47,8 @@
padding: 0.7rem 0.85rem;
}
.workspace-callout.is-error {
border-color: #a63f3f;
color: #d66;
border-color: var(--danger);
color: var(--danger);
}
.workspace-empty-copy {
margin: 0;
@@ -94,20 +94,20 @@
}
.orchestrator-status strong {
color: var(--text-strong);
font-size: 0.76rem;
font-size: var(--font-size-compact);
}
.orchestrator-status span {
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
}
.orchestrator-status-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #a75454;
background: var(--danger);
}
.orchestrator-status[data-online="true"] .orchestrator-status-dot {
background: #43a66d;
background: var(--success);
}
.ticket-panel-summary {
display: grid;
@@ -116,8 +116,8 @@
}
.ticket-panel-summary strong {
color: var(--text-strong);
font-size: 1.8rem;
line-height: 1;
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
}
.ticket-kanban {
display: grid;
@@ -149,7 +149,7 @@
}
.ticket-lane-header h2 {
margin: 0;
font-size: 0.78rem;
font-size: var(--font-size-compact);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -160,17 +160,17 @@
background: var(--text-muted);
}
.ticket-lane[data-state="ready-planning"] .ticket-state-dot {
background: #43a66d;
background: var(--success);
}
.ticket-lane[data-state="inprogress-queued"] .ticket-state-dot {
background: #d09b37;
background: var(--warning);
}
.ticket-lane[data-state="done-closed"] .ticket-state-dot {
background: var(--accent);
}
.ticket-lane-count {
color: var(--text-muted);
font-size: 0.75rem;
font-size: var(--font-size-compact);
}
.ticket-lane-cards {
display: grid;
@@ -188,7 +188,7 @@
margin: 0;
color: var(--text-muted);
padding: 0.5rem;
font-size: 0.7rem;
font-size: var(--font-size-compact);
text-align: center;
}
.ticket-lane-page-state {
@@ -198,7 +198,7 @@
margin: 0;
padding: 0.45rem;
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
text-align: center;
}
.ticket-lane-page-error {
@@ -229,17 +229,17 @@
overflow: hidden;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.68rem;
font-size: var(--font-size-compact);
text-overflow: ellipsis;
}
.ticket-card strong {
font-size: 0.85rem;
font-size: var(--font-size-compact);
line-height: 1.35;
}
.ticket-card-meta {
justify-content: space-between;
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
}
.ticket-risk-list {
display: flex;
@@ -247,16 +247,23 @@
gap: 0.25rem;
}
.ticket-risk-list span {
border: 1px solid #a97531;
border-radius: 999px;
color: #d3a55d;
padding: 0.1rem 0.35rem;
font-size: 0.62rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--warning);
font-size: var(--font-size-compact);
}
.ticket-risk-list span::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.ticket-lane-empty {
padding: 1.2rem 0.4rem;
color: var(--text-muted);
font-size: 0.75rem;
font-size: var(--font-size-compact);
text-align: center;
}
.ticket-detail-page {
@@ -267,28 +274,35 @@
.ticket-detail-header h1 {
margin: 0.45rem 0 0.25rem;
color: var(--text-strong);
font-size: clamp(1.5rem, 3vw, 2.4rem);
font-size: var(--font-size-title);
}
.ticket-detail-header p {
margin: 0;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.ticket-detail-kicker {
gap: 0.6rem;
}
.ticket-detail-kicker code {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
}
.workspace-status-pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.45rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
text-transform: uppercase;
}
.workspace-status-pill::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.ticket-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
@@ -318,11 +332,11 @@
.ticket-section-heading h2, .ticket-control-card h2 {
margin: 0;
color: var(--text-strong);
font-size: 0.9rem;
font-size: var(--font-size-body);
}
.ticket-section-heading span, .ticket-control-card header span {
color: var(--text-muted);
font-size: 0.7rem;
font-size: var(--font-size-compact);
}
.ticket-editor, .ticket-control-form {
display: grid;
@@ -332,7 +346,7 @@
display: grid;
gap: 0.3rem;
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-body);
}
.ticket-editor input,
.ticket-editor textarea,
@@ -349,8 +363,8 @@
}
.ticket-worker-card p {
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.5;
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
.ticket-role-actions {
gap: 0.55rem;
@@ -365,14 +379,14 @@
details.ticket-control-card summary {
cursor: pointer;
color: var(--text-strong);
font-size: 0.85rem;
font-size: var(--font-size-compact);
font-weight: 700;
}
details.ticket-control-card[open] summary {
margin-bottom: var(--space-3);
}
.ticket-close-card {
border-color: color-mix(in srgb, #a63f3f 55%, var(--line));
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
}
.ticket-relations-list, .ticket-blocker-list {
display: grid;
@@ -391,13 +405,13 @@
text-decoration: none;
}
.ticket-blocker-list a {
border-color: #a97531;
border-color: var(--warning);
}
.ticket-relations-list span,
.ticket-relations-list small,
.ticket-blocker-list span {
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
}
.ticket-timeline {
display: grid;
@@ -435,7 +449,7 @@
}
.ticket-timeline time, .ticket-event-author {
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
}
.ticket-event-author {
margin: 0.2rem 0;
@@ -9,10 +9,10 @@
.section-action {
margin-left: auto;
border: 1px solid var(--line);
border-radius: 999px;
border-radius: var(--radius-soft);
background: var(--bg-subtle);
color: var(--text-strong);
font-size: 0.72rem;
font-size: var(--font-size-compact);
padding: 0.2rem 0.55rem;
cursor: pointer;
text-decoration: none;
@@ -35,7 +35,7 @@
display: grid;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-body);
}
.worker-launch-form input,
@@ -85,8 +85,8 @@
}
.worker-new-page-header h1 {
color: var(--text-strong);
font-size: clamp(1.6rem, 4vw, 2.4rem);
line-height: 1.05;
font-size: var(--font-size-title);
line-height: var(--line-height-title);
}
.worker-new-page-header p:not(.eyebrow) {
margin-top: var(--space-2);
@@ -110,11 +110,11 @@
.worker-ticket-context span,
.worker-ticket-context code {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
}
.worker-ticket-context a {
color: var(--accent);
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.worker-launch-form {
display: grid;
@@ -128,14 +128,14 @@
.worker-form-section h2 {
margin: 0;
color: var(--text-strong);
font-size: 1rem;
font-size: var(--font-size-body);
}
.worker-launch-sentence {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.95rem;
font-size: var(--font-size-body);
white-space: nowrap;
}
.worker-inline-select {
@@ -154,7 +154,7 @@
.new-working-directory-panel h3 {
margin: 0;
color: var(--text-strong);
font-size: 0.95rem;
font-size: var(--font-size-body);
}
.new-working-directory-fields,
.worker-detail-grid {
@@ -1,3 +1,4 @@
@layer reset, tokens, base, layout, components;
@layer components {
.workspace-catalog-shell {
width: min(1120px, calc(100% - 2rem));
@@ -32,7 +33,7 @@
.workspace-catalog-eyebrow {
color: var(--accent) !important;
font-size: 0.75rem;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
@@ -74,13 +75,22 @@
}
.workspace-card-heading > span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
text-transform: uppercase;
}
.workspace-card-heading > span::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.workspace-card-heading > .workspace-state-active {
border-color: color-mix(in srgb, var(--success) 50%, var(--line));
color: var(--success);
@@ -112,7 +122,7 @@
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.8rem;
font-size: var(--font-size-body);
}
.workspace-create-panel input,
@@ -172,7 +182,7 @@
}
.workspace-switcher label {
font-size: 0.68rem;
font-size: var(--font-size-compact);
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: uppercase;
@@ -181,18 +191,18 @@
.workspace-switcher select {
width: 100%;
padding: 0.45rem 0.55rem;
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.workspace-switcher-actions {
display: flex;
gap: 0.75rem;
font-size: 0.72rem;
font-size: var(--font-size-compact);
}
.workspace-switcher-error {
color: var(--danger);
font-size: 0.7rem;
font-size: var(--font-size-compact);
}
@media (max-width: 760px) {
@@ -1,5 +1,63 @@
@layer reset, tokens, base, layout, components;
@layer components {
.main-content .stack {
display: grid;
gap: var(--space-2);
}
.main-content .card {
min-width: 0;
padding: 0;
}
.main-content .section-note {
margin: 0;
color: var(--text-muted);
}
.main-content .section-state {
margin: var(--space-3) 0 0;
color: var(--text-muted);
}
.main-content .section-state.error {
color: var(--danger);
}
.main-content .muted {
color: var(--text-muted);
}
.main-content .secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: 0.45rem 0.75rem;
background: var(--bg-raised);
color: var(--text-strong);
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.main-content .secondary-button:hover:not(:disabled),
.main-content .secondary-button:focus-visible:not(:disabled) {
border-color: var(--accent);
background: var(--interactive-hover);
}
.main-content .secondary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.main-content small {
display: block;
margin-top: var(--space-1);
}
.workspace-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -10,7 +68,6 @@
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
@@ -24,7 +81,7 @@
color: var(--text-muted);
}
.workspace-action-card span {
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.workspace-action-card strong {
color: var(--text-strong);
@@ -51,7 +108,6 @@
align-items: start;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
@@ -96,7 +152,7 @@
.state-pill {
flex: 0 0 auto;
color: var(--success);
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
@@ -105,7 +161,7 @@
.objective-summary {
margin: 0;
color: var(--text-muted);
font-size: 0.88rem;
font-size: var(--font-size-compact);
line-height: 1.4;
}
.ticket-meta,
@@ -115,7 +171,7 @@
justify-content: flex-end;
gap: var(--space-2);
color: var(--text-faint);
font-size: 0.78rem;
font-size: var(--font-size-compact);
line-height: 1.35;
text-align: right;
white-space: nowrap;
@@ -155,7 +211,7 @@
}
.detail-heading span {
color: var(--text-muted);
font-size: 0.8rem;
font-size: var(--font-size-compact);
}
.ticket-detail-grid {
margin-bottom: var(--space-4);
@@ -168,70 +224,77 @@
}
.risk-flags span,
.warning-pill {
border-radius: 999px;
padding: 0.18rem 0.55rem;
background: var(--bg-subtle);
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--warning);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.risk-flags span::before,
.warning-pill::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.ticket-body pre {
overflow-x: auto;
margin: 0;
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: var(--text);
white-space: pre-wrap;
}
dl {
.main-content dl {
display: grid;
gap: var(--space-1);
margin: 0;
}
dl > div {
.main-content dl > div {
display: grid;
grid-template-columns: minmax(6.5rem, 10rem) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
dt {
.main-content dt {
color: var(--text-faint);
font-size: 0.72rem;
font-size: var(--font-size-compact);
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
dd {
.main-content dd {
min-width: 0;
margin: 0;
}
dd small {
.main-content dd small {
display: inline;
margin-top: 0;
margin-left: var(--space-2);
}
.table-wrap {
.main-content .table-wrap {
overflow-x: auto;
}
table {
.main-content table {
width: 100%;
border-collapse: collapse;
}
th,
td {
.main-content th,
.main-content td {
border-bottom: 1px solid var(--line);
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
th {
.main-content th {
color: var(--text-faint);
font-size: 0.78rem;
font-size: var(--font-size-compact);
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -251,8 +314,8 @@
.page-header-row h1,
.workers-page-header h1 {
color: var(--text-strong);
font-size: clamp(1.6rem, 4vw, 2.4rem);
line-height: 1.05;
font-size: var(--font-size-title);
line-height: var(--line-height-title);
}
.page-header-row p:not(.breadcrumb),
.workers-page-header p {
@@ -262,7 +325,7 @@
.breadcrumb {
margin-bottom: var(--space-2);
color: var(--text-muted);
font-size: 0.85rem;
font-size: var(--font-size-compact);
}
.breadcrumb a {
color: inherit;
@@ -0,0 +1,129 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Props = {
children: Snippet<[]>;
top?: boolean;
right?: boolean;
bottom?: boolean;
left?: boolean;
fill?: boolean;
invalid?: boolean;
as?: 'span' | 'div';
class?: string;
};
let {
children,
top = true,
right = true,
bottom = true,
left = true,
fill = false,
invalid = false,
as = 'span',
class: className = '',
}: Props = $props();
</script>
<svelte:element
this={as}
class={`bevel ${fill ? 'bevel--fill' : ''} ${className}`.trim()}
data-top={top ? 'true' : 'false'}
data-right={right ? 'true' : 'false'}
data-bottom={bottom ? 'true' : 'false'}
data-left={left ? 'true' : 'false'}
data-invalid={invalid || undefined}
>
{@render children()}
</svelte:element>
<style>
.bevel {
--bevel-radius: var(--radius-soft);
--bevel-top-left-radius: var(--bevel-radius);
--bevel-top-right-radius: var(--bevel-radius);
--bevel-bottom-right-radius: var(--bevel-radius);
--bevel-bottom-left-radius: var(--bevel-radius);
display: inline-grid;
min-width: 0;
border-style: solid;
border-color: var(--line-strong);
border-width: 1px;
border-radius:
var(--bevel-top-left-radius)
var(--bevel-top-right-radius)
var(--bevel-bottom-right-radius)
var(--bevel-bottom-left-radius);
vertical-align: middle;
}
.bevel[data-top='false'] {
--bevel-top-left-radius: 0px;
--bevel-top-right-radius: 0px;
border-top-width: 0;
}
.bevel[data-right='false'] {
--bevel-top-right-radius: 0px;
--bevel-bottom-right-radius: 0px;
border-right-width: 0;
}
.bevel[data-bottom='false'] {
--bevel-bottom-right-radius: 0px;
--bevel-bottom-left-radius: 0px;
border-bottom-width: 0;
}
.bevel[data-left='false'] {
--bevel-bottom-left-radius: 0px;
--bevel-top-left-radius: 0px;
border-left-width: 0;
}
.bevel--fill {
width: 100%;
}
.bevel > :global(*) {
min-width: 0;
border-radius: inherit;
}
.bevel > :global(:is(button, input, select, textarea)) {
border-color: transparent;
}
.bevel[data-invalid='true'],
.bevel:has(> :global([aria-invalid='true'])) {
border-color: var(--danger);
}
.bevel:has(> :global(:is(:disabled, [aria-disabled='true']))) {
opacity: 0.58;
}
@media (forced-colors: active) {
.bevel {
border-color: ButtonText;
forced-color-adjust: auto;
}
.bevel[data-invalid='true'],
.bevel:has(> :global([aria-invalid='true'])) {
border-color: Mark;
}
}
@media print {
.bevel {
border-color: currentColor;
}
}
</style>
@@ -0,0 +1,64 @@
<script lang="ts">
export type BevelLineDirection = 'x' | 'y';
type Props = {
direction?: BevelLineDirection;
length?: string;
decorative?: boolean;
as?: 'span' | 'div';
class?: string;
};
let {
direction = 'x',
length = '100%',
decorative = false,
as = 'span',
class: className = '',
}: Props = $props();
</script>
<svelte:element
this={as}
class={`bevel-line ${className}`.trim()}
data-direction={direction}
role={decorative ? undefined : 'separator'}
aria-orientation={decorative ? undefined : direction === 'x' ? 'horizontal' : 'vertical'}
aria-hidden={decorative ? 'true' : undefined}
style:width={direction === 'x' ? length : undefined}
style:height={direction === 'y' ? length : undefined}
></svelte:element>
<style>
.bevel-line {
display: block;
flex: 0 0 auto;
border-color: var(--line-strong);
pointer-events: none;
}
.bevel-line[data-direction='x'] {
height: 0;
border-top-style: solid;
border-top-width: 1px;
}
.bevel-line[data-direction='y'] {
width: 0;
border-left-style: solid;
border-left-width: 1px;
}
@media (forced-colors: active) {
.bevel-line {
border-color: CanvasText;
forced-color-adjust: auto;
}
}
@media print {
.bevel-line {
border-color: currentColor;
}
}
</style>
@@ -0,0 +1,178 @@
<script lang="ts">
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
type Placement = 'top' | 'bottom';
type Alignment = 'start' | 'center' | 'end';
let {
id,
text,
placement = 'top',
align = 'center',
children,
}: {
id: string;
text: string;
placement?: Placement;
align?: Alignment;
children: Snippet<[descriptionId: string]>;
} = $props();
let open = $state(false);
let pointerInside = false;
let focusInside = false;
let hoverTimer: number | undefined;
let rootElement: HTMLSpanElement | undefined;
let tooltipElement: HTMLSpanElement | undefined;
let tooltipLeft = $state(0);
let tooltipTop = $state(0);
function clearHoverTimer() {
if (hoverTimer === undefined) return;
globalThis.clearTimeout(hoverTimer);
hoverTimer = undefined;
}
function positionTooltip() {
if (!open || rootElement === undefined || tooltipElement === undefined) return;
const root = rootElement.getBoundingClientRect();
const tooltip = tooltipElement.getBoundingClientRect();
const margin = 8;
const gap = 8;
let left = root.left + (root.width - tooltip.width) / 2;
if (align === 'start') left = root.left;
if (align === 'end') left = root.right - tooltip.width;
left = Math.max(margin, Math.min(left, globalThis.innerWidth - tooltip.width - margin));
let top = placement === 'top' ? root.top - tooltip.height - gap : root.bottom + gap;
if (top < margin) top = root.bottom + gap;
if (top + tooltip.height > globalThis.innerHeight - margin) {
top = root.top - tooltip.height - gap;
}
top = Math.max(margin, Math.min(top, globalThis.innerHeight - tooltip.height - margin));
tooltipLeft = Math.round(left);
tooltipTop = Math.round(top);
}
function reveal() {
open = true;
void tick().then(positionTooltip);
}
function showAfterDelay() {
pointerInside = true;
clearHoverTimer();
hoverTimer = globalThis.setTimeout(() => {
reveal();
hoverTimer = undefined;
}, 300);
}
function showImmediately() {
focusInside = true;
clearHoverTimer();
reveal();
}
function dismiss() {
clearHoverTimer();
open = false;
}
function handlePointerLeave() {
pointerInside = false;
clearHoverTimer();
if (!focusInside) open = false;
}
function handleFocusOut(event: FocusEvent) {
const root = event.currentTarget;
if (root instanceof HTMLElement && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
return;
}
focusInside = false;
if (!pointerInside) open = false;
}
function handleWindowKeydown(event: KeyboardEvent) {
if (open && event.key === 'Escape') dismiss();
}
onMount(() => {
const reposition = () => positionTooltip();
globalThis.addEventListener('resize', reposition);
document.addEventListener('scroll', reposition, true);
return () => {
globalThis.removeEventListener('resize', reposition);
document.removeEventListener('scroll', reposition, true);
};
});
onDestroy(clearHoverTimer);
</script>
<svelte:window onkeydown={handleWindowKeydown} />
<span
class="tooltip-root"
bind:this={rootElement}
onpointerenter={showAfterDelay}
onpointerleave={handlePointerLeave}
onfocusin={showImmediately}
onfocusout={handleFocusOut}
>
{@render children(id)}
<span
{id}
class:open
class="tooltip-content"
role="tooltip"
bind:this={tooltipElement}
style:left={`${tooltipLeft}px`}
style:top={`${tooltipTop}px`}
>
{text}
</span>
</span>
<style>
.tooltip-root {
display: inline-flex;
width: fit-content;
}
.tooltip-content {
position: fixed;
z-index: 50;
width: max-content;
max-width: min(20rem, calc(100vw - var(--space-4)));
border: 1px solid var(--line-strong);
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-3);
background: var(--text-strong);
box-shadow: var(--shadow-overlay);
color: var(--bg);
font-family: var(--font-sans);
font-size: var(--font-size-compact);
font-weight: 500;
line-height: 16px;
opacity: 0;
pointer-events: none;
text-align: left;
visibility: hidden;
}
.tooltip-content.open {
opacity: 1;
visibility: visible;
}
@media (prefers-reduced-motion: no-preference) {
.tooltip-content {
transition: opacity 100ms ease;
}
}
</style>
+74 -27
View File
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { setContext } from 'svelte';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
@@ -16,6 +17,11 @@
sidebar = activeSidebar;
});
const headerController = $state<HeaderController>({ content: null });
let sidebarFolded = $state(false);
function toggleSidebar() {
sidebarFolded = !sidebarFolded;
}
provideHeaderController(headerController);
setContext<SidebarController>(SIDEBAR_CONTEXT, {
@@ -25,8 +31,8 @@
<WorkspaceAlerts />
<div class="app-shell">
<SidebarFrame>
<div class="app-shell" class:sidebar-open={!sidebarFolded}>
<SidebarFrame bind:folded={sidebarFolded}>
<GlobalSidebar
currentPath={page.url.pathname}
content={sidebar}
@@ -34,19 +40,39 @@
workspaceError={data.workspaceCatalogError}
/>
</SidebarFrame>
<header class="app-shell__topbar">
<div class="app-shell__topbar-location">
{#if headerController.content}{@render headerController.content()}{/if}
</div>
<nav class="app-shell__topbar-actions" aria-label="Global navigation">
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
</nav>
</header>
<Bevel as="div" class="app-shell__topbar-bevel" top={false} right={false} left={false} fill>
<header class="app-shell__topbar">
<div class="app-shell__topbar-location">
{#if headerController.content}{@render headerController.content()}{/if}
</div>
<nav class="app-shell__topbar-actions" aria-label="Global navigation">
<button
class="app-shell__icon-button app-shell__mobile-sidebar-toggle"
type="button"
aria-label={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
aria-expanded={!sidebarFolded}
title={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
onclick={toggleSidebar}
>
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
{#if sidebarFolded}
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
{:else}
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
{/if}
</svg>
</button>
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
</nav>
</header>
</Bevel>
<main class="app-shell__main">
{@render children()}
</main>
@@ -65,11 +91,14 @@
min-width: 0;
}
.app-shell__topbar {
position: relative;
.app-shell :global(.bevel.app-shell__topbar-bevel) {
z-index: 30;
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.app-shell__topbar {
display: flex;
align-items: center;
justify-content: space-between;
@@ -77,7 +106,6 @@
min-width: 0;
min-height: 3.25rem;
padding: 0 var(--space-5);
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
backdrop-filter: blur(14px);
}
@@ -111,6 +139,14 @@
color: var(--text-muted);
}
.app-shell__mobile-sidebar-toggle {
display: none;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.app-shell__icon {
width: 1.1rem;
height: 1.1rem;
@@ -138,25 +174,36 @@
@media (max-width: 760px) {
.app-shell {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
width: 100vw;
height: auto;
min-height: 100dvh;
overflow: visible;
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.app-shell :global(.bevel.app-shell__topbar-bevel) {
grid-column: 1;
grid-row: 1;
}
.app-shell__topbar {
grid-column: 1;
grid-row: 2;
padding: 0 var(--space-4);
}
.app-shell__mobile-sidebar-toggle {
display: inline-flex;
}
.app-shell__main {
grid-column: 1;
grid-row: 3;
overflow: visible;
grid-row: 2;
overflow-y: auto;
padding: var(--space-5) var(--space-4);
}
.app-shell.sidebar-open .app-shell__main {
display: none;
}
}
</style>
@@ -0,0 +1,51 @@
<script lang="ts">
import { page } from '$app/state';
import { setContext } from 'svelte';
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import {
getSidebarController,
SIDEBAR_CONTEXT,
type SidebarController,
type SidebarSnippet,
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import WorkspaceSidebarFixture from './WorkspaceSidebarFixture.svelte';
import { designLabBasePath } from './workspace-navigation';
import './showroom.css';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
sidebarContent = activeContent;
});
setContext<SidebarController>(SIDEBAR_CONTEXT, {
registerSidebar: sidebarContentOverrides.register,
});
const inSettings = $derived(page.url.pathname.startsWith(`${designLabBasePath}/settings`));
</script>
{#snippet designLabHeader()}
<nav class="design-lab-header" aria-label="Current location">
{#if inSettings}
<a href={designLabBasePath}>Workspace Web UX</a>
<span aria-hidden="true">/</span>
<span aria-current="page">Settings</span>
{:else}
<span aria-current="page">Workspace Web UX</span>
{/if}
</nav>
{/snippet}
{#snippet workspaceSidebar()}
<WorkspaceSidebarFixture currentPath={page.url.pathname} content={sidebarContent} />
{/snippet}
<HeaderOverride content={designLabHeader} />
<SidebarOverride controller={parentSidebarController} sidebar={workspaceSidebar} />
{@render children()}
@@ -0,0 +1,277 @@
<script lang="ts">
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import Tooltip from '$lib/workspace/ui/Tooltip.svelte';
</script>
<div class="showroom">
<section class="showroom-section" aria-labelledby="action-heading">
<h1 id="action-heading" class="showroom-heading">Action</h1>
<BevelLine direction="x" length="100%" decorative />
<div class="action-row">
<Bevel>
<button class="action action--primary" type="button">Create Worker</button>
</Bevel>
<Tooltip id="rerun-help" text="Runs validation against the current Workspace configuration." placement="bottom" align="start">
{#snippet children(descriptionId)}
<Bevel>
<button class="action action--secondary" type="button" aria-describedby={descriptionId}>Re-run checks</button>
</Bevel>
{/snippet}
</Tooltip>
<button class="action action--text" type="button">View log</button>
<Bevel>
<button class="action action--destructive" type="button">Remove repository</button>
</Bevel>
<Bevel>
<button class="action action--secondary" type="button" disabled>Running checks…</button>
</Bevel>
</div>
<div class="border-matrix">
<Bevel as="div" fill>
<div class="tactile-group" role="group" aria-label="Bordered validation controls">
<span>
<strong>Closed border</strong>
<small>3 checks passed at 14:32</small>
</span>
<Bevel>
<button class="compact-control" type="button" aria-pressed="true">Live</button>
</Bevel>
</div>
</Bevel>
<Bevel as="div" fill>
<div class="tactile-group" role="group" aria-label="Read-only bordered controls">
<span>
<strong>Read-only border</strong>
<small>Read-only control group</small>
</span>
<span class="status"><span class="status__marker" data-tone="muted"></span>Locked</span>
</div>
</Bevel>
<Bevel as="div" right={false} bottom={false} fill>
<div class="tactile-group" role="group" aria-label="Top and left edge">
<span>
<strong>Top + left</strong>
<small>Only the shared top-left corner is rounded</small>
</span>
</div>
</Bevel>
<Bevel as="div" top={false} left={false} fill>
<div class="tactile-group" role="group" aria-label="Right and bottom groove">
<span>
<strong>Right + bottom</strong>
<small>Only the shared bottom-right corner is rounded</small>
</span>
</div>
</Bevel>
</div>
</section>
<section class="showroom-section" aria-labelledby="line-heading">
<h2 id="line-heading" class="showroom-heading">Line</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="line-showcase">
<div class="line-sample line-sample--horizontal">
<span class="line-sample__label">Horizontal · 100%</span>
<BevelLine direction="x" length="100%" />
<span class="line-sample__label">Horizontal · 160px</span>
<BevelLine direction="x" length="160px" />
</div>
<div class="line-sample line-sample--vertical">
<span>Before</span>
<BevelLine direction="y" length="72px" />
<span>After</span>
</div>
</div>
</section>
<section class="showroom-section" aria-labelledby="status-heading">
<h2 id="status-heading" class="showroom-heading">Status</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="status-row">
<span class="status"><span class="status__marker" data-tone="success"></span>Ready</span>
<span class="status"><span class="status__marker" data-tone="accent"></span>Running</span>
<span class="status"><span class="status__marker" data-tone="warning"></span>Needs review</span>
<span class="status"><span class="status__marker" data-tone="danger"></span>Failed</span>
<span class="status"><span class="status__marker" data-tone="muted"></span>Unavailable</span>
</div>
</section>
<section class="showroom-section" aria-labelledby="feedback-heading">
<h2 id="feedback-heading" class="showroom-heading">Feedback</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="feedback-grid">
<div class="feedback" role="status">
<span class="status"><span class="status__marker status__marker--loading" data-tone="accent"></span>Loading Merge Requests…</span>
</div>
<div class="feedback">
<strong>No Objectives.</strong>
</div>
<div class="feedback feedback--error" role="alert">
<div>
<strong>Merge failed.</strong>
<p>Target branch changed after review. Refresh the target before retrying.</p>
</div>
<Bevel>
<button class="action action--secondary" type="button">Refresh target</button>
</Bevel>
</div>
<div class="feedback feedback--permission">
<div>
<strong>Repository Access is read-only.</strong>
<p>Workspace owner permission is required to change credentials.</p>
</div>
</div>
</div>
</section>
<section id="resource-row" class="showroom-section" aria-labelledby="resource-row-heading">
<h2 id="resource-row-heading" class="showroom-heading">Resource row</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="resource-list">
<a class="resource-row" href="/design-lab/workspace-web-ux?ticket=YOI-248">
<span class="resource-row__identity">
<code>YOI-248</code>
<strong>Preserve nested navigation ownership across route transitions</strong>
</span>
<span class="resource-row__meta">High priority</span>
<span class="status"><span class="status__marker" data-tone="warning"></span>Blocked</span>
</a>
<BevelLine direction="x" length="100%" decorative />
<a class="resource-row" href="/design-lab/workspace-web-ux?ticket=YOI-251">
<span class="resource-row__identity">
<code>YOI-251</code>
<strong>Validate repository access projection</strong>
</span>
<span class="resource-row__meta">Updated 14 min ago</span>
<span class="status"><span class="status__marker" data-tone="success"></span>Ready</span>
</a>
<BevelLine direction="x" length="100%" decorative />
<a class="resource-row" href="/design-lab/workspace-web-ux?ticket=YOI-252">
<span class="resource-row__identity">
<code>YOI-252-WITH-A-LONG-CANONICAL-RESOURCE-KEY</code>
<strong>Keep very long translated resource names readable without pushing the current state out of the row</strong>
</span>
<span class="resource-row__meta">No owner</span>
<span class="status"><span class="status__marker" data-tone="muted"></span>Draft</span>
</a>
</div>
</section>
<section class="showroom-section" aria-labelledby="comparable-heading">
<h2 id="comparable-heading" class="showroom-heading">Comparable data</h2>
<BevelLine direction="x" length="100%" decorative />
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable comparison must be keyboard-focusable.) -->
<div class="table-scroll" tabindex="0" role="region" aria-label="Runtime comparison">
<table>
<thead>
<tr>
<th scope="col">Runtime</th>
<th scope="col">Kind</th>
<th scope="col">State</th>
<th scope="col">
<Tooltip id="last-seen-help" text="Most recent observation from the Runtime host." placement="top" align="end">
{#snippet children(descriptionId)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Focusable column label exposes contextual help to keyboard users.) -->
<span class="column-help" tabindex="0" aria-describedby={descriptionId}>Last seen</span>
{/snippet}
</Tooltip>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><span>Local development</span><code>local</code></th>
<td>Built-in</td>
<td><span class="status"><span class="status__marker" data-tone="success"></span>Available</span></td>
<td>Now</td>
</tr>
<tr>
<th scope="row"><span>Remote Linux runner with a long operational name</span><code>runner-eu-central-01</code></th>
<td>Remote</td>
<td><span class="status"><span class="status__marker" data-tone="danger"></span>Unavailable</span></td>
<td>2 hours ago</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="showroom-split">
<section class="showroom-section" aria-labelledby="key-value-heading">
<h2 id="key-value-heading" class="showroom-heading">Key-value</h2>
<BevelLine direction="x" length="100%" decorative />
<dl class="key-value">
<div><dt>Repository</dt><dd>yoi</dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Default selector</dt><dd><code>refs/heads/main</code></dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Access</dt><dd><span class="status"><span class="status__marker" data-tone="success"></span>Configured</span></dd></div>
</dl>
</section>
<section class="showroom-section" aria-labelledby="tooltip-heading">
<h2 id="tooltip-heading" class="showroom-heading">Tooltip</h2>
<BevelLine direction="x" length="100%" decorative />
<Tooltip id="source-help" text="The source that currently supplies this configuration value." placement="bottom" align="start">
{#snippet children(descriptionId)}
<button class="help-label" type="button" aria-describedby={descriptionId}>Configuration source <span aria-hidden="true">?</span></button>
{/snippet}
</Tooltip>
</section>
</div>
<section class="showroom-section" aria-labelledby="disclosure-heading">
<h2 id="disclosure-heading" class="showroom-heading">Disclosure</h2>
<BevelLine direction="x" length="100%" decorative />
<details class="disclosure">
<summary>Technical details</summary>
<dl class="technical-values">
<div><dt>Revision</dt><dd><code>rev_01J8WZ7C8AV6P3Y2N0M4KQ9B1F</code></dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Digest</dt><dd><code>sha256:2f8d9bb168afb7b19f447463f3fbe8b4728d8e87a64eaf1afbea1ca4d4c41f21</code></dd></div>
</dl>
</details>
</section>
<section class="showroom-section" aria-labelledby="color-heading">
<h2 id="color-heading" class="showroom-heading">Color</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="token-grid">
<div class="color-token"><span style="--sample: var(--bg)"></span><code>--bg</code></div>
<div class="color-token"><span style="--sample: var(--bg-raised)"></span><code>--bg-raised</code></div>
<div class="color-token"><span style="--sample: var(--bg-subtle)"></span><code>--bg-subtle</code></div>
<div class="color-token"><span style="--sample: var(--text-strong)"></span><code>--text-strong</code></div>
<div class="color-token"><span style="--sample: var(--accent)"></span><code>--accent</code></div>
<div class="color-token"><span style="--sample: var(--success)"></span><code>--success</code></div>
<div class="color-token"><span style="--sample: var(--warning)"></span><code>--warning</code></div>
<div class="color-token"><span style="--sample: var(--danger)"></span><code>--danger</code></div>
</div>
</section>
<div class="showroom-split">
<section class="showroom-section" aria-labelledby="typography-heading">
<h2 id="typography-heading" class="showroom-heading">Typography</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="type-samples">
<div><span class="type-route">Workspace identity</span><code>24 / 32</code></div>
<div><span class="type-body">Repository access</span><code>14 / 20</code></div>
<div><span class="type-meta">refs/heads/main</span><code>12 / 16</code></div>
</div>
</section>
<section class="showroom-section" aria-labelledby="spacing-heading">
<h2 id="spacing-heading" class="showroom-heading">Spacing</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="space-samples">
<div><span style="--size: var(--space-1)"></span><code>4</code></div>
<div><span style="--size: var(--space-2)"></span><code>8</code></div>
<div><span style="--size: var(--space-3)"></span><code>12</code></div>
<div><span style="--size: var(--space-4)"></span><code>16</code></div>
<div><span style="--size: var(--space-5)"></span><code>24</code></div>
<div><span style="--size: var(--space-6)"></span><code>32</code></div>
</div>
</section>
</div>
</div>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { designLabBasePath, workspaceNavigation, workspaceWorkers } from './workspace-navigation';
let {
currentPath,
content = null,
}: {
currentPath: string;
content?: Snippet<[]> | null;
} = $props();
const settingsPath = `${designLabBasePath}/settings`;
</script>
<div class="workspace-sidebar">
<header class="sidebar-header">
<nav class="workspace-sidebar-shortcuts" aria-label="Workspace shortcuts">
<a
class="workspace-sidebar-shortcut"
class:active={currentPath === designLabBasePath}
href={designLabBasePath}
aria-label="Workspace home"
title="Workspace home"
aria-current={currentPath === designLabBasePath ? 'page' : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m3 11 9-8 9 8"></path>
<path d="M5 10v10h14V10"></path>
<path d="M9 20v-6h6v6"></path>
</svg>
</a>
<a
class="workspace-sidebar-shortcut"
class:active={currentPath.startsWith(settingsPath)}
href={settingsPath}
aria-label="Workspace settings"
title="Workspace settings"
aria-current={currentPath === settingsPath ? 'page' : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 5h16M4 12h16M4 19h16"></path>
<path d="M8 3v4M16 10v4M10 17v4"></path>
</svg>
</a>
</nav>
</header>
{#if content}
{@render content()}
{:else}
<nav class="sidebar-sections" aria-label="Workspace sections">
{#each workspaceNavigation as item}
{#if item.children}
<section class="sidebar-nav-section sidebar-nav-section--category">
<h2 class="sidebar-nav-section__header">{item.label}</h2>
{#each item.children as child}
<a class="sidebar-link" href={child.href}>{child.label}</a>
{/each}
</section>
{:else if item.label === 'Workers'}
<section class="sidebar-nav-section" aria-labelledby="design-lab-workers-heading">
<div class="section-heading-row">
<h2 id="design-lab-workers-heading">
<a class="section-heading-link" href={item.href}>workers</a>
</h2>
<a class="section-action" href={`${designLabBasePath}?worker=new`}>New</a>
<span class="section-count">{workspaceWorkers.length}</span>
</div>
<ul class="nav-list" aria-label="Workers">
{#each workspaceWorkers as worker}
<li class="worker-nav-item">
<a class="worker-nav-link" href={`${designLabBasePath}?worker=${worker.key}`}>
<span class="worker-status-indicator" aria-hidden="true">
<span class="worker-status-dot"></span>
</span>
<span class="worker-nav-label" title={worker.label}>{worker.label}</span>
<small class="worker-nav-meta" title={`${worker.state} · ${worker.repository}`}>
{worker.state} · {worker.repository}
</small>
</a>
</li>
{/each}
</ul>
</section>
{:else}
<section class="sidebar-nav-section sidebar-nav-section--resource">
<a class="sidebar-link" href={item.href}>{item.label}</a>
</section>
{/if}
{/each}
</nav>
{/if}
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import { page } from '$app/state';
import { setContext } from 'svelte';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import {
getSidebarController,
SIDEBAR_CONTEXT,
type SidebarController,
type SidebarSnippet,
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import SettingsSidebarFixture from './SettingsSidebarFixture.svelte';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
sidebarContent = activeContent;
});
setContext<SidebarController>(SIDEBAR_CONTEXT, {
registerSidebar: sidebarContentOverrides.register,
});
</script>
{#snippet settingsSidebar()}
<SettingsSidebarFixture currentPath={page.url.pathname} content={sidebarContent} />
{/snippet}
<SidebarOverride controller={parentSidebarController} sidebar={settingsSidebar} />
{@render children()}
@@ -0,0 +1,100 @@
<script lang="ts">
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import Tooltip from '$lib/workspace/ui/Tooltip.svelte';
function keepSampleFormLocal(event: SubmitEvent) {
event.preventDefault();
}
</script>
<div class="showroom showroom--settings">
<section class="showroom-section" aria-labelledby="form-field-heading">
<h1 id="form-field-heading" class="showroom-heading">Form field</h1>
<BevelLine direction="x" length="100%" decorative />
<form class="sample-form" onsubmit={keepSampleFormLocal}>
<div class="form-grid">
<label class="form-field">
<span>Runtime label</span>
<Bevel fill>
<input name="runtime-label" value="Remote Linux runner" />
</Bevel>
</label>
<label class="form-field">
<span>Endpoint</span>
<Bevel fill invalid>
<input
name="runtime-endpoint"
value="http://runner.internal.example:8787/api/runtime"
aria-describedby="endpoint-constraint endpoint-error"
aria-invalid="true"
/>
</Bevel>
<small id="endpoint-constraint">Use an HTTPS endpoint reachable from the Workspace server.</small>
<span id="endpoint-error" class="field-error">This endpoint uses HTTP. Enter an HTTPS URL.</span>
</label>
<label class="form-field">
<span>Architecture</span>
<Bevel fill>
<select name="architecture">
<option>arm64</option>
<option selected>x86_64</option>
</select>
</Bevel>
</label>
</div>
<BevelLine direction="x" length="100%" decorative />
<div class="form-footer">
<p class="unsaved"><span class="status__marker" data-tone="warning"></span>Unsaved changes</p>
<Bevel>
<button class="action action--primary" type="submit">Save runtime</button>
</Bevel>
</div>
</form>
</section>
<section class="showroom-section" aria-labelledby="operation-error-heading">
<h2 id="operation-error-heading" class="showroom-heading">Operation error</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="feedback feedback--error" role="alert">
<div>
<strong>Runtime was not saved.</strong>
<p>The endpoint did not respond within 10 seconds. Check the address and try again.</p>
</div>
<Tooltip id="retry-runtime-help" text="Checks this endpoint again without saving other fields." placement="bottom" align="end">
{#snippet children(descriptionId)}
<Bevel>
<button class="action action--secondary" type="button" aria-describedby={descriptionId}>Test endpoint</button>
</Bevel>
{/snippet}
</Tooltip>
</div>
</section>
<section class="showroom-section" aria-labelledby="permission-boundary-heading">
<h2 id="permission-boundary-heading" class="showroom-heading">Permission boundary</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="permission-read-view">
<dl class="key-value">
<div><dt>Credential source</dt><dd>Workspace secret store</dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Access</dt><dd><span class="status"><span class="status__marker" data-tone="muted"></span>Read-only</span></dd></div>
</dl>
<p>Workspace owner permission is required to replace repository credentials.</p>
</div>
</section>
<section class="showroom-section" aria-labelledby="empty-heading">
<h2 id="empty-heading" class="showroom-heading">Empty</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="feedback">
<strong>No Profile Sources.</strong>
<Bevel>
<button class="action action--secondary" type="button">Add profile source</button>
</Bevel>
</div>
</section>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { settingsBasePath, settingsNavigation } from './settings-navigation';
let {
currentPath,
content = null,
}: {
currentPath: string;
content?: Snippet<[]> | null;
} = $props();
</script>
<div class="settings-sidebar">
{#if content}
{@render content()}
{:else}
<nav class="sidebar-sections" aria-label="Settings sections">
<div class="sidebar-nav-section">
<div class="sidebar-list">
{#each settingsNavigation as item, index}
<a
class="sidebar-link"
class:active={index === 0 && currentPath === settingsBasePath}
href={item.href}
aria-current={index === 0 && currentPath === settingsBasePath ? 'page' : undefined}
>
<span class="sidebar-link-label">{item.label}</span>
</a>
{/each}
</div>
</div>
</nav>
{/if}
</div>
@@ -0,0 +1,12 @@
import { designLabBasePath } from '../workspace-navigation';
export const settingsBasePath = `${designLabBasePath}/settings`;
export const settingsNavigation = [
{ label: 'Runtimes', href: settingsBasePath },
{ label: 'Configuration Sources', href: `${settingsBasePath}?section=configuration-sources` },
{ label: 'Repositories', href: `${settingsBasePath}?section=repositories` },
{ label: 'Repository Access', href: `${settingsBasePath}?section=repository-access` },
{ label: 'Profile Sources', href: `${settingsBasePath}?section=profile-sources` },
{ label: 'Workspace Identity', href: `${settingsBasePath}?section=workspace-identity` },
] as const;
@@ -0,0 +1,683 @@
@layer reset, tokens, base, layout, components;
@layer components {
.design-lab-header {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 20px;
}
.design-lab-header a {
color: var(--text-muted);
text-decoration: none;
}
.design-lab-header a:hover {
color: var(--text-strong);
text-decoration: underline;
text-underline-offset: 3px;
}
.design-lab-header [aria-current="page"] {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-sidebar .section-action {
margin-left: auto;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-soft);
color: var(--accent);
font-size: var(--font-size-compact);
font-weight: 650;
line-height: 16px;
text-decoration: none;
}
.workspace-sidebar .section-action:hover,
.workspace-sidebar .section-action:focus-visible {
background: var(--interactive-hover);
}
.showroom {
width: min(100%, 1180px);
margin: 0 auto;
padding: clamp(var(--space-5), 4vw, var(--space-6));
display: grid;
gap: var(--space-6);
}
.showroom--settings {
width: min(100%, 900px);
margin-inline: 0;
}
.showroom-section {
min-width: 0;
display: grid;
align-content: start;
gap: var(--space-3);
}
.showroom-heading {
margin: 0;
padding-bottom: var(--space-2);
color: var(--text-strong);
font-size: var(--font-size-body);
font-weight: 650;
line-height: 20px;
}
.showroom-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: calc(var(--space-6) * 1.5);
}
.action-row,
.status-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.action {
min-height: 36px;
border: 1px solid transparent;
border-radius: var(--radius-soft);
padding: 7px var(--space-4);
background: transparent;
color: inherit;
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 20px;
cursor: pointer;
}
.action--primary {
background: var(--accent);
color: var(--bg);
}
.action--primary:hover {
background: color-mix(in oklab, var(--accent), var(--text-strong) 14%);
}
.action--secondary {
background: var(--bg-raised);
color: var(--text-strong);
}
.action--secondary:hover {
background: var(--interactive-hover);
}
.action--destructive {
background: color-mix(in oklab, var(--danger) 14%, var(--bg-raised));
color: var(--danger);
}
.action--destructive:hover {
background: color-mix(in oklab, var(--danger) 22%, var(--bg-raised));
}
.action--text {
padding-inline: var(--space-2);
color: var(--accent);
}
.action--text:hover {
background: var(--interactive-hover);
color: var(--accent-muted);
}
.action:disabled {
cursor: not-allowed;
}
.compact-control {
min-height: 30px;
border: 1px solid transparent;
border-radius: inherit;
padding: var(--space-1) var(--space-3);
background: var(--interactive-selected);
color: var(--text-strong);
cursor: pointer;
font: 600 var(--font-size-compact) / var(--line-height-compact)
var(--font-mono);
}
.border-matrix {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.tactile-group {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: inherit;
color: var(--text);
}
.tactile-group > span {
display: grid;
gap: var(--space-1);
}
.tactile-group small {
color: var(--text-muted);
font-family: var(--font-mono);
}
.line-showcase {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: var(--space-6);
}
.line-sample--horizontal {
display: grid;
align-content: center;
gap: var(--space-3);
}
.line-sample--vertical {
display: flex;
min-height: 96px;
align-items: center;
gap: var(--space-3);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
}
.line-sample__label {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
}
.status {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 18px;
white-space: nowrap;
}
.status__marker {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--text-faint);
}
.status__marker[data-tone="success"] {
background: var(--success);
}
.status__marker[data-tone="accent"] {
background: var(--accent);
}
.status__marker[data-tone="warning"] {
background: var(--warning);
}
.status__marker[data-tone="danger"] {
background: var(--danger);
}
.status__marker--loading {
border: 2px solid color-mix(in oklch, var(--accent), transparent 65%);
border-top-color: var(--accent);
background: transparent !important;
animation: showroom-spin 800ms linear infinite;
}
@keyframes showroom-spin {
to {
transform: rotate(360deg);
}
}
.feedback-grid {
display: grid;
gap: var(--space-2);
}
.feedback {
min-height: 48px;
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
border-left: 3px solid var(--line-strong);
background: var(--bg-raised);
}
.feedback strong {
color: var(--text-strong);
font-size: var(--font-size-body);
}
.feedback p {
margin: 2px 0 0;
color: var(--text-muted);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
.feedback--error {
border-left-color: var(--danger);
}
.feedback--permission {
border-left-color: var(--warning);
}
.resource-list {
min-width: 0;
margin-inline: var(--space-2);
}
.resource-row {
min-width: 0;
min-height: 64px;
padding: var(--space-3) var(--space-2);
display: grid;
grid-template-columns:
minmax(0, 1fr) minmax(110px, auto) minmax(90px, auto);
align-items: center;
gap: var(--space-4);
color: inherit;
text-decoration: none;
}
.resource-row:hover {
border-radius: var(--radius-soft);
background: var(--interactive-hover);
}
.resource-row__identity {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.resource-row__identity strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-strong);
font-weight: 600;
}
.resource-row__identity code {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: 16px;
}
.resource-row__meta {
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: 16px;
text-align: right;
}
.table-scroll {
max-width: 100%;
overflow-x: auto;
}
.table-scroll:focus-visible {
border-radius: var(--radius-soft);
}
.showroom table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
text-align: left;
}
.showroom th,
.showroom td {
padding: var(--space-3) var(--space-4);
vertical-align: middle;
}
.showroom tbody tr:nth-child(even) {
background: var(--bg-raised);
}
.showroom thead th {
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 16px;
}
.showroom tbody th {
max-width: 360px;
color: var(--text-strong);
font-size: var(--font-size-compact);
font-weight: 600;
}
.showroom tbody th span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.showroom tbody th code {
display: block;
margin-top: 2px;
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 400;
}
.column-help {
color: inherit;
text-decoration: underline dotted;
text-underline-offset: 0.2em;
cursor: help;
}
.key-value,
.technical-values {
margin: 0 var(--space-2);
}
.key-value > div:not(.bevel-line),
.technical-values > div:not(.bevel-line) {
padding: var(--space-2) 0;
display: grid;
grid-template-columns: minmax(110px, 0.65fr) minmax(0, 1fr);
gap: var(--space-4);
}
.key-value dt,
.technical-values dt {
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 18px;
}
.key-value dd,
.technical-values dd {
min-width: 0;
margin: 0;
color: var(--text-strong);
font-size: var(--font-size-compact);
line-height: 18px;
}
.technical-values code {
overflow-wrap: anywhere;
}
.help-label {
min-height: 36px;
border: 0;
border-bottom: 1px dotted var(--line-strong);
background: transparent;
color: var(--text-strong);
font-weight: 600;
cursor: help;
}
.help-label span {
color: var(--text-muted);
}
.disclosure {
padding-block: var(--space-2);
}
.disclosure summary {
padding: var(--space-3) var(--space-2);
color: var(--text-strong);
font-weight: 600;
cursor: pointer;
}
.disclosure summary:hover {
border-radius: var(--radius-soft);
background: var(--interactive-hover);
}
.technical-values {
padding: 0 var(--space-2) var(--space-4);
}
.token-grid {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: var(--space-4);
}
.color-token {
min-width: 0;
display: grid;
gap: var(--space-2);
}
.color-token span {
height: 48px;
border: 1px solid var(--line);
background: var(--sample);
}
.color-token code,
.type-samples code,
.space-samples code {
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: 16px;
}
.type-samples {
display: grid;
gap: var(--space-4);
}
.type-samples > div {
display: grid;
gap: var(--space-1);
}
.type-route {
color: var(--text-strong);
font-size: var(--font-size-title);
font-weight: 650;
line-height: var(--line-height-title);
}
.type-body {
color: var(--text);
font-size: var(--font-size-body);
line-height: 20px;
}
.type-meta {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
line-height: 16px;
}
.space-samples {
display: grid;
gap: var(--space-2);
}
.space-samples > div {
display: grid;
grid-template-columns: minmax(0, 1fr) 32px;
align-items: center;
gap: var(--space-3);
}
.space-samples span {
width: var(--size);
height: 8px;
min-width: 4px;
background: var(--accent-muted);
}
.sample-form {
display: grid;
gap: var(--space-5);
margin-inline: var(--space-2);
}
.form-grid {
display: grid;
gap: var(--space-5);
}
.form-field {
display: grid;
gap: var(--space-2);
color: var(--text-strong);
font-size: var(--font-size-compact);
font-weight: 600;
}
.form-field input,
.form-field select {
width: 100%;
min-height: 40px;
border: 1px solid transparent;
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-3);
background: var(--bg);
color: var(--text-strong);
}
.form-field small {
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 400;
line-height: 16px;
}
.field-error {
color: var(--danger);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 16px;
}
.form-footer {
padding-top: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.unsaved {
margin: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-strong);
font-size: var(--font-size-compact);
font-weight: 600;
}
.permission-read-view {
display: grid;
gap: var(--space-3);
}
.permission-read-view p {
margin: 0;
color: var(--text-muted);
font-size: var(--font-size-body);
}
@media (prefers-reduced-motion: reduce) {
.status__marker--loading {
animation: none;
}
}
@media (max-width: 760px) {
.showroom {
padding: var(--space-5) var(--space-4);
gap: var(--space-5);
}
.showroom-split,
.line-showcase,
.border-matrix {
grid-template-columns: 1fr;
gap: var(--space-6);
}
.resource-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2) var(--space-3);
}
.resource-row__identity {
grid-column: 1 / -1;
}
.resource-row__meta {
text-align: left;
}
.feedback {
align-items: flex-start;
flex-direction: column;
}
.token-grid {
grid-template-columns: repeat(2, minmax(100px, 1fr));
}
}
@media (max-width: 420px) {
.key-value > div,
.technical-values > div {
grid-template-columns: 1fr;
gap: var(--space-1);
}
.form-footer,
.tactile-group {
align-items: stretch;
flex-direction: column;
}
}
}
@@ -0,0 +1,37 @@
export const designLabBasePath = '/design-lab/workspace-web-ux';
export type WorkspaceNavigationItem = {
label: string;
href: string;
children?: WorkspaceNavigationItem[];
};
export const workspaceNavigation: WorkspaceNavigationItem[] = [
{ label: 'Tickets', href: `${designLabBasePath}?resource=tickets` },
{ label: 'Objectives', href: `${designLabBasePath}?resource=objectives` },
{ label: 'Merge Requests', href: `${designLabBasePath}?resource=merge-requests` },
{
label: 'Memory',
href: `${designLabBasePath}?resource=memory`,
children: [
{ label: 'Document', href: `${designLabBasePath}?resource=memory-document` },
{ label: 'Staging', href: `${designLabBasePath}?resource=memory-staging` },
],
},
{ label: 'Workers', href: `${designLabBasePath}?resource=workers` },
];
export const workspaceWorkers = [
{
key: 'wrk-language-review',
label: 'Language review',
state: 'Running',
repository: 'yoi',
},
{
key: 'wrk-accessibility-check',
label: 'Accessibility check with a deliberately long display name',
state: 'Idle',
repository: 'workspace-web',
},
] as const;
@@ -81,7 +81,7 @@
.memory-document-summary span {
color: var(--text-muted);
display: block;
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -90,7 +90,7 @@
.memory-document-summary strong {
color: var(--text);
display: block;
font-size: 0.95rem;
font-size: var(--font-size-compact);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
@@ -101,8 +101,8 @@
border-radius: 0.9rem;
color: var(--text);
font-family: var(--font-mono);
font-size: 0.88rem;
line-height: 1.6;
font-size: var(--font-size-body);
line-height: var(--line-height-body);
margin: 1rem 0 0;
overflow: auto;
padding: 1rem;
@@ -170,7 +170,7 @@
.staging-meta dt {
color: var(--text-muted);
display: block;
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -179,7 +179,7 @@
.staging-summary-grid strong {
color: var(--text);
display: block;
font-size: 0.95rem;
font-size: var(--font-size-compact);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
@@ -206,7 +206,7 @@
.staging-entry h3 {
color: var(--text-strong);
font-size: 1rem;
font-size: var(--font-size-body);
margin: 0.35rem 0 0;
}
@@ -225,7 +225,7 @@
border-radius: 999px;
color: var(--accent);
display: inline-flex;
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.18rem 0.55rem;
@@ -384,7 +384,7 @@
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: var(--font-size-compact); }
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
@@ -187,9 +187,9 @@
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--surface);
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
@@ -276,9 +276,9 @@
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--surface);
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
@@ -2120,7 +2120,7 @@
color: var(--text-muted);
padding: 0.42rem 0.65rem;
font: inherit;
font-size: 0.7rem;
font-size: var(--font-size-compact);
font-weight: 700;
cursor: pointer;
}
@@ -2147,7 +2147,7 @@
.rewind-targets h3 {
margin: 0;
font-size: 0.9rem;
font-size: var(--font-size-body);
}
.rewind-target-list {
@@ -2209,7 +2209,7 @@
background: var(--bg-raised);
color: var(--text-muted);
cursor: pointer;
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 800;
padding: 0.35rem 0.5rem;
text-align: left;
@@ -2240,7 +2240,7 @@
.pending-submissions {
margin: 0 var(--space-3);
color: var(--muted);
font-size: 0.75rem;
font-size: var(--font-size-compact);
}
.pending-submissions ol {
@@ -2306,7 +2306,7 @@
.console-side-panel dt {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
@@ -2320,7 +2320,7 @@
.metadata-details {
color: var(--text-muted);
font-size: 0.84rem;
font-size: var(--font-size-compact);
}
.metadata-details summary {
@@ -2380,7 +2380,7 @@
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.2rem 0.5rem;
font: 500 0.75rem/1.2 var(--font-mono);
font: 500 var(--font-size-compact) / var(--line-height-compact) var(--font-mono);
}
.composer-attachment.failed {
@@ -2469,12 +2469,12 @@
gap: var(--space-2);
align-items: center;
color: var(--text-muted);
font-size: 0.86rem;
font-size: var(--font-size-compact);
}
.composer-notice {
color: var(--text-muted);
font-size: 0.86rem;
font-size: var(--font-size-compact);
}
.composer-actions {
+132
View File
@@ -0,0 +1,132 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Bevel draws a plain selectable border around semantic children", async () => {
const source = await Deno.readTextFile(
new URL("../src/lib/workspace/ui/Bevel.svelte", import.meta.url),
);
const appCss = await Deno.readTextFile(
new URL("../src/app.css", import.meta.url),
);
const designLanguage = await Deno.readTextFile(
new URL(
"../../../docs/development/ui-ux/design-language.md",
import.meta.url,
),
);
for (
const contract of [
"children: Snippet<[]>",
"top?: boolean",
"right?: boolean",
"bottom?: boolean",
"left?: boolean",
"data-invalid={invalid || undefined}",
"border-style: solid",
"border-color: var(--line-strong)",
"border-width: 1px",
"aria-invalid='true'",
"forced-colors: active",
]
) {
assert(source.includes(contract), `Bevel must preserve ${contract}`);
}
assert(
source.includes("data-top={top ? 'true' : 'false'}") &&
source.includes("data-right={right ? 'true' : 'false'}") &&
source.includes("data-bottom={bottom ? 'true' : 'false'}") &&
source.includes("data-left={left ? 'true' : 'false'}") &&
source.includes("border-top-width: 0") &&
source.includes("border-right-width: 0") &&
source.includes("border-bottom-width: 0") &&
source.includes("border-left-width: 0") &&
source.includes("--bevel-top-left-radius: 0px") &&
source.includes("--bevel-bottom-right-radius: 0px"),
"Bevel must draw only selected sides and round only corners shared by enabled sides",
);
assert(
!source.includes("BevelProfile") &&
!source.includes("BevelDepth") &&
!source.includes("profile") &&
!source.includes("depth") &&
!source.includes("pressed") &&
!source.includes("::before") &&
!source.includes("::after") &&
!source.includes("gradient") &&
!source.includes("mask") &&
!source.includes("color-mix") &&
!source.includes("cos(") &&
!source.includes("pow(") &&
!appCss.includes("--bevel-highlight") &&
!appCss.includes("--bevel-shadow") &&
!appCss.includes("--bevel-highlight-curve") &&
!appCss.includes("--bevel-face-width"),
"Bevel must remain a plain border without lighting or depth simulation",
);
assert(
!source.includes("background:") &&
!source.includes("background-color:") &&
source.includes("button, input, select, textarea"),
"Bevel must not own surface color and must keep semantic controls as children",
);
assert(
designLanguage.includes("1pxの単色borderを描くvisual wrapper") &&
designLanguage.includes(
"照明、raisedinset、ridgegrooveを表現しない",
) &&
designLanguage.includes("text/content areaへ適用する"),
"The design language must define Bevel as a plain border without surface ownership",
);
});
Deno.test("Workspace showroom exercises closed and adjacent borders", async () => {
const workspaceSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/+page.svelte",
import.meta.url,
),
);
const settingsSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/settings/+page.svelte",
import.meta.url,
),
);
const showroomCss = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/showroom.css",
import.meta.url,
),
);
const tactileGroupRule = showroomCss.match(
/\.tactile-group \{([\s\S]*?)\n \}/,
)?.[1];
assert(
workspaceSource.includes('<div class="border-matrix">') &&
workspaceSource.includes("right={false} bottom={false}") &&
workspaceSource.includes("top={false} left={false}") &&
workspaceSource.includes("Closed border") &&
workspaceSource.includes("Read-only border") &&
!workspaceSource.includes("profile=") &&
!workspaceSource.includes("depth=") &&
!workspaceSource.includes("<Bevel pressed"),
"Workspace showroom must demonstrate closed and adjacent plain borders",
);
assert(
tactileGroupRule && !tactileGroupRule.includes("background"),
"Showroom bordered items must leave their content area background unset",
);
assert(
settingsSource.includes("<Bevel fill>") &&
settingsSource.includes("<Bevel fill invalid>") &&
settingsSource.includes('aria-invalid="true"') &&
!settingsSource.includes("profile=") &&
!settingsSource.includes("depth="),
"Settings showroom must demonstrate plain and invalid borders",
);
});
@@ -0,0 +1,132 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("BevelLine exposes a semantic directional separator", async () => {
const source = await Deno.readTextFile(
new URL("../src/lib/workspace/ui/BevelLine.svelte", import.meta.url),
);
for (
const contract of [
"export type BevelLineDirection = 'x' | 'y'",
"length?: string",
"as?: 'span' | 'div'",
"role={decorative ? undefined : 'separator'}",
"'horizontal' : 'vertical'",
"style:width={direction === 'x' ? length : undefined}",
"style:height={direction === 'y' ? length : undefined}",
"pointer-events: none",
"forced-colors: active",
]
) {
assert(source.includes(contract), `BevelLine must preserve ${contract}`);
}
assert(
source.includes("border-color: var(--line-strong)") &&
source.includes("border-top-style: solid") &&
source.includes("border-top-width: 1px") &&
source.includes("border-left-style: solid") &&
source.includes("border-left-width: 1px") &&
!source.includes("background:") &&
!source.includes("gradient") &&
!source.includes("color-mix") &&
!source.includes("BevelLineDepth") &&
!source.includes("depth"),
"BevelLine must remain a plain one-pixel directional border",
);
assert(
!source.includes("padding-inline") &&
!source.includes("padding-block") &&
!source.includes("margin-inline") &&
!source.includes("margin-block"),
"BevelLine must fill its parent layer without independent endpoint spacing",
);
});
Deno.test("Workspace showroom demonstrates both BevelLine directions", async () => {
const source = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/+page.svelte",
import.meta.url,
),
);
assert(
source.includes('direction="x" length="100%"') &&
source.includes('direction="y" length="72px"') &&
source.includes('direction="x" length="160px"') &&
!source.includes("depth=") &&
!source.includes("weight="),
"Workspace showroom must demonstrate horizontal and vertical plain separators",
);
});
Deno.test("Shell regions use selected Bevel edges while open separators use BevelLine", async () => {
const rootLayout = await Deno.readTextFile(
new URL("../src/routes/+layout.svelte", import.meta.url),
);
const sidebarFrame = await Deno.readTextFile(
new URL(
"../src/lib/workspace/sidebar/SidebarFrame.svelte",
import.meta.url,
),
);
const sidebarCss = await Deno.readTextFile(
new URL("../src/lib/workspace/sidebar/sidebar.css", import.meta.url),
);
const showroomCss = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/showroom.css",
import.meta.url,
),
);
const designLanguage = await Deno.readTextFile(
new URL(
"../../../docs/development/ui-ux/design-language.md",
import.meta.url,
),
);
assert(
rootLayout.includes('class="app-shell__topbar-bevel"') &&
rootLayout.includes("top={false} right={false} left={false}") &&
rootLayout.includes("bind:folded={sidebarFolded}") &&
rootLayout.includes("app-shell__mobile-sidebar-toggle") &&
rootLayout.includes("class:sidebar-open={!sidebarFolded}") &&
!rootLayout.includes("depth=") &&
!rootLayout.includes("BevelLine") &&
sidebarFrame.includes('class="sidebar-frame__bevel"') &&
sidebarFrame.includes("top={false} bottom={false} left={false}") &&
sidebarFrame.includes("$bindable(false)") &&
!sidebarFrame.includes("depth=") &&
!sidebarFrame.includes("BevelLine") &&
sidebarCss.includes(".sidebar-frame.folded") &&
sidebarCss.includes("display: none") &&
sidebarCss.includes("display: grid"),
"Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state",
);
assert(
designLanguage.includes(
"Headerのbottom edgeとSidebarのright edgeだけを有効",
) &&
designLanguage.includes("Mobileのfold controlはHeaderに置く") &&
designLanguage.includes("main contentと同時表示しない") &&
designLanguage.includes("同じlayerではheading、本文、Lineの端を揃え") &&
designLanguage.includes(
"親layoutがそのlayer全体へinline方向の余白を与え",
) &&
showroomCss.includes(".resource-list {") &&
showroomCss.includes("margin-inline: var(--space-2)"),
"The design language and showroom must preserve adjacent area and line hierarchy",
);
assert(
!sidebarCss.includes("border-right:") &&
!sidebarCss.includes("border-bottom:") &&
!showroomCss.includes("border-top: 1px solid") &&
!showroomCss.includes("border-bottom: 1px solid"),
"Shell and showroom structural boundaries must use shared Bevel components",
);
});
@@ -0,0 +1,59 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Tooltip exposes the same contextual help to pointer and keyboard users", async () => {
const source = await Deno.readTextFile(
new URL("../src/lib/workspace/ui/Tooltip.svelte", import.meta.url),
);
for (
const contract of [
'role="tooltip"',
"onpointerenter={showAfterDelay}",
"onfocusin={showImmediately}",
"event.key === 'Escape'",
"children: Snippet<[descriptionId: string]>",
"max-width: min(20rem, calc(100vw - var(--space-4)))",
]
) {
assert(source.includes(contract), `Tooltip must preserve ${contract}`);
}
});
Deno.test("Tooltip showroom binds help to actions and column headings", async () => {
const workspaceSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/+page.svelte",
import.meta.url,
),
);
const settingsSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/settings/+page.svelte",
import.meta.url,
),
);
for (const descriptionId of ["rerun-help", "last-seen-help", "source-help"]) {
assert(
workspaceSource.includes(descriptionId),
`Workspace showroom must expose contextual help ${descriptionId}`,
);
}
assert(
settingsSource.includes("retry-runtime-help"),
"Settings showroom must expose retry operation help",
);
assert(
workspaceSource.includes('class="column-help"') &&
workspaceSource.includes("aria-describedby={descriptionId}>Last seen"),
"Column help must be bound to the column label rather than a separate info button",
);
assert(
!workspaceSource.includes("Actions explain") &&
!workspaceSource.includes("Use a table when"),
"Showroom must not restore persistent operation or table explanation",
);
});
@@ -0,0 +1,84 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
async function collectStyleSources(directory: URL): Promise<URL[]> {
const files: URL[] = [];
for await (const entry of Deno.readDir(directory)) {
const url = new URL(entry.name, directory);
if (entry.isDirectory) {
url.pathname += "/";
files.push(...await collectStyleSources(url));
} else if (
entry.isFile &&
(entry.name.endsWith(".css") || entry.name.endsWith(".svelte"))
) {
files.push(url);
}
}
return files;
}
Deno.test("Workspace typography uses only the title body and compact scales", async () => {
const sourceRoot = new URL("../src/", import.meta.url);
const allowedSizes = new Set([
"var(--font-size-title)",
"var(--font-size-body)",
"var(--font-size-compact)",
"inherit",
]);
for (const url of await collectStyleSources(sourceRoot)) {
const source = await Deno.readTextFile(url);
for (const match of source.matchAll(/font-size:\s*([^;]+);/g)) {
const value = match[1].trim();
assert(
allowedSizes.has(value),
`${url.pathname} must not use the font-size value ${value}`,
);
}
assert(
!/font:\s*[^;]*(?:\d+(?:\.\d+)?(?:px|rem|em))[^;]*;/g.test(source),
`${url.pathname} font shorthand must use a shared typography token`,
);
}
});
Deno.test("Workspace typography tokens bind the documented three-level scale", async () => {
const appCss = await Deno.readTextFile(
new URL("../src/app.css", import.meta.url),
);
const designLanguage = await Deno.readTextFile(
new URL(
"../../../docs/development/ui-ux/design-language.md",
import.meta.url,
),
);
for (
const contract of [
"--font-size-title: 24px",
"--line-height-title: 32px",
"--font-size-body: 14px",
"--line-height-body: 20px",
"--font-size-compact: 12px",
"--line-height-compact: 16px",
"font-size: var(--font-size-title)",
"font-size: var(--font-size-body)",
"font-size: var(--font-size-compact)",
]
) {
assert(appCss.includes(contract), `app.css must preserve ${contract}`);
}
assert(
designLanguage.includes("--font-size-title /* 24px */") &&
designLanguage.includes("--font-size-body /* 14px */") &&
designLanguage.includes("--font-size-compact /* 12px */") &&
designLanguage.includes("`12px`未満と`13px`を使わない"),
"The design language must preserve the title, body, and compact typography contract",
);
});