feat: establish workspace web design language
This commit is contained in:
+10
-7
@@ -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
|
||||
|
||||
|
||||
@@ -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を発生させる。
|
||||
@@ -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へ書かない。
|
||||
@@ -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は、対象と結果を明示してから実行する。
|
||||
@@ -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の関係性を検査する。三者は補完関係であり、どれか一つで他を代替しない。
|
||||
@@ -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 implementer’s 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,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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
+35
@@ -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;
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user