feat: refine workspace bevel hierarchy

This commit is contained in:
2026-09-17 04:09:12 +09:00
parent d124b03fc8
commit 272cd1bb03
9 changed files with 294 additions and 208 deletions
+85 -78
View File
@@ -2,80 +2,48 @@
この文書は、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とする。 この文書は、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となる章を参照する。 一つの規則は、以下の各章のうち最も具体的な一箇所だけをauthorityとする。別の章で同じ禁止や手段を再掲せず、必要な場合はauthorityとなる章を参照する。
この文書はrendered resultが満たす設計規則を定義する。実装者自身がbefore/afterをcaptureして完了判定する手順は、[`visual-review.md`](visual-review.md)をauthorityとする。
## 1. Principles ## 1. Principles
なぜこの設計にするかを定義する。個別の表示規則に迷った場合は、この章へ戻って判断する。 この章は具体的な見た目や配置を定めない。複数の規則や要求が競合したときに、どちらを優先するか判断するための原理だけを定める。
### 基本思想 ### Task before explanation
- 作業ツールとして、シンプルで直感的なUX 現在のtaskと判断を、網羅的な説明、metadataの露出、装飾より優先する。UIはユーザーが次の有効な操作へ到達するまでの距離を短くする。
- UIのために曖昧な情報を推測しない。データを正直に表示し、操作を見通せるようにする
- 状態・scope・操作を、装飾ではなく構造で読ませ、情報量を増やしても騒がしくならない
- 説明的に見せるのではなく、配置と関係で理解させる
### 知覚原則 ### Meaning before decoration
size、weight、color、spacing、line、background、motionを使うときは、次のどの知覚を成立させるためか説明できなければならない。説明できない視覚表現は追加しない。 視覚上の差は、情報の重要度、関係、操作可能性、または状態の違いを伝えるためにだけ使う。意味を説明できない視覚表現は追加しない。
#### Hierarchy — 何が重要か ### Authority before convenience
- 情報は、primary、secondary、technicalの順に読む構造にする。 UIの都合でdata、state、permission、利用可能なactionを推測しない。表示とaffordanceは、現在のauthorityが確定している内容に一致させる。
- 重要度は、最初に配置順、次にsize、weight、contrastで示す。colorや装飾だけで重要度を作らない。
- 同じscopeに最も強いheading、filled action、attention表現を複数置かない。
- primary contentと、その判断に必要なstateまたはactionを初期viewportへ置く。
- metadataと補助操作は、主要なidentityやcurrent stateより強く見せない。
#### Grouping — 何と何が同じグループか ### Complexity follows need
- 同じ判断や操作に使う情報は、一つのまとまりとして知覚できるようにする 複雑さを消すのではなく、判断に必要になる位置と時点で開示する。初期表示では、現在のtaskと競合する詳細を前面へ出さない
- 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 ## 2. Semantics / Grammar
何を、どの関係と意味で表現するかを定義する。 この章は、Workspace Webで情報、操作、状態を何として扱い、どう表現するかを定る。
### 情報階層 ### Information structure
#### Priority and focus
- 情報はprimary、secondary、technicalの順に読む構造にする。
- 一つのscopeでは、primary resource、current attention、next actionのいずれか一つを最初のfocusにする。
- 重要度は最初に配置順、次にsize、weight、contrastで示す。colorや装飾だけで重要度を作らない。
- 同じscopeに最も強いheading、filled action、attention表現を複数置かない。
- metadataと補助操作は、主要なidentityやcurrent stateより強く見せない。
- accent、filled action、強いcontrast、motionを複数箇所で競合させない。
- normal stateは静かに保ち、attention表現は現在判断が必要な例外へ限定する。
- focusを作るために周囲の情報を過度に薄くしたり、小さくしたりしない。
#### Page and section structure
route titleはshell headerとmain contentを通じて一度だけ表示する。 route titleはshell headerとmain contentを通じて一度だけ表示する。
@@ -85,9 +53,18 @@ route titleはshell headerとmain contentを通じて一度だけ表示する。
- route分類、breadcrumb、header titleをeyebrow、heading、keyで繰り返さない。 - route分類、breadcrumb、header titleをeyebrow、heading、keyで繰り返さない。
- subtitleとledeは原則として置かない。 - subtitleとledeは原則として置かない。
一つのsectionは、一つの目的、ownership、または読み順を持つ。目的が異なる内容を同じsectionへ入れず、同じ目的の内容を見た目だけで複数sectionへ分けない。Section間とsection内の関係は`Principles`のGroupingとSeparationに従い、具体的なcueは`Primitives / Tokens`だけから選ぶ。 一つのsectionは、一つの目的、ownership、または読み順を持つ。目的が異なる内容を同じsectionへ入れず、同じ目的の内容を見た目だけで複数sectionへ分けない。Section間とsection内の関係はのGrouping and separationに従い、具体的なcueは`Primitives / Tokens`だけから選ぶ。
### Metadata #### Grouping and separation
- 同じ判断や操作に使う情報は、一つのまとまりとして知覚できるようにする。
- groupの単位は、共通のownership、behavior、またはユーザーが行う一つの判断から決める。data sourceやDTOが同じという理由だけでgroup化しない。
- label、value、help、error、actionの対応関係を、別の説明を読まなくても追えるようにする。
- 異なるgroupの境界を知覚でき、その強さが意味上の距離と一致するようにする。
- 境界は何と何を分け、どのgroupの開始または終端を示すか説明できるようにする。
- 一つのgroupまたは境界へ複数の視覚cueを重ねず、異なるhierarchyの境界をすべて同じ強さで表現しない。
#### Metadata
主要表示に置くのは、identity、healthまたはattention、比較、次の操作に必要な値だけとする。 主要表示に置くのは、identity、healthまたはattention、比較、次の操作に必要な値だけとする。
@@ -102,7 +79,7 @@ route titleはshell headerとmain contentを通じて一度だけ表示する。
機械的な値にはmonospace familyを使う。DTOに値が存在することだけを理由に表示しない。 機械的な値にはmonospace familyを使う。DTOに値が存在することだけを理由に表示しない。
### 段階的な開示 #### 段階的な開示
- primary viewにはidentity、current state、attention、次の有効なactionを表示する。 - primary viewにはidentity、current state、attention、次の有効なactionを表示する。
- 副次的な運用情報はplain section、side panel、またはlabel付き`details`へ置く。 - 副次的な運用情報はplain section、side panel、またはlabel付き`details`へ置く。
@@ -111,7 +88,25 @@ route titleはshell headerとmain contentを通じて一度だけ表示する。
- create formとedit formは明示的なactionの後に表示する。 - create formとedit formは明示的なactionの後に表示する。
- blocking error、permission requirement、validation constraint、unsaved changeを閉じたdisclosureへ隠さない。 - blocking error、permission requirement、validation constraint、unsaved changeを閉じたdisclosureへ隠さない。
### Authority、権限、操作 ### Interaction and state
#### 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 representation
- 権威を持つdata sourceのstateをそのまま表示し、UIの都合でunknown、stale、unavailableを推測で埋めない。
- stateは対象resourceまたはoperationの近くに置き、何のstateかを位置関係で判別できるようにする。
- stateはmarkerとtextで示し、colorを補助に使う。
- loading、empty、error、permission denied、unavailable、staleを別の状態として扱う。
- 非同期operationでは、開始、処理中、完了、失敗を見通せるようにする。
#### Authority、権限、操作
- permissionはcontrolのenabled状態だけでなく、表示構成そのものへ反映する。 - permissionはcontrolのenabled状態だけでなく、表示構成そのものへ反映する。
- readは可能だがmutationできない場合、有用なread viewだけを構成する。 - readは可能だがmutationできない場合、有用なread viewだけを構成する。
@@ -121,7 +116,7 @@ route titleはshell headerとmain contentを通じて一度だけ表示する。
- destructive actionには対象と結果を明記し、復元できない場合は意図的なconfirmationを要求する。 - destructive actionには対象と結果を明記し、復元できない場合は意図的なconfirmationを要求する。
- 同じscopeのfilled primary actionは一つだけとする。 - 同じscopeのfilled primary actionは一つだけとする。
### Loading、empty、error、unavailable loading、empty、error、unavailableは同じfallbackへまとめず、それぞれ次の表現を使う。
#### Loading #### Loading
@@ -148,7 +143,9 @@ route titleはshell headerとmain contentを通じて一度だけ表示する。
resource unavailable、capability missing、permission deniedを別の状態として扱う。`403`をempty listとして表示したり、別scopeへredirectしたりしない。 resource unavailable、capability missing、permission deniedを別の状態として扱う。`403`をempty listとして表示したり、別scopeへredirectしたりしない。
### 文言 ## 3. Language / Copy
UI copyの語彙、長さ、説明責任を定める。
- 具体的な名詞と動詞を使う。 - 具体的な名詞と動詞を使う。
- identifier、API名、type名は正確性が必要な場合に原文を維持する。 - identifier、API名、type名は正確性が必要な場合に原文を維持する。
@@ -157,6 +154,10 @@ resource unavailable、capability missing、permission deniedを別の状態と
- empty copyとstatus copyは短く、事実を直接書く。 - empty copyとstatus copyは短く、事実を直接書く。
- buttonは操作を表す。`Submit``OK`、装飾的なcategory labelを避ける。 - buttonは操作を表す。`Submit``OK`、装飾的なcategory labelを避ける。
## 4. Cross-cutting requirements
viewport、input modality、支援技術にかかわらず、すべての表現とcomponentが満たす要件を定める。
### Responsive ### Responsive
- page全体にhorizontal scrollを発生させない。 - page全体にhorizontal scrollを発生させない。
@@ -171,14 +172,14 @@ resource unavailable、capability missing、permission deniedを別の状態と
- semantic elementを使う。 - semantic elementを使う。
- すべてのcontrolに明示的なaccessible nameを与える。 - すべてのcontrolに明示的なaccessible nameを与える。
- keyboard focusを常に見えるようにする。 - 視覚的なfocusとkeyboard focusを一致させ、常に見えるようにする。
- tab orderを画面上の順序と作業順に合わせる。 - tab orderを画面上の順序と作業順に合わせる。
- icon-only controlには具体的な`aria-label`を付ける。 - icon-only controlには具体的な`aria-label`を付ける。
- stateはcolor以外のtextまたはsemantic distinctionを持つ。 - stateはcolor以外のtextまたはsemantic distinctionを持つ。
- 必要なhorizontal scroll regionはfocus可能にし、accessible nameを付ける。 - 必要なhorizontal scroll regionはfocus可能にし、accessible nameを付ける。
- lightとdarkで同じsemantic tokenを使う。 - lightとdarkで同じsemantic tokenを使う。
## 3. Primitives / Tokens ## 5. Primitives / Tokens
色、文字、間隔、形、motionの最小単位を定義する。 色、文字、間隔、形、motionの最小単位を定義する。
@@ -204,9 +205,6 @@ colorはCSS custom propertyをauthorityとし、通常のWorkspace colorはOKLCH
--danger --danger
--interactive-hover --interactive-hover
--interactive-selected --interactive-selected
--bevel-highlight
--bevel-shadow
--bevel-face-width
--shadow-overlay --shadow-overlay
``` ```
@@ -214,11 +212,6 @@ colorはCSS custom propertyをauthorityとし、通常のWorkspace colorはOKLCH
- muted textは新しいhueを増やす前にlightnessとchromaを下げる。 - muted textは新しいhueを増やす前にlightnessとchromaを下げる。
- accentとstatus colorはstate、focus、navigationへ限定する。 - accentとstatus colorはstate、focus、navigationへ限定する。
- componentへraw colorを追加せず、semantic tokenを使う。 - 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を作らない。 - 意味が重なるtoken aliasを作らない。
ConsoleとterminalのANSI paletteは専用tokenを使い、通常のstatusやformへ流用しない。 ConsoleとterminalのANSI paletteは専用tokenを使い、通常のstatusやformへ流用しない。
@@ -262,15 +255,29 @@ ConsoleとterminalのANSI paletteは専用tokenを使い、通常のstatusやfor
- motionは任意かつ短くし、意味の必須条件にしない。 - motionは任意かつ短くし、意味の必須条件にしない。
- 繰り返しまたは連続animationではreduced-motion preferenceを尊重する。 - 繰り返しまたは連続animationではreduced-motion preferenceを尊重する。
## 4. Components / Patterns ## 6. Components / Patterns
実際のUIを、上のprinciple、grammar、tokenから組み立てる。完成pageを固定templateにしない。 実際のUIを、上のprinciple、grammar、communication、cross-cutting requirement、tokenから組み立てる。完成pageを固定templateにしない。
### Bevel / Structural edge
- raised edgeは、周囲より前にあるcontrolまたはsurfaceを表す場合に使う。
- inset edgeは、入力を受け取る領域または周囲より奥にあるsurfaceを表す場合に使う。
- ridgeとgrooveは構造上の境界を表すために使い、status、attention、単なる装飾には使わない。
- spacingやalignmentだけで関係を十分に示せる場合、Bevelを追加しない。
- Bevel自体へbackgroundやsurface toneを与えず、必要なbackgroundは内側のsemantic childが持つtext/content areaへ適用する。
- 隣接するsurfaceでは、実際に境界となる辺だけを見せ、存在しない境界やcornerを装飾目的で追加しない。
- standaloneな閉じた領域は、領域自体をBevelで囲う。ページ全体を分割するHeaderとSidebarはclosed boxとして隣接させず、DesktopではHeaderのbottom edgeとSidebarのright edgeだけを有効にして内部境界を示す。viewport外周に重なるtopleft edgeは描画しない。
- `BevelLine`は開いた領域内の構造separatorだけに使う。同じlayerではheading、本文、Lineの端を揃え、Lineだけに端方向のpaddingやmarginを追加しない。
- 内側のlayerを分離するときは、親layoutがそのlayer全体へinline方向の余白を与え、content、row、Lineを一緒に内側へ移す。Lineだけを短くして階層を表現しない。
- 外周border、focus ring、status marker、tableの意味的なgridはBevelや`BevelLine`へ置き換えない。
### Sidebar ### Sidebar
- Sidebarは現在位置と利用可能なscopeを一つのnavigation hierarchyとして示す。 - Sidebarは現在位置と利用可能なscopeを一つのnavigation hierarchyとして示す。
- desktop幅は`clamp(220px, 20vw, 280px)`を基本とする。 - desktop幅は`clamp(220px, 20vw, 280px)`を基本とする。
- navigation contentだけをscrollさせfold controlはframe下部に残す - navigation contentだけをscrollさせる。Desktopのfold controlはSidebar下部、Mobileのfold controlはHeaderに置く
- MobileでSidebarを表示するときはHeader下の残りviewport全体をSidebarに使い、main contentと同時表示しない。
- navigationとscope controlだけを置く。 - navigationとscope controlだけを置く。
- navigation linkはsidebar幅全体を使うflat rowとする。 - navigation linkはsidebar幅全体を使うflat rowとする。
- active stateは一つのcueと`aria-current="page"`で示す。 - active stateは一つのcueと`aria-current="page"`で示す。
+1 -1
View File
@@ -51,7 +51,7 @@
--interactive-selected: oklch(93% 0.02 230); --interactive-selected: oklch(93% 0.02 230);
--bevel-highlight: #fff; --bevel-highlight: #fff;
--bevel-shadow: #000; --bevel-shadow: #000;
--bevel-face-width: 2px; --bevel-face-width: 1px;
--shadow-overlay: 0 12px 32px oklch(0% 0 0 / 0.14); --shadow-overlay: 0 12px 32px oklch(0% 0 0 / 0.14);
--font-sans: "Gen Interface JP", sans-serif; --font-sans: "Gen Interface JP", sans-serif;
@@ -1,60 +1,51 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte'; import Bevel from '$lib/workspace/ui/Bevel.svelte';
import './sidebar.css'; import './sidebar.css';
type Props = { type Props = {
children: Snippet<[]>; children: Snippet<[]>;
folded?: boolean;
}; };
let { children }: Props = $props(); let { children, folded = $bindable(false) }: Props = $props();
let folded = $state(false);
function toggleFold() { function toggleFold() {
folded = !folded; folded = !folded;
} }
</script> </script>
<aside class="sidebar-frame" class:folded aria-label="Sidebar"> <div class="sidebar-frame" class:folded>
{#if !folded} <Bevel as="div" class="sidebar-frame__bevel" depth="inset" top={false} bottom={false} left={false} fill>
<div class="sidebar-frame-content"> <aside class="sidebar-frame__surface" aria-label="Sidebar">
{@render children()} {#if !folded}
</div> <div class="sidebar-frame-content">
{/if} {@render children()}
</div>
<div class="sidebar-control-row">
<button
class="sidebar-fold-button"
type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
aria-expanded={!folded}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
onclick={toggleFold}
>
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
{/if} {/if}
</button>
</div>
<BevelLine <div class="sidebar-control-row">
class="sidebar-frame__divider sidebar-frame__divider--vertical" <button
direction="y" class="sidebar-fold-button"
length="100%" type="button"
decorative aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
/> aria-expanded={!folded}
<BevelLine title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
class="sidebar-frame__divider sidebar-frame__divider--horizontal" onclick={toggleFold}
direction="x" >
length="100%" {#if folded}
decorative <svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
/> <path d="m6 17 5-5-5-5" />
</aside> <path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
{/if}
</button>
</div>
</aside>
</Bevel>
</div>
@@ -5,31 +5,28 @@
--sidebar-item-hover: oklch(96% 0 0); --sidebar-item-hover: oklch(96% 0 0);
--sidebar-item-active: oklch(92.5% 0 0); --sidebar-item-active: oklch(92.5% 0 0);
display: grid;
grid-column: 1; grid-column: 1;
grid-row: 1 / 3; grid-row: 1 / 3;
position: relative;
display: flex;
flex-direction: column;
width: clamp(220px, 20vw, 280px); width: clamp(220px, 20vw, 280px);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
}
.sidebar-frame .bevel.sidebar-frame__bevel {
width: 100%;
min-width: 0;
min-height: 0;
}
.sidebar-frame__surface {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
padding-block: var(--space-4); padding-block: var(--space-4);
} }
.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) { @media (prefers-color-scheme: dark) {
.sidebar-frame { .sidebar-frame {
--sidebar-item-hover: oklch(24% 0 0); --sidebar-item-hover: oklch(24% 0 0);
@@ -39,7 +36,6 @@
.sidebar-frame.folded { .sidebar-frame.folded {
width: max-content; width: max-content;
overflow: hidden; overflow: hidden;
padding-inline: 0;
} }
.sidebar-frame-content { .sidebar-frame-content {
flex: 1 1 auto; flex: 1 1 auto;
@@ -628,17 +624,18 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.sidebar-frame, .sidebar-frame {
.sidebar-frame.folded { z-index: 20;
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 2;
width: auto; width: 100%;
height: 100%;
} }
.sidebar-frame__divider--vertical { .sidebar-frame.folded {
display: none; display: none;
} }
.sidebar-frame__divider--horizontal { .sidebar-control-row {
display: block; display: none;
} }
} }
} }
@@ -128,7 +128,7 @@
inset: 0; inset: 0;
box-sizing: border-box; box-sizing: border-box;
border-style: solid; border-style: solid;
border-color: var(--line-strong); border-color: var(--_fallback-border, var(--line-strong));
border-width: var(--_top-w) var(--_right-w) var(--_bottom-w) var(--_left-w); border-width: var(--_top-w) var(--_right-w) var(--_bottom-w) var(--_left-w);
border-radius: inherit; border-radius: inherit;
pointer-events: none; pointer-events: none;
@@ -155,11 +155,11 @@
} }
.bevel[data-profile='edge'][data-depth='inset']::before { .bevel[data-profile='edge'][data-depth='inset']::before {
border-color: var(--line); --_fallback-border: var(--line);
} }
.bevel[data-profile='ridge'][data-depth='inset']::before { .bevel[data-profile='ridge'][data-depth='inset']::before {
border-color: var(--line); --_fallback-border: var(--line);
} }
.bevel[data-profile='ridge']::after { .bevel[data-profile='ridge']::after {
@@ -168,7 +168,7 @@
var(--bevel-right-inset) var(--bevel-right-inset)
var(--bevel-bottom-inset) var(--bevel-bottom-inset)
var(--bevel-left-inset); var(--bevel-left-inset);
border-color: var(--line); --_fallback-border: var(--line);
border-radius: border-radius:
max(0px, calc(var(--bevel-top-left-radius) - var(--bevel-face-width))) 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-top-right-radius) - var(--bevel-face-width)))
@@ -177,7 +177,7 @@
} }
.bevel[data-profile='ridge'][data-depth='inset']::after { .bevel[data-profile='ridge'][data-depth='inset']::after {
border-color: var(--line-strong); --_fallback-border: var(--line-strong);
} }
.bevel[data-invalid='true'], .bevel[data-invalid='true'],
+74 -39
View File
@@ -2,7 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { setContext } from 'svelte'; import { setContext } from 'svelte';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte'; import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte'; import Bevel from '$lib/workspace/ui/Bevel.svelte';
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context'; import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte'; import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte'; import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
@@ -17,6 +17,11 @@
sidebar = activeSidebar; sidebar = activeSidebar;
}); });
const headerController = $state<HeaderController>({ content: null }); const headerController = $state<HeaderController>({ content: null });
let sidebarFolded = $state(false);
function toggleSidebar() {
sidebarFolded = !sidebarFolded;
}
provideHeaderController(headerController); provideHeaderController(headerController);
setContext<SidebarController>(SIDEBAR_CONTEXT, { setContext<SidebarController>(SIDEBAR_CONTEXT, {
@@ -26,8 +31,8 @@
<WorkspaceAlerts /> <WorkspaceAlerts />
<div class="app-shell"> <div class="app-shell" class:sidebar-open={!sidebarFolded}>
<SidebarFrame> <SidebarFrame bind:folded={sidebarFolded}>
<GlobalSidebar <GlobalSidebar
currentPath={page.url.pathname} currentPath={page.url.pathname}
content={sidebar} content={sidebar}
@@ -35,25 +40,39 @@
workspaceError={data.workspaceCatalogError} workspaceError={data.workspaceCatalogError}
/> />
</SidebarFrame> </SidebarFrame>
<header class="app-shell__topbar"> <Bevel as="div" class="app-shell__topbar-bevel" depth="inset" top={false} right={false} left={false} fill>
<div class="app-shell__topbar-location"> <header class="app-shell__topbar">
{#if headerController.content}{@render headerController.content()}{/if} <div class="app-shell__topbar-location">
</div> {#if headerController.content}{@render headerController.content()}{/if}
<nav class="app-shell__topbar-actions" aria-label="Global navigation"> </div>
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account"> <nav class="app-shell__topbar-actions" aria-label="Global navigation">
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24"> <button
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /> class="app-shell__icon-button app-shell__mobile-sidebar-toggle"
<circle cx="12" cy="7" r="4" /> type="button"
</svg> aria-label={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
</a> aria-expanded={!sidebarFolded}
</nav> title={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
<BevelLine onclick={toggleSidebar}
class="app-shell__topbar-divider" >
direction="x" <svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
length="100%" {#if sidebarFolded}
decorative <path d="m6 17 5-5-5-5" />
/> <path d="m13 17 5-5-5-5" />
</header> {:else}
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
{/if}
</svg>
</button>
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
</nav>
</header>
</Bevel>
<main class="app-shell__main"> <main class="app-shell__main">
{@render children()} {@render children()}
</main> </main>
@@ -72,11 +91,14 @@
min-width: 0; min-width: 0;
} }
.app-shell__topbar { .app-shell :global(.bevel.app-shell__topbar-bevel) {
position: relative;
z-index: 30; z-index: 30;
grid-column: 2; grid-column: 2;
grid-row: 1; grid-row: 1;
min-width: 0;
}
.app-shell__topbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -88,12 +110,6 @@
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
} }
:global(.app-shell__topbar-divider) {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
}
.app-shell__topbar-location { .app-shell__topbar-location {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -123,6 +139,14 @@
color: var(--text-muted); color: var(--text-muted);
} }
.app-shell__mobile-sidebar-toggle {
display: none;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.app-shell__icon { .app-shell__icon {
width: 1.1rem; width: 1.1rem;
height: 1.1rem; height: 1.1rem;
@@ -150,25 +174,36 @@
@media (max-width: 760px) { @media (max-width: 760px) {
.app-shell { .app-shell {
grid-template-columns: 1fr; grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr; grid-template-rows: auto minmax(0, 1fr);
width: 100vw; width: 100vw;
height: auto; height: 100dvh;
min-height: 100dvh; min-height: 0;
overflow: visible; overflow: hidden;
}
.app-shell :global(.bevel.app-shell__topbar-bevel) {
grid-column: 1;
grid-row: 1;
} }
.app-shell__topbar { .app-shell__topbar {
grid-column: 1;
grid-row: 2;
padding: 0 var(--space-4); padding: 0 var(--space-4);
} }
.app-shell__mobile-sidebar-toggle {
display: inline-flex;
}
.app-shell__main { .app-shell__main {
grid-column: 1; grid-column: 1;
grid-row: 3; grid-row: 2;
overflow: visible; overflow-y: auto;
padding: var(--space-5) var(--space-4); padding: var(--space-5) var(--space-4);
} }
.app-shell.sidebar-open .app-shell__main {
display: none;
}
} }
</style> </style>
@@ -167,7 +167,6 @@
gap: var(--space-4); gap: var(--space-4);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border-radius: inherit; border-radius: inherit;
background: var(--bg-raised);
color: var(--text); color: var(--text);
} }
@@ -297,6 +296,7 @@
.resource-list { .resource-list {
min-width: 0; min-width: 0;
margin-inline: var(--space-2);
} }
.resource-row { .resource-row {
@@ -409,7 +409,7 @@
.key-value, .key-value,
.technical-values { .technical-values {
margin: 0; margin: 0 var(--space-2);
} }
.key-value > div:not(.bevel-line), .key-value > div:not(.bevel-line),
@@ -553,6 +553,7 @@
.sample-form { .sample-form {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
margin-inline: var(--space-2);
} }
.form-grid { .form-grid {
+23 -6
View File
@@ -39,10 +39,15 @@ Deno.test("Bevel keeps depth visual while semantic controls remain children", as
assert( assert(
source.includes("@supports") && source.includes("@supports") &&
source.includes("border-style: solid") && source.includes("border-style: solid") &&
source.includes(
"border-color: var(--_fallback-border, var(--line-strong))",
) &&
source.includes("--_fallback-border: var(--line)") &&
source.includes("--_fallback-border: var(--line-strong)") &&
source.includes( source.includes(
"border-width: var(--_top-w) var(--_right-w) var(--_bottom-w) var(--_left-w)", "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", "Bevel fallback colors must not outrank the enhanced transparent border",
); );
assert( assert(
source.includes("calc(max(cos(180deg - var(--_L)), 0) * 100%)") && source.includes("calc(max(cos(180deg - var(--_L)), 0) * 100%)") &&
@@ -52,12 +57,12 @@ Deno.test("Bevel keeps depth visual while semantic controls remain children", as
"Bevel must clamp unlit faces to the configured shadow without ambient light", "Bevel must clamp unlit faces to the configured shadow without ambient light",
); );
assert( assert(
appCss.includes("--bevel-face-width: 2px") && appCss.includes("--bevel-face-width: 1px") &&
source.includes("--_W: var(--bevel-face-width)") && source.includes("--_W: var(--bevel-face-width)") &&
source.includes("inset: var(--bevel-face-width)") && source.includes("inset: var(--bevel-face-width)") &&
!source.includes("BevelSize") && !source.includes("BevelSize") &&
!source.includes("data-size"), !source.includes("data-size"),
"Bevel must use the project-wide 2px edge and compose ridge from two 2px faces", "Bevel must use the project-wide 1px edge and compose ridge from two 1px faces",
); );
assert( assert(
source.includes("data-top={top ? 'true' : 'false'}") && source.includes("data-top={top ? 'true' : 'false'}") &&
@@ -94,9 +99,8 @@ Deno.test("Bevel keeps depth visual while semantic controls remain children", as
"Bevel must use the shared highlight and shadow palette without owning a surface tone", "Bevel must use the shared highlight and shadow palette without owning a surface tone",
); );
assert( assert(
designLanguage.includes("Bevelはedge") && designLanguage.includes("Bevel自体へbackgroundやsurface toneを与えず") &&
designLanguage.includes("lightingだけを所有する") && designLanguage.includes("text/content areaへ適用する"),
designLanguage.includes("text/content areaだけとする"),
"The design language must prohibit Bevel surface colors outside the child content area", "The design language must prohibit Bevel surface colors outside the child content area",
); );
assert( assert(
@@ -118,6 +122,15 @@ Deno.test("Workspace showroom exercises raised inset and ridge edges", async ()
import.meta.url, import.meta.url,
), ),
); );
const showroomCss = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/showroom.css",
import.meta.url,
),
);
const tactileGroupRule = showroomCss.match(
/\.tactile-group \{([\s\S]*?)\n \}/,
)?.[1];
assert( assert(
workspaceSource.includes('profile="edge" depth="raised"') && workspaceSource.includes('profile="edge" depth="raised"') &&
@@ -128,6 +141,10 @@ Deno.test("Workspace showroom exercises raised inset and ridge edges", async ()
workspaceSource.includes("pressed"), workspaceSource.includes("pressed"),
"Workspace showroom must demonstrate edge and ridge depths with full and adjacent side selections", "Workspace showroom must demonstrate edge and ridge depths with full and adjacent side selections",
); );
assert(
tactileGroupRule && !tactileGroupRule.includes("background"),
"Showroom ridge items must leave their content area background unset",
);
assert( assert(
settingsSource.includes('profile="edge" depth="inset"') && settingsSource.includes('profile="edge" depth="inset"') &&
settingsSource.includes('aria-invalid="true"'), settingsSource.includes('aria-invalid="true"'),
@@ -28,14 +28,20 @@ Deno.test("BevelLine exposes a semantic directional separator", async () => {
} }
assert( assert(
appCss.includes("--bevel-face-width: 2px") && appCss.includes("--bevel-face-width: 1px") &&
source.includes("--bevel-line-face-width: var(--bevel-face-width)") && source.includes("--bevel-line-face-width: var(--bevel-face-width)") &&
source.includes( source.includes(
"--bevel-line-width: calc(var(--bevel-line-face-width) * 2)", "--bevel-line-width: calc(var(--bevel-line-face-width) * 2)",
) && ) &&
!source.includes("BevelLineWeight") && !source.includes("BevelLineWeight") &&
!source.includes("data-weight"), !source.includes("data-weight"),
"BevelLine must compose a fixed 4px ridge from two project-wide 2px faces", "BevelLine must compose a fixed 2px ridge from two project-wide 1px faces",
);
assert(
!source.includes("--bevel-line-inset") &&
!source.includes("padding-inline") &&
!source.includes("padding-block"),
"BevelLine must fill its parent layer without independent endpoint spacing",
); );
assert( assert(
source.includes("background: var(--line-strong)") && source.includes("background: var(--line-strong)") &&
@@ -76,7 +82,7 @@ Deno.test("Workspace showroom demonstrates both BevelLine directions", async ()
); );
}); });
Deno.test("Workspace shell and showroom use BevelLine for structural separators", async () => { Deno.test("Shell regions use single-edge Bevel while open separators use BevelLine", async () => {
const rootLayout = await Deno.readTextFile( const rootLayout = await Deno.readTextFile(
new URL("../src/routes/+layout.svelte", import.meta.url), new URL("../src/routes/+layout.svelte", import.meta.url),
); );
@@ -95,18 +101,50 @@ Deno.test("Workspace shell and showroom use BevelLine for structural separators"
import.meta.url, import.meta.url,
), ),
); );
const designLanguage = await Deno.readTextFile(
new URL(
"../../../docs/development/ui-ux/design-language.md",
import.meta.url,
),
);
assert( assert(
rootLayout.includes("app-shell__topbar-divider") && rootLayout.includes('class="app-shell__topbar-bevel"') &&
sidebarFrame.includes("sidebar-frame__divider--vertical") && rootLayout.includes('depth="inset"') &&
sidebarFrame.includes("sidebar-frame__divider--horizontal"), rootLayout.includes("top={false} right={false} left={false}") &&
"Workspace shell must render structural dividers through BevelLine", rootLayout.includes("bind:folded={sidebarFolded}") &&
rootLayout.includes("app-shell__mobile-sidebar-toggle") &&
rootLayout.includes("class:sidebar-open={!sidebarFolded}") &&
!rootLayout.includes("BevelLine") &&
sidebarFrame.includes('class="sidebar-frame__bevel"') &&
sidebarFrame.includes('depth="inset"') &&
sidebarFrame.includes("top={false} bottom={false} left={false}") &&
sidebarFrame.includes("$bindable(false)") &&
!sidebarFrame.includes("BevelLine") &&
sidebarCss.includes(".sidebar-frame.folded") &&
sidebarCss.includes("display: none") &&
sidebarCss.includes("display: grid"),
"Desktop shell boundaries must use one Bevel edge and Mobile must share Header fold state",
);
assert(
designLanguage.includes(
"Headerのbottom edgeとSidebarのright edgeだけを有効",
) &&
designLanguage.includes("Mobileのfold controlはHeaderに置く") &&
designLanguage.includes("main contentと同時表示しない") &&
designLanguage.includes("同じlayerではheading、本文、Lineの端を揃え") &&
designLanguage.includes(
"親layoutがそのlayer全体へinline方向の余白を与え",
) &&
showroomCss.includes(".resource-list {") &&
showroomCss.includes("margin-inline: var(--space-2)"),
"The design language and showroom must align same-layer lines and inset nested layers as a whole",
); );
assert( assert(
!sidebarCss.includes("border-right:") && !sidebarCss.includes("border-right:") &&
!sidebarCss.includes("border-bottom:") && !sidebarCss.includes("border-bottom:") &&
!showroomCss.includes("border-top: 1px solid") && !showroomCss.includes("border-top: 1px solid") &&
!showroomCss.includes("border-bottom: 1px solid"), !showroomCss.includes("border-bottom: 1px solid"),
"Shell and showroom structural separators must not fall back to one-sided solid borders", "Shell and showroom structural boundaries must not fall back to one-sided solid borders",
); );
}); });