feat: establish workspace web design language

This commit is contained in:
2026-09-17 01:31:54 +09:00
parent c8eb767d63
commit ab6450b9fe
39 changed files with 3626 additions and 614 deletions
+10 -7
View File
@@ -19,13 +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. [`design/workspace-web-ux.md`](design/workspace-web-ux.md) — Workspace Webの情報設計、操作設計、視覚設計、レスポンシブ対応、アクセシビリティ、CSSの責務
15. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
16. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
17. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
18. [`development/validation.md`](development/validation.md) — how to check changes.
19. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
20. [`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
-413
View File
@@ -1,413 +0,0 @@
# Workspace Web UX・デザインルール
状態: レビュー用の正規ルール案
この文書は、`web/workspace`の情報設計、操作設計、視覚設計、responsive対応、accessibility、CSSの責務を定義する。本番画面はこの文書へ収束させる。現行実装は判断材料ではあるが、ルールそのものではない。この文書と矛盾する既存のマークアップやCSSは、別の有効なパターンではなく移行対象として扱う。
`docs/.local/workspace-web-pattern-candidate-v1.md`は比較検討に利用できるが、productionの設計authorityではない。
## 設計の立場
Workspace
Webは、Workspace内のリソースを起点に、権威ある状態を確認し、注意が必要な箇所を判断し、一度に一つの操作を行うための作業面である。宣伝用ダッシュボード、分析画面、Backend
DTOのraw viewerにはしない。
永続化されたBackend
recordを状態のauthorityとする。UIは入力途中のdraftを保持したり、操作準備を先行したりしてよいが、推測、cache、通信状態を現在のdomain
stateとして表示してはならない。
## ページモデル
各routeは、主要な目的を一つだけ持つ。その目的は「Ticketを選ぶ」「このWorkerを確認する」「Repository
accessを設定する」のように、動詞と対象を一つずつ使って表現できなければならない。一文で表現できない場合はrouteを分けるか、副次的な情報を段階的に開示する。
Workspaceのページは、次のいずれかを基本形とする。
1. **一覧またはboard** — リソースを探し、注意が必要な項目を見つける。
2. **詳細** — 一つのリソースを理解し、現在実行できる次の操作を行う。
3. **formまたはeditor** — 範囲の明確な設定変更を行う。
4. **Console** — 一つのWorkerを操作しながら、履歴と現在のcontrolを確認する。
同じ目的を支える場合に限り、一つのrouteで複数の基本形を組み合わせてよい。たとえばTicket詳細では履歴とworkflow
actionを併置できる。一方、Workspace landing
pageに無関係なRuntime、Repository、診断情報を並べてはならない。
## 画面枠とナビゲーション
- root layoutが唯一のapplication shellとsidebar containerを所有する。
- sidebar
sectionは、Global、Workspace、Settings、route固有sectionの順に積み重ねる。
- 子routeは自分のscopeに属するlinkだけを追加する。親のnavigationを置き換えたり複製したりしない。
- Workspace scopeのlinkは、Workspace routeの中だけに表示する。
- navigation labelとURLにはcanonical resource
keyを使う。内部UUIDをlabelやfallback linkとして露出しない。
- 存在しない、または閲覧できないWorkspaceを開いた場合は、その状態を明示する。別のWorkspaceへ暗黙に切り替えない。
- sidebarのfold状態はshellが所有する。navigation
sectionが別のsidebar、独自の幅、fold controlを持ってはならない。
## 情報階層
### ページヘッダー
通常のページは、一つの簡潔なheader rowから始める。
- 左側にpage titleを置く。
- 右側のprimary actionは最大一つとする。
- route分類を繰り返すeyebrowは置かない。
- titleを言い換えただけの導入文は置かない。
subtitleを置けるのは、判断に影響する情報、分かりにくいscope、重要な制約を伝える場合だけとする。resource
keyは、detail
pageで識別に必要な場合にtitle付近へ置いてよいが、人が読むtitleより強く見せない。
### セクション
- sectionはDTOやdatabase fieldの順ではなく、ユーザーの作業順に並べる。
- primary actionと、その判断に必要な状態を初期viewportに置く。
- containerを追加する前に、spacing、alignment、typography、必要最小限のseparatorで階層を作る。
- scanningに役立つ場合だけsection headingを置く。page
titleで意味が明らかな領域を`Overview`などで重ねて囲まない。
- list、board、table、form
group、timelineは連続した作業面として扱う。項目ごとに独立したcardへ分解しない。
- 一つの境界は一度だけ表現する。nested
border、背景差、大きなgap、shadowを重ねて同じ階層を表現しない。
### メタデータ
主要画面に表示するのは、リソースの識別、healthやattentionの判断、項目間の比較、次の操作の選択に必要な値だけとする。
次の値は通常、label付きのdetail、debug、copy affordanceの後ろへ置く。
- 内部ID
- schema、config、recordのrevision
- digestとfingerprint
- provider diagnostics
- authority source label
- raw requestまたはresponse payload
機械的な値にはmonospace familyを使う。labelや説明文にはsans
familyを使う。DTOに値が存在することだけを理由に画面へ表示してはならない。
## 段階的な開示
段階的な開示は判断負荷を減らすために使う。前提条件や現在の失敗を隠すために使ってはならない。
- primary viewにはidentity、current
state、attention、次の有効なactionを表示する。
- 副次的な運用情報はplain section、side
panel、またはlabel付き`<details>`へ置く。
- debug evidenceとraw metadataは、ユーザーが明示的に開いた場合だけ表示する。
- destructive controlは、ユーザーが該当flowへ入るまでprimary
actionより弱く表示する。
- create formとedit
formは、明示的なactionの後に表示する。継続編集が主目的でなければ、成功後はresource
viewへ戻すかformを閉じる。
- disclosure
labelは内容または操作対象を具体的に示す。名詞を伴わない`More``Advanced`を使わない。
blocking error、permission requirement、validation constraint、unsaved
changeを閉じたdisclosureへ隠してはならない。
## Authority、権限、操作
permissionはcontrolのenabled状態だけでなく、ページ構成そのものへ反映する。
- non-ownerにowner-only mutation controlを表示しない。
- readは可能だがmutationできない場合、有用なread viewだけを構成する。空のaction
barや理由の分からないdisabled controlを置かない。
- permission
boundaryの説明は現在の作業に影響する場所だけに置く。`owner only`をpage
eyebrowとして使わない。
- endpointからdataを取得できたかどうかでpermissionを推測しない。権威あるpermission
projectionを使う。
- domain stateとtransport
stateを混同しない。たとえばWebSocketがopenであることは、Workerがidleまたはhealthyであることを意味しない。
- 現在の権威あるstateで有効なactionだけを利用可能として提示する。
- destructive
actionには対象リソースと結果を明記する。即時に復元できない場合は意図的なconfirmationを要求し、通常のprimary
pathと視覚的に区別する。
page headerまたはform action group内のfilled primary
actionは一つだけとする。secondary actionはbordered actionまたはtext
actionとする。destructive actionは、最後の破壊確認段階を除きfilledにしない。
## 読み込み中、空、エラー、利用不能
dataを持つsurfaceは、意味のある状態を個別に扱う。
### 読み込み中
- data取得中もpage frameとsection位置を維持する。
- scopeが明らかでない場合は、何を読み込んでいるか示す。
- page全体を中央spinnerへ置き換えてlayout shiftを起こさない。
- spinnerだけに意味を持たせず、accessibility上のlabelも与える。
### 空
- absenceは`No tickets`のように直接表現する。装飾的なillustrationや長い説明文を置かない。
- permissionがあり、実際に利用できる場合だけ、次のactionを一つ提示する。
- recordが存在しない状態と、recordを読み込めなかった状態を区別する。
### エラー
- field validationは対象fieldの近くに置く。
- operation errorは失敗したactionの近くに置く。
- section load errorは対象section内に置き、利用できる他のsectionは残す。
- routeの主要目的を果たせない場合だけroute-level errorにする。
- diagnosticsはboundedかつactionableにする。payload、secret、内部traceをdumpしない。
- 新しく発生し、即時の注意が必要なerrorには`role="alert"`を使う。常設の説明文をalertにしない。
### 利用不能と権限不足
resource unavailable、capability missing、permission
deniedは異なる状態として扱い、copyも分ける。利用できない具体的なcapabilityまたは必要なpermissionと、安全な次のactionがあればそれを示す。`403`をempty
listとして表示したり、別のWorkspaceへredirectしたりしない。
## 視覚設計
### 基本姿勢
Workspace Webは、分離したwidgetの集合ではなく、一つのcontrol
surfaceとして見えるようにする。情報のgroupingには、まずspacing、typography、text
contrastを使う。border、rounded rectangle、shadow、filled
panelは、spacingだけでは伝わらない意味のある境界に限定する。
### カラーパレット
colorは`app.css`のCSS custom propertyをauthorityとし、OKLCHで定義する。light
modeとdark modeは`prefers-color-scheme`を通じて同じsemantic tokenを使う。
ルール:
- page backgroundとlayout surfaceはchroma zeroの`oklch(... 0 0)`を使う。
- primary textとcode textは、ほぼneutralなwarm colorとする。CSS
OKLCHはsaturationではなくchromaを直接扱うため、「約5%
saturation」の意図は`C = 0.01`から`0.012`程度の小さなwarm chromaで表現する。
- muted textは新しいhueを増やす前にlightnessとchromaを下げる。
- accentとstatus colorはsemantic
exceptionとする。state、focus、navigationを示すために使い、container装飾には使わない。
- Workspace Web componentへraw hexまたはrgb
colorを追加しない。必要な場合は`app.css`へsemantic tokenを追加する。
基本token:
```css
--bg
--bg-raised
--bg-subtle
--line
--line-strong
--text
--text-strong
--text-muted
--text-faint
--code
--accent
--success
--warning
--danger
```
### 表示面と操作部品
- structural surfaceにshadowや装飾目的のradiusを使わない。
- bordered parentの内側にbordered childを置かない。
- rowは一つのseparatorと、必要に応じたhoverまたはselected backgroundで表現する。
- button、input、select、textareaなどのinteractive
controlだけに`var(--radius-soft)`を使う。
- resource key、link、status、table cell、Ticket、sectionをpillにしない。
- statusは小さなmarkerとtextで表現する。markerは`aria-hidden`とし、textに意味を持たせる。
- laneやsectionがすでにstateを示している場合、row内で同じstateを繰り返さない。
- colorとspacingにはsemantic tokenだけを使う。literal colorや局所的なspacing
scaleを追加しない。
## タイポグラフィと文言
通常のWorkspace pageでは、三段階のtype hierarchyを使う。
| 用途 | size / line height | 使用箇所 |
| -------------------------------------- | ------------------ | -------------------- |
| Page title | `24px / 32px` | routeごとに一つ |
| Body、control、row、section title | `14px / 20px` | contentとinteraction |
| Metadata、table heading、machine value | `12px / 16px` | 副次情報と機械的な値 |
別のfont sizeを増やす前に、配置、spacing、weight、muted
colorでhierarchyを作る。Consoleとterminal outputは、密度が必要な場合にmetadata
sizeのmonospace
styleを使ってよい。より多くのdataを表示するためにtextを`12px`未満へ縮小しない。
copyのルール:
- 具体的な名詞と動詞を使う。
- Workspace、Ticket、Objective、Repository、Runtime、Workdir、Worker、Merge
Requestなど、ユーザーに見えるdomain nameを使う。
- identifier、API名、type名は、正確性が必要な場合に原文を維持する。
- page title、navigation label、明らかなcontrol behaviorを説明文で繰り返さない。
- proseを追加するのは、判断を変える情報、結果へのwarning、permissionまたはvalidation
constraint、error recoveryを示す場合だけとする。
- empty copyとstatus copyは短く、事実を直接書く。
- buttonは`Add repository``Save settings``Stop Worker`のように操作を表す。`Submit``OK`、装飾的なcategory
labelを避ける。
- statusとattentionをcolorだけで表現しない。
- uppercase labelは小さなmetadata labelに限る。大きなall-caps blockを置かない。
## リソースごとのパターン
### Tickets
- boardを一つの連続した横方向の作業面として扱う。
- laneは控えめなheadingと、隣接laneとの一つのseparatorで区切る。
- Ticketはlane内のflat rowとし、独立したcardにしない。
- Ticket key、title、優先判断を変えるmetadataだけを表示する。
- blockerとattentionはtextでも示す。
- narrow viewportでは、Ticketをmobile cardへ変換せず、label付きhorizontal scroll
regionでlane比較を維持する。
### リソース一覧
- 同じfieldをリソース間で比較する場合、flat listまたはtableを使う。
- 人が読むnameとcanonical keyを最初のcolumnに置く。
- columnは判断に必要な値だけに絞る。provider detailとrevisionはresource
detailへ移す。
- narrow viewportでcolumnを消すと比較できなくなる場合、label付きhorizontal
scroll regionを使う。それ以外は一つのrowを明確なlabel付きでstackする。
### 詳細ページ
- identity、current state、attentionを最初に置く。
- narrativeまたはhistoryをprimary
columnに置く。actionを同時に確認する必要がある場合だけ、bounded secondary
regionを併置する。
- relation、target、review、workflowが別のauthorityを持つ場合、それぞれを独立したsectionにする。
- raw metadataは閉じたdetailまたはdebug disclosureへ置く。
### Settingsとフォーム
- 一つのSettings routeは一つの設定関心事を所有する。
- edit actionの前または同時に、現在有効な値を示す。
- labelはcontrolの直上に置く。
- help textはfield nameの説明ではなくconstraintを示す。
- 一つの判断に必要なfieldをgroup化する。fieldやgroupごとにcardで囲まない。
- narrow screenでは一列にする。複数columnは、同時に比較または決定する値に限る。
- 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で置き換えない。
- hidden reasoningとraw system promptはdebug modeでも表示しない。
## レスポンシブ対応
レスポンシブ設計では、すべてを同じstackへ変換するのではなく、作業上の関係性を維持する。
- desktopのpage paddingは`32px``760px`以下では`16px`とする。
- page全体にhorizontal scrollを発生させない。board、table、code
view、terminalは、自分の範囲内でbounded horizontal scrollを所有してよい。
- side-by-side表示が同じ判断を支えなくなった場合、detail
gridまたはformを一列にする。
- stack化によってworkflowやcolumn
relationshipが失われる場合、比較面を横方向に維持する。
- primary actionを初期viewportから追い出さない。
- touch targetの操作性を維持し、overflow解消のためにtextを縮小しない。
- 長いkey、ref、diagnosticはwrapまたはtruncateし、完全な値へaccessできる手段を用意する。
## アクセシビリティ
- `main``nav``section``table``form``fieldset``label``button`などのsemantic
elementを使う。
- すべてのcontrolに明示的なaccessible
nameを与える。placeholderをlabelとして使わない。
- keyboard focusをsemantic accent outlineで常に見えるようにする。
- tab orderを画面上の順序と作業順に合わせる。
- icon-only controlには、対象リソースを含む具体的な`aria-label`を付ける。
- 重要なloadingとoperation statusにはboundedな`aria-live`
regionを使う。streaming noiseを繰り返しannounceしない。
- error、warning、success、selected、runningは、color以外のtextまたはsemantic
distinctionを持つ。
- 必要なhorizontal scroll regionはfocus可能にし、accessible nameを付ける。
- light schemeとdark schemeで同じsemantic tokenを使い、component-local color
overrideなしで可読性を保つ。
- motionは任意かつ短くし、意味の必須条件にしない。繰り返しまたは連続animationではreduced-motion
preferenceを尊重する。
## CSSの責務
`app.css`はglobal-onlyとし、次だけを所有する。
- font import
- cascade layer order
- semantic design token
- resetとbase element style
- application-level layout helper
- 無関係なfeature間で共有する小さなprimitive
featureとpageのstyleは、`workspace-pages.css``tickets.css``workers.css``settings.css``sidebar.css`など、その領域を所有するstylesheetへ置く。共有するvisual
ruleはliteral valueのcopyではなくglobal tokenで表現する。
Svelte-local
styleは、behaviorとstyleを分離できず、再利用もしない場合に限る。広範なpage
styleを`app.css`へ戻したり、component-localなcolor、spacing scale、font、z-index
systemを作ったりしない。
新しいUIを追加するときは、次の順に判断する。
1. semantic HTMLと、この文書のpage modelから始める。
2. global tokenと既存のfeature-owned patternを再利用する。
3. spacingとtext contrastでhierarchyを作る。
4. boundaryが意味を持つ場合だけborderを使う。
5. background fillはpage-level surface、selection、attention、read-only
codeまたはrecord bodyに限定する。
6. 新しいcolorが必要ならOKLCH tokenとして追加し、既存のsemantic
tokenで表現できない理由を残す。
## 避けるパターン
次のパターンを設計慣習として追加または維持しない。
- 無関係なaction cardで構成されたdashboard風landing page
- card-within-cardまたはpanel-within-panel
- hierarchyを作るためだけのshadowや大きなradius
- `Delivery``Workspace resources``owner only`などのeyebrow
- page titleで示したリソースを管理する画面だと繰り返すlede
- statusまたはresource keyのpill
- laneですでに示したstateをrow内で繰り返す表示
- DTO順に全fieldを露出するpage
- primary viewに置かれた内部ID、revision、digest、fingerprint、authority label
- non-ownerに表示されたowner-only disabled control
- loading、empty、error、unavailable、permissionを一つのgeneric
messageへまとめる表示
- page全体をoverflowさせる固定desktop grid
- 比較関係を壊す任意のmobile card化
- color-only state、placeholder-only label、見えないkeyboard focus、accessible
nameのないicon-only control
- 二つ目のsidebar、route-local application
shell、親navigationを置き換えるsection
- component-localなliteral color、spacing scale、font、z-index system
## レビューと変更手順
Workspace Webの意味のある変更では、`tools/web-ux`をrepeatable browser
captureの手段として使う。
1. representative personaとviewportで現在のrouteをcaptureする。
2. screenshotと`review-context.json`を確認する。visible
UI、console、page、HTTP、scenario failureを含めて判定する。
3. UX上の具体的な問題と、違反しているルールを記録する。
4. 最小で一貫したproduction変更を行う。
5. 同じpersona、route、viewportで再captureする。
6. before/after evidenceを比較し、関連するsource test、UI test、E2E
testを実行する。
HTTP document
statusが200でも、画面に`401 Unauthorized`などのerrorが表示されていれば成功とはみなさない。authentication
profileはorigin-boundであり、別のhostまたはport用に取得したprofileを暗黙に再利用しない。
Design reviewでは、最初にユーザーのtask、次に情報階層とstate
handling、最後にvisual polishを評価する。test通過やscreenshot生成だけではUX
acceptanceを満たさない。
@@ -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を発生させる。
+304
View File
@@ -0,0 +1,304 @@
# Workspace Web Design Language
この文書は、Workspace Webをどう知覚させ、情報、状態、操作をどの視覚文法で表現するかを定義する。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)をauthorityとする。
一つの規則は、次の4階層のうち最も具体的な一箇所だけをauthorityとする。別の章で同じ禁止や手段を再掲せず、必要な場合はauthorityとなる章を参照する。
## 1. Principles
なぜこの設計にするかを定義する。個別の表示規則に迷った場合は、この章へ戻って判断する。
### 基本思想
- 作業ツールとして、シンプルで直感的なUX
- UIのために曖昧な情報を推測しない。データを正直に表示し、操作を見通せるようにする
- 状態・scope・操作を、装飾ではなく構造で読ませ、情報量を増やしても騒がしくならない
- 説明的に見せるのではなく、配置と関係で理解させる
### 知覚原則
size、weight、color、spacing、line、background、motionを使うときは、次のどの知覚を成立させるためか説明できなければならない。説明できない視覚表現は追加しない。
#### Hierarchy — 何が重要か
- 情報は、primary、secondary、technicalの順に読む構造にする。
- 重要度は、最初に配置順、次にsize、weight、contrastで示す。colorや装飾だけで重要度を作らない。
- 同じscopeに最も強いheading、filled action、attention表現を複数置かない。
- primary contentと、その判断に必要なstateまたはactionを初期viewportへ置く。
- metadataと補助操作は、主要なidentityやcurrent stateより強く見せない。
#### Grouping — 何と何が同じグループか
- 同じ判断や操作に使う情報は、一つのまとまりとして知覚できるようにする。
- groupの単位は、共通のownership、behavior、またはユーザーが行う一つの判断から決める。
- label、value、help、error、actionの対応関係を、別の説明を読まなくても追えるようにする。
- data sourceやDTOが同じという理由だけでgroup化しない。
- 一つの関係へ複数の視覚cueを重ねず、何がgroupを成立させているか一つに定める。
#### Separation — 何が別物か
- 異なるgroupの境界を知覚でき、その強さが意味上の距離と一致するようにする。
- すべての境界について、何と何を分けているか説明できなければならない。
- 一つの意味上の境界へ複数のseparation cueを重ねない。
- 境界がどのgroupの開始または終端を示すか、読み順から判別できるようにする。
- 異なるhierarchyの境界をすべて同じ強さで表現しない。
#### Affordance — 何が操作できるか
- actionにはbutton、navigationにはlink、選択にはcontrolを使い、役割を見た目とsemantic elementで一致させる。
- interactive elementは、通常、hover、focus、active、disabledを区別できるようにする。
- staticなstatus、key、metadataをbuttonやpillの形にしない。
- clickable rowは、click可能な範囲と遷移先を一つにする。
- permissionがない操作をdisabled controlとして見せることでaffordanceを偽らない。
#### State — 現在どういう状態か
- 権威を持つdata sourceのstateをそのまま表示し、UIの都合でunknown、stale、unavailableを推測で埋めない。
- stateは対象resourceまたはoperationの近くに置き、何のstateかを位置関係で判別できるようにする。
- stateはmarkerとtextで示し、colorを補助に使う。
- loading、empty、error、permission denied、unavailable、staleを別の状態として扱う。
- 非同期operationでは、開始、処理中、完了、失敗を見通せるようにする。
#### Focus — 今どこを見るべきか
- 一つのscopeでは、primary resource、current attention、next actionのいずれか一つを最初のfocusにする。
- accent、filled action、強いcontrast、motionを複数箇所で競合させない。
- normal stateは静かに保ち、attention表現は現在判断が必要な例外へ限定する。
- 視覚的なfocusとkeyboard focusを一致させる。
- focusを作るために周囲の情報を過度に薄くしたり、小さくしたりしない。
### 検証原則
この文書はrendered resultが満たす設計規則を定義する。実装者自身がbefore/afterをcaptureし、grouping、separator、spacing、wrapping、responsive、stateを目視して完了判定する手順は、[`visual-review.md`](visual-review.md)をauthorityとする。
## 2. Semantics / Grammar
何を、どの関係と意味で表現するかを定義する。
### 情報階層
route titleはshell headerとmain contentを通じて一度だけ表示する。
- 標準headerまたはbreadcrumbが現在routeを十分に識別している場合、main contentは最初の操作対象またはsectionから始める。
- shell headerがscopeだけを示し、main contentの対象を識別できない場合に限り、main側へ一つのtitleを置く。
- primary actionは、そのactionがpage全体の主要操作である場合だけtitleと同じrowへ置く。
- route分類、breadcrumb、header titleをeyebrow、heading、keyで繰り返さない。
- subtitleとledeは原則として置かない。
一つのsectionは、一つの目的、ownership、または読み順を持つ。目的が異なる内容を同じsectionへ入れず、同じ目的の内容を見た目だけで複数sectionへ分けない。Section間とsection内の関係は`Principles`のGroupingとSeparationに従い、具体的なcueは`Primitives / Tokens`だけから選ぶ。
### Metadata
主要表示に置くのは、identity、healthまたはattention、比較、次の操作に必要な値だけとする。
次の値は通常、label付きのdetail、debug、copy affordanceの後ろへ置く。
- 内部ID
- schema、config、recordのrevision
- digestとfingerprint
- provider diagnostics
- authority source label
- raw requestまたはresponse payload
機械的な値にはmonospace familyを使う。DTOに値が存在することだけを理由に表示しない。
### 段階的な開示
- primary viewにはidentity、current state、attention、次の有効なactionを表示する。
- 副次的な運用情報はplain section、side panel、またはlabel付き`details`へ置く。
- debug evidenceとraw metadataは、ユーザーが明示的に開いた場合だけ表示する。
- destructive controlは、ユーザーが該当flowへ入るまでprimary actionより弱く表示する。
- create formとedit formは明示的なactionの後に表示する。
- blocking error、permission requirement、validation constraint、unsaved changeを閉じたdisclosureへ隠さない。
### Authority、権限、操作
- permissionはcontrolのenabled状態だけでなく、表示構成そのものへ反映する。
- readは可能だがmutationできない場合、有用なread viewだけを構成する。
- endpointからdataを取得できたかどうかでpermissionを推測しない。
- domain stateとtransport stateを混同しない。
- current stateで有効なactionだけを利用可能として提示する。
- destructive actionには対象と結果を明記し、復元できない場合は意図的なconfirmationを要求する。
- 同じscopeのfilled primary actionは一つだけとする。
### Loading、empty、error、unavailable
#### Loading
- page frameとsection位置を維持する。
- scopeが明らかでない場合は、何を読み込んでいるか示す。
- page全体を中央spinnerへ置き換えない。
- spinnerだけに意味を持たせない。
#### Empty
- absenceを直接表現する。
- permissionがあり、actionが実際に利用できる場合だけ、次のactionを一つ提示する。
- recordが存在しない状態と、読み込めなかった状態を区別する。
#### Error
- field validationは対象fieldの近くに置く。
- operation errorは失敗したactionの近くに置く。
- section load errorは対象section内に置き、利用できるsibling sectionは残す。
- routeの主要目的を果たせない場合だけroute-level errorにする。
- diagnosticsはboundedかつactionableにする。
#### Unavailableとpermission
resource unavailable、capability missing、permission deniedを別の状態として扱う。`403`をempty listとして表示したり、別scopeへredirectしたりしない。
### 文言
- 具体的な名詞と動詞を使う。
- identifier、API名、type名は正確性が必要な場合に原文を維持する。
- title、navigation label、明らかなcontrol behavior、componentの存在理由を説明文で繰り返さない。
- proseを追加するのは、判断を変える具体的な情報、warning、permission、validation constraint、error recoveryに必要な場合だけとする。
- empty copyとstatus copyは短く、事実を直接書く。
- buttonは操作を表す。`Submit``OK`、装飾的なcategory labelを避ける。
### Responsive
- page全体にhorizontal scrollを発生させない。
- board、table、code view、terminalは、自分の範囲内でbounded horizontal scrollを所有してよい。
- side-by-side表示が同じ判断を支えなくなった場合、一列にする。
- stack化によって比較関係が失われる場合、比較面を横方向に維持する。
- primary actionを初期viewportから追い出さない。
- touch targetの操作性を維持し、overflow解消のためにtextを縮小しない。
- 長いkey、ref、diagnosticはwrapまたはtruncateし、完全な値へaccessできる手段を用意する。
### Accessibility
- semantic elementを使う。
- すべてのcontrolに明示的なaccessible nameを与える。
- keyboard focusを常に見えるようにする。
- tab orderを画面上の順序と作業順に合わせる。
- icon-only controlには具体的な`aria-label`を付ける。
- stateはcolor以外のtextまたはsemantic distinctionを持つ。
- 必要なhorizontal scroll regionはfocus可能にし、accessible nameを付ける。
- lightとdarkで同じsemantic tokenを使う。
## 3. Primitives / Tokens
色、文字、間隔、形、motionの最小単位を定義する。
### 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
--bevel-highlight
--bevel-shadow
--bevel-face-width
--shadow-overlay
```
- backgroundとlayout surfaceはneutralにする。
- muted textは新しいhueを増やす前にlightnessとchromaを下げる。
- accentとstatus colorはstate、focus、navigationへ限定する。
- componentへraw colorを追加せず、semantic tokenを使う。
- `--bevel-highlight``--bevel-shadow`はBevel共通の照明endpointとし、componentのbackgroundから派生させない。Bevel wrapper、`profile``depth`、描画辺へsurface色を与えてはならず、Bevelはedge lightingだけを所有する。背景色を変更できるのは内側のsemantic childが所有するtext/content areaだけとする。
- Bevelの一面の幅はproject-wideな`--bevel-face-width: 2px`へ固定し、component APIから変更させない。`edge`は一面2px、`ridge``BevelLine`は二面を重ねた合計4pxとする。
- Bevelのgeometryは`profile = edge | ridge`、凹凸方向は`depth = raised | inset`として直交させる。`ridge + inset`はgrooveを表す。Lineも`depth = raised | inset`を使う。
- Box Bevelは`top``right``bottom``left`で描画辺を個別に選べる。defaultは全辺有効とし、角丸はその角に隣接する2辺がともに有効な場合だけ描画する。ridgeの内側面も有効辺からだけinsetする。
- 独立した構造separatorは`BevelLine`を使い、片側のsolid borderで代用しない。外周border、focus ring、status marker、表の意味的なgridは別のprimitiveとして扱う。
- 意味が重なるtoken aliasを作らない。
ConsoleとterminalのANSI paletteは専用tokenを使い、通常のstatusやformへ流用しない。
### Typography
| 用途 | size / line height | 使用箇所 |
| -------------------------------------- | ------------------ | ----------------------------- |
| Route title | `24px / 32px` | shellまたはmainのどちらか一方 |
| Body、control、row、section title | `14px / 20px` | contentとinteraction |
| Metadata、table heading、machine value | `12px / 16px` | 副次情報と機械的な値 |
```css
--font-sans
--font-mono
```
別のfont sizeを増やす前に、配置、spacing、weight、muted colorでhierarchyを作る。textを`12px`未満へ縮小しない。
### Spacing / Shape
```css
--space-1 /* 4px */
--space-2 /* 8px */
--space-3 /* 12px */
--space-4 /* 16px */
--space-5 /* 24px */
--space-6 /* 32px */
--radius-soft
```
- groupingはspacingとalignmentを最初のcueとする。
- separationにはspacing、line、surface backgroundのいずれか一つをprimary cueとして選ぶ。
- 汎用的な`Card` primitiveは設けない。
- nested surfaceはparentと同じ境界表現を繰り返さない。
- shadowは通常flowから浮くmenu、popover、Tooltipなどの一時的overlayだけが使用できる。
- radiusはinteractive controlだけが使用できる。
### Motion
- motionは任意かつ短くし、意味の必須条件にしない。
- 繰り返しまたは連続animationではreduced-motion preferenceを尊重する。
## 4. Components / Patterns
実際のUIを、上のprinciple、grammar、tokenから組み立てる。完成pageを固定templateにしない。
### Sidebar
- Sidebarは現在位置と利用可能なscopeを一つのnavigation hierarchyとして示す。
- desktop幅は`clamp(220px, 20vw, 280px)`を基本とする。
- navigation contentだけをscrollさせ、fold controlはframe下部に残す。
- navigationとscope controlだけを置く。
- navigation linkはsidebar幅全体を使うflat rowとする。
- active stateは一つのcueと`aria-current="page"`で示す。
- iconだけでlabelを置き換えない。
- fold controlは一つだけ置き、現在stateに対応するaccessible nameを使う。
- narrow viewportでも同じSidebarの意味とnavigation hierarchyを維持する。
### 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環境でも同じ説明へ到達できるようにする。
### 汎用component pattern
- **Action**: `primary``secondary``text``destructive``disabled`を同じ高さ、padding、focus contractで表現する。
- **Form field**: label、control、constraintまたはhelp、field errorの順に配置する。
- **Status**: markerと短いtextを一組にする。
- **Feedback**: loading、empty、operation error、permission boundaryを別の状態として表現する。
- **Resource row**: keyまたはname、判断に必要なmetadata、attentionまたはstate、一つのdestinationを置く。
- **Comparable data**: 同じfieldを比較する場合だけtableを使う。
- **Key-value**: 一つのresourceの属性には`dl`を使う。
- **Disclosure**: secondary metadataやdiagnosticは具体的なlabelを持つ`details`などへ置く。
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
+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.
+53 -121
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,25 @@
--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);
--bevel-highlight: #fff;
--bevel-shadow: #000;
--bevel-face-width: 2px;
--shadow-overlay: 0 12px 32px oklch(0% 0 0 / 0.14);
--font-sans: "Gen Interface JP", sans-serif;
--font-mono: "IBM Plex Mono", monospace;
--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 +74,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 +95,22 @@
--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;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
}
a {
color: var(--accent);
}
h1,
h2,
h3,
@@ -104,127 +119,44 @@
dd {
overflow-wrap: anywhere;
}
h1,
h2,
h3 {
color: var(--text-strong);
margin-top: 0;
color: var(--text-strong);
}
p {
line-height: 1.55;
}
code,
pre {
color: var(--code);
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;
font-size: 0.85em;
}
.secondary-button:hover,
.inline-link:hover {
border-color: var(--line-strong);
background: var(--bg-raised);
: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);
}
}
@@ -231,7 +231,7 @@
}
.compaction-error {
color: var(--tui-error);
color: var(--danger);
}
.activity-summary {
@@ -245,7 +245,7 @@
}
.console-line.error .activity-summary {
color: var(--tui-error);
color: var(--danger);
}
.run-stats {
@@ -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;
@@ -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),
@@ -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,5 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import './sidebar.css';
type Props = {
@@ -43,4 +44,17 @@
{/if}
</button>
</div>
<BevelLine
class="sidebar-frame__divider sidebar-frame__divider--vertical"
direction="y"
length="100%"
decorative
/>
<BevelLine
class="sidebar-frame__divider sidebar-frame__divider--horizontal"
direction="x"
length="100%"
decorative
/>
</aside>
@@ -7,6 +7,7 @@
grid-column: 1;
grid-row: 1 / 3;
position: relative;
display: flex;
flex-direction: column;
width: clamp(220px, 20vw, 280px);
@@ -14,7 +15,20 @@
min-height: 0;
overflow: hidden;
padding-block: var(--space-4);
border-right: 1px solid var(--line);
}
.sidebar-frame__divider {
position: absolute;
z-index: 2;
pointer-events: none;
}
.sidebar-frame__divider--vertical {
inset-block: 0;
inset-inline-end: 0;
}
.sidebar-frame__divider--horizontal {
display: none;
inset-inline: 0;
inset-block-end: 0;
}
@media (prefers-color-scheme: dark) {
.sidebar-frame {
@@ -207,7 +221,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;
@@ -287,6 +301,12 @@
.workspace-menu-status.error {
color: var(--danger);
}
.section-state {
margin: var(--space-2) 0 0;
color: var(--text-muted);
font-size: 0.74rem;
line-height: 1.35;
}
.workspace-status {
margin: var(--space-1) 0 0;
color: var(--text-muted);
@@ -311,7 +331,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;
}
@@ -465,7 +485,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;
@@ -613,8 +633,12 @@
grid-column: 1;
grid-row: 1;
width: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.sidebar-frame__divider--vertical {
display: none;
}
.sidebar-frame__divider--horizontal {
display: block;
}
}
}
@@ -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,10 +67,10 @@
}
.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-weight: 800;
@@ -43,6 +78,14 @@
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);
}
@@ -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 {
@@ -288,7 +328,7 @@
.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 {
@@ -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-page {
@@ -354,7 +394,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,
@@ -450,7 +489,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,
@@ -493,22 +531,28 @@
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-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 {
@@ -689,7 +733,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 {
@@ -728,7 +771,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 {
@@ -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,
@@ -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;
@@ -104,10 +104,10 @@
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;
@@ -160,10 +160,10 @@
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);
@@ -247,12 +247,19 @@
gap: 0.25rem;
}
.ticket-risk-list span {
border: 1px solid #a97531;
border-radius: 999px;
color: #d3a55d;
padding: 0.1rem 0.35rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--warning);
font-size: 0.62rem;
}
.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);
@@ -282,13 +289,20 @@
font-size: 0.72rem;
}
.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;
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);
@@ -372,7 +386,7 @@
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,7 +405,7 @@
text-decoration: none;
}
.ticket-blocker-list a {
border-color: #a97531;
border-color: var(--warning);
}
.ticket-relations-list span,
.ticket-relations-list small,
@@ -9,7 +9,7 @@
.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;
@@ -74,13 +74,22 @@
}
.workspace-card-heading > span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.5rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.7rem;
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);
@@ -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;
@@ -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;
@@ -168,68 +224,75 @@
}
.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-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;
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;
letter-spacing: 0.08em;
@@ -0,0 +1,348 @@
<script lang="ts">
import type { Snippet } from 'svelte';
export type BevelProfile = 'edge' | 'ridge';
export type BevelDepth = 'raised' | 'inset';
type Props = {
children: Snippet<[]>;
profile?: BevelProfile;
depth?: BevelDepth;
top?: boolean;
right?: boolean;
bottom?: boolean;
left?: boolean;
fill?: boolean;
invalid?: boolean;
pressed?: boolean;
as?: 'span' | 'div';
class?: string;
};
let {
children,
profile = 'edge',
depth = 'raised',
top = true,
right = true,
bottom = true,
left = true,
fill = false,
invalid = false,
pressed = false,
as = 'span',
class: className = '',
}: Props = $props();
</script>
<svelte:element
this={as}
class={`bevel ${fill ? 'bevel--fill' : ''} ${className}`.trim()}
data-profile={profile}
data-depth={depth}
data-top={top ? 'true' : 'false'}
data-right={right ? 'true' : 'false'}
data-bottom={bottom ? 'true' : 'false'}
data-left={left ? 'true' : 'false'}
data-invalid={invalid || undefined}
data-pressed={pressed || undefined}
>
{@render children()}
</svelte:element>
<style>
.bevel {
--bevel-light: 315deg;
--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);
--bevel-top-inset: var(--bevel-face-width);
--bevel-right-inset: var(--bevel-face-width);
--bevel-bottom-inset: var(--bevel-face-width);
--bevel-left-inset: var(--bevel-face-width);
position: relative;
isolation: isolate;
display: inline-grid;
min-width: 0;
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;
--bevel-top-inset: 0px;
}
.bevel[data-right='false'] {
--bevel-top-right-radius: 0px;
--bevel-bottom-right-radius: 0px;
--bevel-right-inset: 0px;
}
.bevel[data-bottom='false'] {
--bevel-bottom-right-radius: 0px;
--bevel-bottom-left-radius: 0px;
--bevel-bottom-inset: 0px;
}
.bevel[data-left='false'] {
--bevel-bottom-left-radius: 0px;
--bevel-top-left-radius: 0px;
--bevel-left-inset: 0px;
}
.bevel--fill {
width: 100%;
}
.bevel > :global(*) {
position: relative;
z-index: 1;
min-width: 0;
border-radius: inherit;
}
.bevel > :global(:is(button, input, select, textarea)) {
border-color: transparent;
}
.bevel::before,
.bevel[data-profile='ridge']::after {
--_W: var(--bevel-face-width);
--_top-w: var(--_W);
--_right-w: var(--_W);
--_bottom-w: var(--_W);
--_left-w: var(--_W);
content: '';
position: absolute;
z-index: 2;
inset: 0;
box-sizing: border-box;
border-style: solid;
border-color: var(--line-strong);
border-width: var(--_top-w) var(--_right-w) var(--_bottom-w) var(--_left-w);
border-radius: inherit;
pointer-events: none;
}
.bevel[data-top='false']::before,
.bevel[data-top='false'][data-profile='ridge']::after {
--_top-w: 0px;
}
.bevel[data-right='false']::before,
.bevel[data-right='false'][data-profile='ridge']::after {
--_right-w: 0px;
}
.bevel[data-bottom='false']::before,
.bevel[data-bottom='false'][data-profile='ridge']::after {
--_bottom-w: 0px;
}
.bevel[data-left='false']::before,
.bevel[data-left='false'][data-profile='ridge']::after {
--_left-w: 0px;
}
.bevel[data-profile='edge'][data-depth='inset']::before {
border-color: var(--line);
}
.bevel[data-profile='ridge'][data-depth='inset']::before {
border-color: var(--line);
}
.bevel[data-profile='ridge']::after {
inset:
var(--bevel-top-inset)
var(--bevel-right-inset)
var(--bevel-bottom-inset)
var(--bevel-left-inset);
border-color: var(--line);
border-radius:
max(0px, calc(var(--bevel-top-left-radius) - var(--bevel-face-width)))
max(0px, calc(var(--bevel-top-right-radius) - var(--bevel-face-width)))
max(0px, calc(var(--bevel-bottom-right-radius) - var(--bevel-face-width)))
max(0px, calc(var(--bevel-bottom-left-radius) - var(--bevel-face-width)));
}
.bevel[data-profile='ridge'][data-depth='inset']::after {
border-color: var(--line-strong);
}
.bevel[data-invalid='true'],
.bevel:has(> :global([aria-invalid='true'])) {
box-shadow: 0 0 0 1px var(--danger);
}
.bevel:has(> :global(:is(:disabled, [aria-disabled='true']))) {
opacity: 0.58;
}
@supports
(color: color-mix(in oklab, white 50%, black)) and
(width: calc(cos(0deg) * 1px)) and
((mask-composite: exclude) or (-webkit-mask-composite: xor)) {
.bevel {
--_s0: var(--bevel-highlight);
--_s1: color-mix(in oklab, var(--bevel-highlight) 96.5926%, var(--bevel-shadow));
--_s2: color-mix(in oklab, var(--bevel-highlight) 86.6025%, var(--bevel-shadow));
--_s3: color-mix(in oklab, var(--bevel-highlight) 70.7107%, var(--bevel-shadow));
--_s4: color-mix(in oklab, var(--bevel-highlight) 50%, var(--bevel-shadow));
--_s5: color-mix(in oklab, var(--bevel-highlight) 25.8819%, var(--bevel-shadow));
--_s6: var(--bevel-shadow);
--_shade:
var(--_s0) 0deg,
var(--_s1) 15deg,
var(--_s2) 30deg,
var(--_s3) 45deg,
var(--_s4) 60deg,
var(--_s5) 75deg,
var(--_s6) 90deg,
var(--_s6) 270deg,
var(--_s5) 285deg,
var(--_s4) 300deg,
var(--_s3) 315deg,
var(--_s2) 330deg,
var(--_s1) 345deg,
var(--_s0) 360deg;
}
.bevel::before,
.bevel[data-profile='ridge']::after {
--_W: var(--bevel-face-width);
--_RTL: var(--bevel-top-left-radius);
--_RTR: var(--bevel-top-right-radius);
--_RBR: var(--bevel-bottom-right-radius);
--_RBL: var(--bevel-bottom-left-radius);
--_L: var(--bevel-light);
--_top: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(0deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_right: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(90deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_bottom: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(180deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_left: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(270deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
border-style: solid;
border-color: transparent;
border-width: var(--_top-w) var(--_right-w) var(--_bottom-w) var(--_left-w);
border-radius: var(--_RTL) var(--_RTR) var(--_RBR) var(--_RBL);
background:
conic-gradient(from var(--_L) at 100% 100%, var(--_shade)) 0 0 / var(--_RTL) var(--_RTL) no-repeat,
conic-gradient(from var(--_L) at 0 100%, var(--_shade)) 100% 0 / var(--_RTR) var(--_RTR) no-repeat,
conic-gradient(from var(--_L) at 0 0, var(--_shade)) 100% 100% / var(--_RBR) var(--_RBR) no-repeat,
conic-gradient(from var(--_L) at 100% 0, var(--_shade)) 0 100% / var(--_RBL) var(--_RBL) no-repeat,
linear-gradient(var(--_top) 0 0) var(--_RTL) 0 /
calc(100% - var(--_RTL) - var(--_RTR)) var(--_top-w) no-repeat,
linear-gradient(var(--_bottom) 0 0) var(--_RBL) 100% /
calc(100% - var(--_RBL) - var(--_RBR)) var(--_bottom-w) no-repeat,
linear-gradient(var(--_left) 0 0) 0 var(--_RTL) / var(--_left-w)
calc(100% - var(--_RTL) - var(--_RBL)) no-repeat,
linear-gradient(var(--_right) 0 0) 100% var(--_RTR) / var(--_right-w)
calc(100% - var(--_RTR) - var(--_RBR)) no-repeat;
background-origin: border-box;
background-clip: border-box;
box-shadow: none;
-webkit-mask:
linear-gradient(#000 0 0) padding-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) padding-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
.bevel[data-depth='inset']::before,
.bevel[data-profile='edge'][data-pressed='true']::before,
.bevel[data-profile='edge'][data-depth='raised']:has(
> :global(:is(button, [role='button']):active)
)::before,
.bevel[data-profile='edge'][data-depth='raised']:has(
> :global([aria-pressed='true'])
)::before {
--_L: calc(var(--bevel-light) + 180deg);
}
.bevel[data-profile='ridge']::before {
--_W: var(--bevel-face-width);
}
.bevel[data-profile='ridge']::after {
--_W: var(--bevel-face-width);
--_RTL: max(0px, calc(var(--bevel-top-left-radius) - var(--bevel-face-width)));
--_RTR: max(0px, calc(var(--bevel-top-right-radius) - var(--bevel-face-width)));
--_RBR: max(0px, calc(var(--bevel-bottom-right-radius) - var(--bevel-face-width)));
--_RBL: max(0px, calc(var(--bevel-bottom-left-radius) - var(--bevel-face-width)));
inset:
var(--bevel-top-inset)
var(--bevel-right-inset)
var(--bevel-bottom-inset)
var(--bevel-left-inset);
}
.bevel[data-profile='ridge'][data-depth='raised']::after {
--_L: calc(var(--bevel-light) + 180deg);
}
.bevel[data-profile='ridge'][data-depth='inset']::after {
--_L: var(--bevel-light);
}
}
@media (forced-colors: active) {
.bevel {
forced-color-adjust: auto;
}
.bevel::before,
.bevel[data-profile='ridge']::after {
border-color: ButtonText;
background: none;
box-shadow: none;
-webkit-mask: none;
mask: none;
}
.bevel[data-invalid='true'],
.bevel:has(> :global([aria-invalid='true'])) {
outline: 2px solid Mark;
outline-offset: 1px;
box-shadow: none;
}
}
@media print {
.bevel::before,
.bevel[data-profile='ridge']::after {
border-color: currentColor;
background: none;
-webkit-mask: none;
mask: none;
}
}
</style>
@@ -0,0 +1,146 @@
<script lang="ts">
export type BevelLineDirection = 'x' | 'y';
export type BevelLineDepth = 'raised' | 'inset';
type Props = {
direction?: BevelLineDirection;
length?: string;
depth?: BevelLineDepth;
decorative?: boolean;
as?: 'span' | 'div';
class?: string;
};
let {
direction = 'x',
length = '100%',
depth = 'raised',
decorative = false,
as = 'span',
class: className = '',
}: Props = $props();
</script>
<svelte:element
this={as}
class={`bevel-line ${className}`.trim()}
data-direction={direction}
data-depth={depth}
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 {
--bevel-light: 315deg;
--bevel-line-face-width: var(--bevel-face-width);
--bevel-line-width: calc(var(--bevel-line-face-width) * 2);
display: block;
flex: 0 0 auto;
overflow: hidden;
border-radius: 999px;
background: var(--line-strong);
pointer-events: none;
}
.bevel-line[data-direction='x'] {
height: var(--bevel-line-width);
}
.bevel-line[data-direction='y'] {
width: var(--bevel-line-width);
}
@supports
(color: color-mix(in oklab, white 50%, black)) and
(width: calc(cos(0deg) * 1px)) {
.bevel-line {
--_L: var(--bevel-light);
--_s0: var(--bevel-highlight);
--_s1: color-mix(in oklab, var(--bevel-highlight) 96.5926%, var(--bevel-shadow));
--_s2: color-mix(in oklab, var(--bevel-highlight) 86.6025%, var(--bevel-shadow));
--_s3: color-mix(in oklab, var(--bevel-highlight) 70.7107%, var(--bevel-shadow));
--_s4: color-mix(in oklab, var(--bevel-highlight) 50%, var(--bevel-shadow));
--_s5: color-mix(in oklab, var(--bevel-highlight) 25.8819%, var(--bevel-shadow));
--_s6: var(--bevel-shadow);
--_shade:
var(--_s0) 0deg,
var(--_s1) 15deg,
var(--_s2) 30deg,
var(--_s3) 45deg,
var(--_s4) 60deg,
var(--_s5) 75deg,
var(--_s6) 90deg,
var(--_s6) 270deg,
var(--_s5) 285deg,
var(--_s4) 300deg,
var(--_s3) 315deg,
var(--_s2) 330deg,
var(--_s1) 345deg,
var(--_s0) 360deg;
--_top: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(0deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_right: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(90deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_bottom: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(180deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_left: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(270deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
}
.bevel-line[data-depth='inset'] {
--_L: calc(var(--bevel-light) + 180deg);
}
.bevel-line[data-direction='x'] {
background:
conic-gradient(from var(--_L) at 100% 50%, var(--_shade)) 0 0 /
var(--bevel-line-face-width) var(--bevel-line-width) no-repeat,
conic-gradient(from var(--_L) at 0 50%, var(--_shade)) 100% 0 /
var(--bevel-line-face-width) var(--bevel-line-width) no-repeat,
linear-gradient(to bottom, var(--_top) 0 50%, var(--_bottom) 50% 100%)
var(--bevel-line-face-width) 0 /
calc(100% - var(--bevel-line-width)) 100% no-repeat;
}
.bevel-line[data-direction='y'] {
background:
conic-gradient(from var(--_L) at 50% 100%, var(--_shade)) 0 0 /
var(--bevel-line-width) var(--bevel-line-face-width) no-repeat,
conic-gradient(from var(--_L) at 50% 0, var(--_shade)) 0 100% /
var(--bevel-line-width) var(--bevel-line-face-width) no-repeat,
linear-gradient(to right, var(--_left) 0 50%, var(--_right) 50% 100%)
0 var(--bevel-line-face-width) / 100%
calc(100% - var(--bevel-line-width)) no-repeat;
}
}
@media (forced-colors: active) {
.bevel-line {
background: CanvasText;
forced-color-adjust: auto;
}
}
@media print {
.bevel-line {
background: 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: 12px;
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>
+13 -1
View File
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { setContext } from 'svelte';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.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';
@@ -46,6 +47,12 @@
</svg>
</a>
</nav>
<BevelLine
class="app-shell__topbar-divider"
direction="x"
length="100%"
decorative
/>
</header>
<main class="app-shell__main">
{@render children()}
@@ -77,11 +84,16 @@
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);
}
:global(.app-shell__topbar-divider) {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
}
.app-shell__topbar-location {
flex: 1 1 auto;
min-width: 0;
@@ -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 profile="edge" depth="raised">
<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="ridge-matrix">
<Bevel as="div" profile="ridge" depth="raised" fill>
<div class="tactile-group" role="group" aria-label="Raised validation controls">
<span>
<strong>Raised ridge</strong>
<small>3 checks passed at 14:32</small>
</span>
<Bevel pressed>
<button class="compact-control" type="button" aria-pressed="true">Live</button>
</Bevel>
</div>
</Bevel>
<Bevel as="div" profile="ridge" depth="inset" fill>
<div class="tactile-group" role="group" aria-label="Inset ridge controls">
<span>
<strong>Inset ridge</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" profile="ridge" depth="inset" 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">Raised · x · 100%</span>
<BevelLine direction="x" length="100%" />
<span class="line-sample__label">Inset · x · 160px</span>
<BevelLine direction="x" length="160px" depth="inset" />
</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 profile="edge" depth="inset" fill>
<input name="runtime-label" value="Remote Linux runner" />
</Bevel>
</label>
<label class="form-field">
<span>Endpoint</span>
<Bevel profile="edge" depth="inset" 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 profile="edge" depth="inset" 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,680 @@
@layer components {
.design-lab-header {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: 14px;
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: 12px;
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: 14px;
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: 14px;
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 12px/1 var(--font-mono);
}
.ridge-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;
background: var(--bg-raised);
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: 12px;
}
.line-sample__label {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
}
.status {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text);
font-size: 13px;
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: 14px;
}
.feedback p {
margin: 2px 0 0;
color: var(--text-muted);
font-size: 13px;
line-height: 18px;
}
.feedback--error {
border-left-color: var(--danger);
}
.feedback--permission {
border-left-color: var(--warning);
}
.resource-list {
min-width: 0;
}
.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: 12px;
line-height: 16px;
}
.resource-row__meta {
color: var(--text-muted);
font-size: 12px;
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: 12px;
font-weight: 600;
line-height: 16px;
}
.showroom tbody th {
max-width: 360px;
color: var(--text-strong);
font-size: 14px;
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: 12px;
font-weight: 400;
}
.column-help {
color: inherit;
text-decoration: underline dotted;
text-underline-offset: 0.2em;
cursor: help;
}
.key-value,
.technical-values {
margin: 0;
}
.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: 12px;
font-weight: 600;
line-height: 18px;
}
.key-value dd,
.technical-values dd {
min-width: 0;
margin: 0;
color: var(--text-strong);
font-size: 13px;
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: 12px;
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: 24px;
font-weight: 650;
line-height: 32px;
}
.type-body {
color: var(--text);
font-size: 14px;
line-height: 20px;
}
.type-meta {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
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);
}
.form-grid {
display: grid;
gap: var(--space-5);
}
.form-field {
display: grid;
gap: var(--space-2);
color: var(--text-strong);
font-size: 13px;
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: 12px;
font-weight: 400;
line-height: 16px;
}
.field-error {
color: var(--danger);
font-size: 12px;
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: 13px;
font-weight: 600;
}
.permission-read-view {
display: grid;
gap: var(--space-3);
}
.permission-read-view p {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
@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,
.ridge-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;
@@ -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;
}
@@ -275,9 +275,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;
}
+136
View File
@@ -0,0 +1,136 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Bevel keeps depth visual while semantic controls remain 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 [
"export type BevelProfile = 'edge' | 'ridge'",
"export type BevelDepth = 'raised' | 'inset'",
"children: Snippet<[]>",
"top?: boolean",
"right?: boolean",
"bottom?: boolean",
"left?: boolean",
"pointer-events: none",
"data-pressed={pressed || undefined}",
"aria-invalid='true'",
"forced-colors: active",
"mask-composite: exclude",
]
) {
assert(source.includes(contract), `Bevel must preserve ${contract}`);
}
assert(
source.includes("@supports") &&
source.includes("border-style: solid") &&
source.includes(
"border-width: var(--_top-w) var(--_right-w) var(--_bottom-w) var(--_left-w)",
),
"Bevel must retain a side-selective plain border fallback around the enhanced edge",
);
assert(
source.includes("calc(max(cos(180deg - var(--_L)), 0) * 100%)") &&
source.includes("var(--_s6) 90deg") &&
source.includes("var(--_s6) 270deg") &&
!source.includes("+ 1) * 50%"),
"Bevel must clamp unlit faces to the configured shadow without ambient light",
);
assert(
appCss.includes("--bevel-face-width: 2px") &&
source.includes("--_W: var(--bevel-face-width)") &&
source.includes("inset: var(--bevel-face-width)") &&
!source.includes("BevelSize") &&
!source.includes("data-size"),
"Bevel must use the project-wide 2px edge and compose ridge from two 2px faces",
);
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(".bevel[data-top='false'] {") &&
source.includes("--bevel-top-left-radius: 0px") &&
source.includes("--bevel-top-right-radius: 0px") &&
source.includes(".bevel[data-right='false'] {") &&
source.includes("--bevel-bottom-right-radius: 0px") &&
source.includes(".bevel[data-bottom='false'] {") &&
source.includes("--bevel-bottom-left-radius: 0px") &&
source.includes(".bevel[data-left='false'] {") &&
source.includes("--bevel-top-inset: 0px") &&
source.includes("--bevel-right-inset: 0px") &&
source.includes("--bevel-bottom-inset: 0px") &&
source.includes("--bevel-left-inset: 0px"),
"Bevel must draw selected sides and round only corners shared by two enabled sides",
);
assert(
source.includes("data-profile={profile}") &&
source.includes("[data-profile='ridge'][data-depth='raised']::after") &&
source.includes("[data-profile='ridge'][data-depth='inset']::after") &&
!source.includes("data-depth='ridge'"),
"Bevel must keep edge/ridge profile orthogonal to raised/inset depth",
);
assert(
appCss.includes("--bevel-highlight: #fff") &&
appCss.includes("--bevel-shadow: #000") &&
!source.includes("surface") &&
!source.includes("tone") &&
!source.includes("background-color:"),
"Bevel must use the shared highlight and shadow palette without owning a surface tone",
);
assert(
designLanguage.includes("Bevelはedge") &&
designLanguage.includes("lightingだけを所有する") &&
designLanguage.includes("text/content areaだけとする"),
"The design language must prohibit Bevel surface colors outside the child content area",
);
assert(
source.includes("button, input, select, textarea"),
"Bevel must leave native semantic controls inside the visual wrapper",
);
});
Deno.test("Workspace showroom exercises raised inset and ridge edges", 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,
),
);
assert(
workspaceSource.includes('profile="edge" depth="raised"') &&
workspaceSource.includes('profile="ridge" depth="raised"') &&
workspaceSource.includes('profile="ridge" depth="inset"') &&
workspaceSource.includes("right={false} bottom={false}") &&
workspaceSource.includes("top={false} left={false}") &&
workspaceSource.includes("pressed"),
"Workspace showroom must demonstrate edge and ridge depths with full and adjacent side selections",
);
assert(
settingsSource.includes('profile="edge" depth="inset"') &&
settingsSource.includes('aria-invalid="true"'),
"Settings showroom must demonstrate inset and invalid fields",
);
});
@@ -0,0 +1,112 @@
// @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),
);
const appCss = await Deno.readTextFile(
new URL("../src/app.css", 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(
appCss.includes("--bevel-face-width: 2px") &&
source.includes("--bevel-line-face-width: var(--bevel-face-width)") &&
source.includes(
"--bevel-line-width: calc(var(--bevel-line-face-width) * 2)",
) &&
!source.includes("BevelLineWeight") &&
!source.includes("data-weight"),
"BevelLine must compose a fixed 4px ridge from two project-wide 2px faces",
);
assert(
source.includes("background: var(--line-strong)") &&
source.includes("@supports") &&
source.includes("linear-gradient(to bottom") &&
source.includes("linear-gradient(to right"),
"BevelLine must retain a plain fallback and directional enhanced edges",
);
assert(
source.includes("var(--bevel-highlight)") &&
source.includes("var(--bevel-shadow)") &&
!source.includes("surface"),
"BevelLine must use the shared highlight and shadow palette without a surface color",
);
assert(
source.includes("calc(max(cos(180deg - var(--_L)), 0) * 100%)") &&
source.includes("var(--_s6) 90deg") &&
source.includes("var(--_s6) 270deg") &&
!source.includes("+ 1) * 50%"),
"BevelLine must clamp unlit faces to the configured shadow without ambient light",
);
});
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" depth="inset"') &&
!source.includes("weight="),
"Workspace showroom must demonstrate horizontal, vertical, and inset BevelLine states",
);
});
Deno.test("Workspace shell and showroom use BevelLine for structural separators", 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,
),
);
assert(
rootLayout.includes("app-shell__topbar-divider") &&
sidebarFrame.includes("sidebar-frame__divider--vertical") &&
sidebarFrame.includes("sidebar-frame__divider--horizontal"),
"Workspace shell must render structural dividers through BevelLine",
);
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 separators must not fall back to one-sided solid borders",
);
});
@@ -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",
);
});