feat: merge workspace web UX demo

This commit is contained in:
2026-09-18 17:41:10 +09:00
53 changed files with 3586 additions and 490 deletions
+45
View File
@@ -55,3 +55,48 @@ the Vite dev server proxy plus the Rust backend command above.
deno task check
deno task build
```
## Design and architecture authority
- [`../../docs/development/ui-ux/design-language.md`](../../docs/development/ui-ux/design-language.md): visual and interaction language.
- [`../../docs/development/ui-ux/product-ux.md`](../../docs/development/ui-ux/product-ux.md): resource and navigation IA.
- [`../../docs/development/ui-ux/application-architecture.md`](../../docs/development/ui-ux/application-architecture.md): shell and nested override architecture.
- [`../../docs/development/ui-ux/visual-review.md`](../../docs/development/ui-ux/visual-review.md): implementer-owned visual validation.
This README owns frontend implementation guidance and concrete source paths. It does not redefine product or visual rules from those documents.
## CSS ownership
`src/app.css` is the global foundation and owns only:
- font imports;
- cascade layer order;
- semantic tokens and light/dark theme;
- reset and base element typography;
- global focus and text-selection behavior.
It must not own page layout, feature class selectors, table or definition-list layout, button variants, status presentation, helper spacing, or anchor color. Navigation, inline links, and action links own their color in the relevant component stylesheet.
Feature styles are owned by their implementation area:
- `src/lib/workspace/styles/workspace-pages.css`: Workspace main-content primitives.
- `src/lib/workspace/styles/tickets.css`: Ticket surfaces.
- `src/lib/workspace/styles/workers.css`: Worker surfaces.
- `src/lib/workspace/styles/settings.css`: Settings, Account, and form surfaces.
- `src/lib/workspace/styles/workspace-catalog.css`: Workspace catalog.
- `src/lib/workspace/sidebar/sidebar.css`: Sidebar components.
- Svelte-local styles: behavior-specific presentation that is not reused outside that component.
Use semantic tokens from `app.css`. Do not introduce component-local color, spacing, font, or z-index systems.
## Source map
- `src/routes/+layout.svelte`: root application shell and override contexts.
- `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration.
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
- `src/lib/workspace/header/`: Header frame, context, and overrides.
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
- `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`.
- `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom.
When one of these paths changes, update this source map in the same change. Stable architecture and UX authority remain in `docs/`; this map follows the current implementation.
+4 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
@@ -32,5 +32,8 @@
"svelte-check": "npm:svelte-check@4.3.4",
"typescript": "npm:typescript@5.9.3",
"vite": "npm:vite@7.2.7"
},
"fmt": {
"proseWrap": "never"
}
}
+80 -132
View File
@@ -6,16 +6,20 @@
@import "@fontsource/ibm-plex-mono/latin-500.css";
@import "@fontsource/ibm-plex-mono/latin-600.css";
@import "@fontsource/ibm-plex-mono/latin-700.css";
@layer reset, tokens, base, layout, components;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
textarea,
@@ -23,9 +27,11 @@
font: inherit;
}
}
@layer tokens {
:root {
color-scheme: light dark;
--bg: oklch(98.5% 0 0);
--bg-raised: oklch(96% 0 0);
--bg-subtle: oklch(93% 0 0);
@@ -41,6 +47,28 @@
--success: oklch(48% 0.11 145);
--warning: oklch(62% 0.12 85);
--danger: oklch(54% 0.14 25);
--interactive-hover: oklch(94.5% 0 0);
--interactive-selected: oklch(93% 0.02 230);
--shadow-overlay: 0 12px 32px oklch(0% 0 0 / 0.14);
--font-sans: "Gen Interface JP", sans-serif;
--font-mono: "IBM Plex Mono", monospace;
--font-size-title: 24px;
--line-height-title: 32px;
--font-size-body: 14px;
--line-height-body: 20px;
--font-size-compact: 12px;
--line-height-compact: 16px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--radius-soft: 8px;
--tui-green: #76946a;
--tui-red: #c34043;
--tui-yellow: #c0a36e;
@@ -49,19 +77,8 @@
--tui-magenta: #957fb8;
--tui-gray: #727169;
--tui-dark-gray: #54546d;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--radius-soft: 8px;
--radius-panel: 12px;
--interactive-hover: oklch(94.5% 0 0);
--interactive-selected: oklch(93% 0.02 230);
--font-sans: "Gen Interface JP", sans-serif;
--font-mono: "IBM Plex Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: oklch(16% 0 0);
@@ -81,21 +98,23 @@
--danger: oklch(76% 0.14 25);
--interactive-hover: oklch(29% 0 0);
--interactive-selected: oklch(30% 0.025 230);
--shadow-overlay: 0 14px 36px oklch(0% 0 0 / 0.38);
}
}
}
@layer base {
html {
font-size: 14px;
font-size: var(--font-size-body);
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
line-height: var(--line-height-body);
}
a {
color: var(--accent);
}
h1,
h2,
h3,
@@ -104,127 +123,56 @@
dd {
overflow-wrap: anywhere;
}
h1,
h2,
h3 {
margin-top: 0;
color: var(--text-strong);
margin-top: 0;
}
p {
line-height: 1.55;
}
code,
pre {
font-family: var(--font-mono);
}
code {
color: var(--code);
}
small {
color: var(--text-muted);
display: block;
margin-top: var(--space-1);
}
}
@layer layout {
.grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
min-width: 0;
}
.runtime {
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
}
.stack {
display: grid;
gap: var(--space-4);
}
}
@layer components {
.section-note,
.section-state,
.muted {
color: var(--text-muted);
font-size: 0.82rem;
}
.section-note,
.section-state {
margin: 0;
line-height: 1.45;
}
.card {
min-width: 0;
padding: 0;
}
dl {
display: grid;
gap: var(--space-1);
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: minmax(6.5rem, 10rem) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
dt {
color: var(--text-faint);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
dd {
min-width: 0;
margin: 0;
}
dd small {
display: inline;
margin-top: 0;
margin-left: var(--space-2);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
th {
color: var(--text-faint);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.inline-link,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
border-radius: 999px;
border: 1px solid var(--line);
padding: 0.4rem 0.75rem;
background: var(--bg);
color: var(--accent);
font-size: 0.85rem;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.secondary-button:hover,
.inline-link:hover {
border-color: var(--line-strong);
background: var(--bg-raised);
h1 {
font-size: var(--font-size-title);
line-height: var(--line-height-title);
}
h2,
h3 {
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
p {
line-height: var(--line-height-body);
}
code,
pre {
color: var(--code);
font-family: var(--font-mono);
}
small {
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
}
:where(
a,
button,
input,
select,
textarea,
summary,
[tabindex]
):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
::selection {
background: var(--interactive-selected);
color: var(--text-strong);
}
}
@@ -96,7 +96,7 @@
display: block;
margin-bottom: 0.15rem;
color: var(--alert-color);
font-size: 0.82rem;
font-size: var(--font-size-body);
letter-spacing: 0.02em;
text-transform: uppercase;
}
@@ -105,8 +105,8 @@
margin: 0;
overflow-wrap: anywhere;
color: var(--text);
font-size: 0.9rem;
line-height: 1.35;
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
.workspace-alert-dismiss {
@@ -118,7 +118,7 @@
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 1.2rem;
font-size: var(--font-size-body);
line-height: 1;
}
@@ -586,7 +586,7 @@
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-subtle));
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
@@ -192,7 +192,7 @@
.task-reminder-summary {
margin: 0;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
line-height: 1.55;
white-space: pre-line;
}
@@ -203,7 +203,7 @@
gap: var(--space-2);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.78rem;
font-size: var(--font-size-compact);
font-variant-numeric: tabular-nums;
font-weight: 750;
}
@@ -213,7 +213,7 @@
padding-left: 1.25rem;
color: var(--tui-dark-gray);
font-family: var(--font-mono);
font-size: 0.75rem;
font-size: var(--font-size-compact);
}
.compaction-summary,
@@ -227,15 +227,15 @@
display: block;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.7rem;
font-size: var(--font-size-compact);
}
.compaction-error {
color: var(--tui-error);
color: var(--danger);
}
.activity-summary {
font-size: 14px;
font-size: var(--font-size-body);
}
.task-reminder-summary {
@@ -245,14 +245,14 @@
}
.console-line.error .activity-summary {
color: var(--tui-error);
color: var(--danger);
}
.run-stats {
margin: 0;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
@@ -294,7 +294,7 @@
min-width: 0;
margin: 0;
font-family: var(--font-mono);
font-size: 12px;
font-size: var(--font-size-compact);
line-height: 1.1;
overflow-x: auto;
white-space: pre;
@@ -313,7 +313,7 @@
align-items: baseline;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.88rem;
font-size: var(--font-size-compact);
font-weight: 750;
}
@@ -329,7 +329,7 @@
.tool-status {
flex: 0 0 auto;
color: var(--tui-dark-gray);
font-size: 0.72rem;
font-size: var(--font-size-compact);
white-space: nowrap;
}
@@ -357,7 +357,7 @@
justify-content: space-between;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
font-weight: 750;
}
@@ -367,7 +367,7 @@
border-radius: 0.65rem;
color: var(--text);
font-family: var(--font-mono);
font-size: 0.78rem;
font-size: var(--font-size-compact);
line-height: 1.45;
margin: 0.6rem 0 0;
overflow-x: auto;
@@ -424,7 +424,7 @@
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: 0.68rem;
font-size: var(--font-size-compact);
font-weight: 750;
opacity: 0;
pointer-events: none;
@@ -443,7 +443,7 @@
border-left: 2px solid var(--line);
padding-left: 0.6rem;
color: var(--text-muted);
font-size: 0.84rem;
font-size: var(--font-size-compact);
}
.message-detail p {
@@ -124,7 +124,7 @@
gap: 0.1rem;
min-width: 0;
padding-inline: 0.75rem;
font-size: 0.8rem;
font-size: var(--font-size-compact);
line-height: 1.35;
}
@@ -237,7 +237,7 @@
.task-pane h3 {
margin: 0 0 1rem;
color: var(--accent);
font-size: 0.9rem;
font-size: var(--font-size-body);
}
.task-empty {
@@ -262,8 +262,8 @@
}
.task-description {
font-size: 0.8rem;
line-height: 1.45;
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
@media (max-width: 900px) {
@@ -158,8 +158,8 @@
text-align: left;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-panel) 92%, transparent);
box-shadow: var(--shadow-soft);
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
box-shadow: var(--shadow-overlay);
opacity: 1;
padding: 0.25rem 0.45rem;
transform: none;
@@ -69,7 +69,7 @@
:global(.rich-markdown h4) {
margin: 0.7rem 0 0.35rem;
color: var(--text-strong);
font-size: 1rem;
font-size: var(--font-size-body);
}
:global(.rich-markdown blockquote) {
@@ -52,7 +52,7 @@
min-height: 1.35rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.74rem;
font-size: var(--font-size-compact);
font-variant-numeric: tabular-nums;
}
</style>
@@ -78,6 +78,134 @@ Deno.test("workspace feature css is owned outside app css", async () => {
);
});
Deno.test("workspace app css is limited to global foundation", async () => {
const appCss = await Deno.readTextFile(
new URL("./../../../app.css", import.meta.url),
);
const workspacePagesCss = await Deno.readTextFile(
new URL(
"./../styles/workspace-pages.css",
import.meta.url,
),
);
const settingsCss = await Deno.readTextFile(
new URL("./../styles/settings.css", import.meta.url),
);
const sidebarCss = await Deno.readTextFile(
new URL("./../sidebar/sidebar.css", import.meta.url),
);
for (
const selector of [
".card",
".stack",
".section-note",
".section-state",
".muted",
".table-wrap",
".inline-link",
".secondary-button",
]
) {
assert(
!appCss.includes(selector),
`app.css must not own feature selector ${selector}`,
);
}
assert(
!/^\s*a\s*\{[^}]*\bcolor\s*:/ms.test(appCss),
"app.css must not override component-owned link colors",
);
for (
const legacyToken of [
"--surface",
"--border",
"--bg-panel",
"--interactive-muted",
"--radius-card",
"--radius-panel",
"--shadow-soft",
"--tui-error",
]
) {
assert(
!appCss.includes(legacyToken),
`app.css must not retain legacy token ${legacyToken}`,
);
}
assert(
workspacePagesCss.includes(".main-content .card") &&
workspacePagesCss.includes(".main-content table") &&
workspacePagesCss.includes(".main-content dl"),
"workspace content primitives must be scoped to the main content owner",
);
assert(
settingsCss.includes(".main-content .inline-link") &&
settingsCss.includes(".main-content .secondary-button"),
"settings CSS must own its link and button variants",
);
assert(
sidebarCss.includes(".section-state"),
"sidebar CSS must own sidebar status presentation",
);
});
Deno.test("design lab demonstrates nested sidebar slots without flattening levels", async () => {
const workspaceLayout = await Deno.readTextFile(
new URL(
"./../../../routes/design-lab/workspace-web-ux/+layout.svelte",
import.meta.url,
),
);
const settingsLayout = await Deno.readTextFile(
new URL(
"./../../../routes/design-lab/workspace-web-ux/settings/+layout.svelte",
import.meta.url,
),
);
assert(
workspaceLayout.includes("createOverrideStack<SidebarSnippet>"),
"Workspace design-lab layout must provide a child sidebar slot",
);
assert(
workspaceLayout.includes("<WorkspaceSidebarFixture"),
"Workspace design-lab layout must register its WorkspaceSidebar fixture",
);
assert(
workspaceLayout.includes("content={sidebarContent}"),
"Workspace design-lab layout must expose its child override slot",
);
assert(
!workspaceLayout.includes("<GlobalNavSections"),
"Workspace design-lab layout must not flatten Global navigation",
);
assert(
!workspaceLayout.includes("<SettingsSidebar"),
"Workspace design-lab layout must not flatten Settings navigation",
);
assert(
settingsLayout.includes("createOverrideStack<SidebarSnippet>"),
"Settings design-lab layout must provide a child sidebar slot",
);
assert(
settingsLayout.includes("<SettingsSidebarFixture"),
"Settings design-lab layout must register its SettingsSidebar fixture",
);
assert(
settingsLayout.includes("controller={parentSidebarController}"),
"Settings design-lab layout must register into its parent slot",
);
assert(
!settingsLayout.includes("<WorkspaceSidebar"),
"Settings design-lab layout must not duplicate WorkspaceSidebar",
);
});
Deno.test("workspace Worker list lives on the dedicated Workers page", async () => {
const workspacePage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
@@ -420,7 +548,7 @@ Deno.test("Worker Console overview activity summaries use 14px text", async () =
assert(
consoleLine.includes(".activity-summary {") &&
consoleLine.includes("font-size: 14px;"),
consoleLine.includes("font-size: var(--font-size-body);"),
"Overview activity summaries such as ran command counts should render at 14px",
);
});
@@ -443,7 +571,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
consoleLine.includes(
".console-line.tool.tool-bash .console-plain-text",
) &&
consoleLine.includes("font-size: 12px;") &&
consoleLine.includes("font-size: var(--font-size-compact);") &&
consoleLine.includes("line-height: 1.1;") &&
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
consoleLine.includes("<RichMarkdown text={item.body || '—'} />") &&
@@ -920,10 +1048,12 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
);
assert(
sidebarFrame.includes("let folded = $state(false)") &&
sidebarFrame.includes("folded = $bindable(false)") &&
sidebarFrame.includes("sidebar-fold-button") &&
sidebarFrame.includes("Fold sidebar") &&
sidebarFrame.includes("Unfold sidebar") &&
rootLayout.includes("let sidebarFolded = $state(false)") &&
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") &&
!workspaceLayout.includes("sidebarFolded") &&
!workspaceLayout.includes("onToggleFold") &&
!sidebar.includes("folded?: boolean") &&
@@ -61,7 +61,7 @@
gap: 0.55rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.84rem;
font-size: var(--font-size-compact);
line-height: 1;
}
@@ -65,7 +65,7 @@
'.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
'.cm-fixed-schema-wrapper': {
color: 'var(--text-muted)',
backgroundColor: 'var(--interactive-muted)',
backgroundColor: 'var(--interactive-hover)',
fontWeight: '600',
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
@@ -1,46 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import './sidebar.css';
type Props = {
children: Snippet<[]>;
folded?: boolean;
};
let { children }: Props = $props();
let folded = $state(false);
let { children, folded = $bindable(false) }: Props = $props();
function toggleFold() {
folded = !folded;
}
</script>
<aside class="sidebar-frame" class:folded aria-label="Sidebar">
{#if !folded}
<div class="sidebar-frame-content">
{@render children()}
</div>
{/if}
<div class="sidebar-control-row">
<button
class="sidebar-fold-button"
type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
aria-expanded={!folded}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
onclick={toggleFold}
>
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
<div class="sidebar-frame" class:folded>
<Bevel as="div" class="sidebar-frame__bevel" top={false} bottom={false} left={false} fill>
<aside class="sidebar-frame__surface" aria-label="Sidebar">
{#if !folded}
<div class="sidebar-frame-content">
{@render children()}
</div>
{/if}
</button>
</div>
</aside>
<div class="sidebar-control-row">
<button
class="sidebar-fold-button"
type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
aria-expanded={!folded}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
onclick={toggleFold}
>
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
{/if}
</button>
</div>
</aside>
</Bevel>
</div>
@@ -5,16 +5,27 @@
--sidebar-item-hover: oklch(96% 0 0);
--sidebar-item-active: oklch(92.5% 0 0);
display: grid;
grid-column: 1;
grid-row: 1 / 3;
display: flex;
flex-direction: column;
width: clamp(220px, 20vw, 280px);
min-width: 0;
min-height: 0;
overflow: hidden;
}
.sidebar-frame .bevel.sidebar-frame__bevel {
width: 100%;
min-width: 0;
min-height: 0;
}
.sidebar-frame__surface {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
padding-block: var(--space-4);
border-right: 1px solid var(--line);
}
@media (prefers-color-scheme: dark) {
.sidebar-frame {
@@ -25,7 +36,6 @@
.sidebar-frame.folded {
width: max-content;
overflow: hidden;
padding-inline: 0;
}
.sidebar-frame-content {
flex: 1 1 auto;
@@ -112,7 +122,7 @@
border-radius: var(--radius-soft);
padding: 0.45rem 0.6rem;
color: var(--text-strong);
font-size: 1.05rem;
font-size: var(--font-size-compact);
font-weight: 800;
line-height: 1.25;
text-overflow: ellipsis;
@@ -139,7 +149,7 @@
background: transparent;
color: var(--text-strong);
font: inherit;
font-size: 1.05rem;
font-size: var(--font-size-compact);
font-weight: 800;
line-height: 1.25;
text-align: left;
@@ -174,7 +184,7 @@
padding: 0.35rem 0.45rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.84rem;
font-size: var(--font-size-compact);
font-weight: 650;
line-height: 1;
}
@@ -207,7 +217,7 @@
border-radius: calc(var(--radius-soft) + 0.2rem);
padding: var(--space-2);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
box-shadow: var(--shadow-overlay);
}
.workspace-menu-item {
display: flex;
@@ -217,7 +227,7 @@
border-radius: var(--radius-soft);
padding: var(--space-2);
color: var(--text-strong);
font-size: 0.82rem;
font-size: var(--font-size-compact);
font-weight: 650;
line-height: 1.3;
text-decoration: none;
@@ -247,7 +257,7 @@
gap: var(--space-2);
padding: var(--space-1) var(--space-2) 0;
color: var(--text-faint);
font-size: 0.68rem;
font-size: var(--font-size-compact);
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -259,7 +269,7 @@
place-items: center;
border-radius: var(--radius-soft);
color: var(--text-muted);
font-size: 1rem;
font-size: var(--font-size-body);
line-height: 1;
text-decoration: none;
}
@@ -281,16 +291,22 @@
.workspace-menu-status {
margin: var(--space-1) var(--space-2);
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
line-height: 1.35;
}
.workspace-menu-status.error {
color: var(--danger);
}
.section-state {
margin: var(--space-2) 0 0;
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: 1.35;
}
.workspace-status {
margin: var(--space-1) 0 0;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
line-height: 1.35;
}
.workspace-status.error,
@@ -311,7 +327,7 @@
.sidebar-fold-button {
border: 1px solid var(--line);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
box-shadow: var(--shadow-overlay);
color: var(--text-strong);
cursor: pointer;
}
@@ -349,7 +365,7 @@
.sidebar-nav-section__header,
.sidebar-section-label {
color: var(--text-faint);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 750;
letter-spacing: 0.11em;
text-transform: uppercase;
@@ -373,7 +389,7 @@
}
.section-count {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
line-height: 1;
}
.nav-list,
@@ -392,7 +408,7 @@
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-soft);
color: var(--text-strong);
font-size: 0.9rem;
font-size: var(--font-size-compact);
text-align: left;
text-decoration: none;
transition: background-color 140ms ease, color 140ms ease;
@@ -465,7 +481,7 @@
border-radius: var(--radius-soft);
padding: var(--space-1);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
box-shadow: var(--shadow-overlay);
}
.worker-actions-menu button {
border: 0;
@@ -474,7 +490,7 @@
background: transparent;
color: var(--text-strong);
font: inherit;
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 650;
line-height: 1.2;
text-align: left;
@@ -535,7 +551,7 @@
align-items: center;
justify-content: center;
width: 0.75rem;
font-size: 0.7rem;
font-size: var(--font-size-compact);
line-height: 1;
}
.worker-status-spinner.is-subworker {
@@ -547,7 +563,7 @@
min-width: 0;
overflow: hidden;
color: inherit;
font-size: 0.78rem;
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 1.1rem;
text-overflow: ellipsis;
@@ -560,7 +576,7 @@
overflow: hidden;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.66rem;
font-size: var(--font-size-compact);
line-height: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
@@ -608,13 +624,18 @@
}
@media (max-width: 760px) {
.sidebar-frame,
.sidebar-frame.folded {
.sidebar-frame {
z-index: 20;
grid-column: 1;
grid-row: 1;
width: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
grid-row: 2;
width: 100%;
height: 100%;
}
.sidebar-frame.folded {
display: none;
}
.sidebar-control-row {
display: none;
}
}
}
@@ -1,5 +1,40 @@
@layer reset, tokens, base, layout, components;
@layer components {
.main-content .muted {
color: var(--text-muted);
}
.main-content .inline-link {
color: var(--accent);
font-weight: 700;
text-underline-offset: 0.15em;
}
.main-content .secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: 0.45rem 0.75rem;
background: var(--bg-raised);
color: var(--text-strong);
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.main-content .secondary-button:hover:not(:disabled),
.main-content .secondary-button:focus-visible:not(:disabled) {
border-color: var(--accent);
background: var(--interactive-hover);
}
.main-content .secondary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.settings-button {
text-decoration: none;
cursor: pointer;
@@ -32,17 +67,25 @@
}
.badge {
display: inline-flex;
flex: 0 0 auto;
border-radius: 999px;
padding: 0.35rem 0.65rem;
background: var(--bg-subtle);
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.badge::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.badge.warning {
color: var(--warning);
}
@@ -62,7 +105,6 @@
max-width: 24rem;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: var(--text-muted);
}
@@ -83,7 +125,6 @@
min-width: min(16rem, 100%);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
@@ -128,7 +169,6 @@
.settings-pattern {
padding: var(--space-4);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
@@ -162,7 +202,7 @@
.account-details dt {
color: var(--text-muted);
font-size: 0.8rem;
font-size: var(--font-size-compact);
font-weight: 700;
text-transform: uppercase;
}
@@ -173,7 +213,7 @@
}
.account-details code {
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.account-panel {
@@ -192,7 +232,7 @@
display: grid;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-body);
}
.settings-runtime-form button,
@@ -223,7 +263,7 @@
border: 1px solid var(--line);
border-radius: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
background: color-mix(in srgb, var(--text-strong) 3%, transparent);
}
.settings-runtime-table-wrap {
@@ -249,7 +289,7 @@
.settings-runtime-table th {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
letter-spacing: 0.05em;
text-transform: uppercase;
}
@@ -288,12 +328,12 @@
.settings-runtime-detail-row td {
padding-top: 0;
background: rgba(255, 255, 255, 0.025);
background: color-mix(in srgb, var(--text-strong) 2.5%, transparent);
}
.settings-muted-action {
color: var(--text-muted);
font-size: 0.82rem;
font-size: var(--font-size-compact);
}
.settings-action-row {
@@ -325,18 +365,18 @@
}
.settings-diagnostics-list li.error {
border-color: rgba(255, 99, 99, 0.55);
border-color: color-mix(in srgb, var(--danger) 55%, transparent);
}
.settings-diagnostics-list li.warning {
border-color: rgba(255, 205, 86, 0.55);
border-color: color-mix(in srgb, var(--warning) 55%, transparent);
}
.settings-test-result {
display: grid;
gap: 0.3rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.04);
background: color-mix(in srgb, var(--text-strong) 4%, transparent);
padding: 0.75rem;
}
.settings-test-result.failed {
@@ -374,7 +414,7 @@
.runtime-detail-grid dt {
margin-bottom: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
@@ -433,7 +473,7 @@
background: var(--bg-raised);
color: var(--text-strong);
font-family: var(--font-mono);
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.runtime-public-key {
@@ -468,7 +508,7 @@
.runtime-trust-form label,
.runtime-revoke-row label {
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
font-weight: 700;
}
@@ -503,7 +543,7 @@
.runtime-trust-comparison dt {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 700;
}
@@ -559,7 +599,7 @@
.runtime-audit-table th {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
letter-spacing: 0.05em;
text-transform: uppercase;
}
@@ -589,7 +629,6 @@
min-width: min(16rem, 100%);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-section-card:hover,
@@ -628,7 +667,7 @@
display: grid;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.82rem;
font-size: var(--font-size-body);
}
.settings-form input,
.settings-form select,
@@ -685,7 +724,6 @@
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-profile-list strong,
@@ -721,29 +759,35 @@
}
.settings-identity-list dt {
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.settings-identity-list dd {
margin: 0;
color: var(--text-strong);
}
.section-status-pill {
display: inline-flex;
width: fit-content;
border-radius: 999px;
padding: 0.25rem 0.55rem;
background: var(--bg-subtle);
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 800;
text-transform: uppercase;
}
.section-status-pill::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.config-source-shell {
display: grid;
grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
min-height: 40rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.config-source-tree {
@@ -784,7 +828,7 @@
}
.config-source-tree nav button small {
color: var(--text-muted);
font-size: 0.65rem;
font-size: var(--font-size-compact);
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -799,10 +843,13 @@
padding: var(--space-3);
border-top: 1px solid var(--line);
}
.config-source-create label,
.config-source-create label {
color: var(--text-muted);
font-size: var(--font-size-body);
}
.config-source-workbench__header span {
color: var(--text-muted);
font-size: 0.75rem;
font-size: var(--font-size-compact);
}
.config-source-create input,
.config-source-actions input {
@@ -861,7 +908,7 @@
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--line);
color: var(--text-muted);
font-size: 0.8rem;
font-size: var(--font-size-compact);
}
.config-source-conflict {
display: flex;
@@ -924,7 +971,6 @@
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-form h2 {
@@ -934,7 +980,7 @@
display: grid;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-body);
font-weight: 700;
}
.settings-repository-form input {
@@ -963,7 +1009,6 @@
.settings-repository-table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-table {
@@ -979,7 +1024,7 @@
}
.settings-repository-table th {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
text-transform: uppercase;
}
.settings-repository-table tbody tr:last-child td {
@@ -10,7 +10,7 @@
border-radius: 0.55rem;
padding: 0.5rem 0.8rem;
font: inherit;
font-size: 0.82rem;
font-size: var(--font-size-compact);
font-weight: 700;
text-decoration: none;
cursor: pointer;
@@ -26,9 +26,9 @@
color: var(--text-strong);
}
.workspace-danger-button {
border: 1px solid #a63f3f;
background: #a63f3f;
color: #fff;
border: 1px solid var(--danger);
background: var(--danger);
color: var(--bg-raised);
}
.workspace-primary-button:disabled,
.workspace-secondary-button:disabled,
@@ -38,7 +38,7 @@
}
.workspace-back-link {
color: var(--text-muted);
font-size: 0.82rem;
font-size: var(--font-size-compact);
text-decoration: none;
}
.workspace-callout {
@@ -47,8 +47,8 @@
padding: 0.7rem 0.85rem;
}
.workspace-callout.is-error {
border-color: #a63f3f;
color: #d66;
border-color: var(--danger);
color: var(--danger);
}
.workspace-empty-copy {
margin: 0;
@@ -94,20 +94,20 @@
}
.orchestrator-status strong {
color: var(--text-strong);
font-size: 0.76rem;
font-size: var(--font-size-compact);
}
.orchestrator-status span {
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
}
.orchestrator-status-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #a75454;
background: var(--danger);
}
.orchestrator-status[data-online="true"] .orchestrator-status-dot {
background: #43a66d;
background: var(--success);
}
.ticket-panel-summary {
display: grid;
@@ -116,8 +116,8 @@
}
.ticket-panel-summary strong {
color: var(--text-strong);
font-size: 1.8rem;
line-height: 1;
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
}
.ticket-kanban {
display: grid;
@@ -149,7 +149,7 @@
}
.ticket-lane-header h2 {
margin: 0;
font-size: 0.78rem;
font-size: var(--font-size-compact);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -160,17 +160,17 @@
background: var(--text-muted);
}
.ticket-lane[data-state="ready-planning"] .ticket-state-dot {
background: #43a66d;
background: var(--success);
}
.ticket-lane[data-state="inprogress-queued"] .ticket-state-dot {
background: #d09b37;
background: var(--warning);
}
.ticket-lane[data-state="done-closed"] .ticket-state-dot {
background: var(--accent);
}
.ticket-lane-count {
color: var(--text-muted);
font-size: 0.75rem;
font-size: var(--font-size-compact);
}
.ticket-lane-cards {
display: grid;
@@ -188,7 +188,7 @@
margin: 0;
color: var(--text-muted);
padding: 0.5rem;
font-size: 0.7rem;
font-size: var(--font-size-compact);
text-align: center;
}
.ticket-lane-page-state {
@@ -198,7 +198,7 @@
margin: 0;
padding: 0.45rem;
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
text-align: center;
}
.ticket-lane-page-error {
@@ -229,17 +229,17 @@
overflow: hidden;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.68rem;
font-size: var(--font-size-compact);
text-overflow: ellipsis;
}
.ticket-card strong {
font-size: 0.85rem;
font-size: var(--font-size-compact);
line-height: 1.35;
}
.ticket-card-meta {
justify-content: space-between;
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
}
.ticket-risk-list {
display: flex;
@@ -247,16 +247,23 @@
gap: 0.25rem;
}
.ticket-risk-list span {
border: 1px solid #a97531;
border-radius: 999px;
color: #d3a55d;
padding: 0.1rem 0.35rem;
font-size: 0.62rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--warning);
font-size: var(--font-size-compact);
}
.ticket-risk-list span::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.ticket-lane-empty {
padding: 1.2rem 0.4rem;
color: var(--text-muted);
font-size: 0.75rem;
font-size: var(--font-size-compact);
text-align: center;
}
.ticket-detail-page {
@@ -267,28 +274,35 @@
.ticket-detail-header h1 {
margin: 0.45rem 0 0.25rem;
color: var(--text-strong);
font-size: clamp(1.5rem, 3vw, 2.4rem);
font-size: var(--font-size-title);
}
.ticket-detail-header p {
margin: 0;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.ticket-detail-kicker {
gap: 0.6rem;
}
.ticket-detail-kicker code {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
}
.workspace-status-pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.45rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
text-transform: uppercase;
}
.workspace-status-pill::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.ticket-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
@@ -318,11 +332,11 @@
.ticket-section-heading h2, .ticket-control-card h2 {
margin: 0;
color: var(--text-strong);
font-size: 0.9rem;
font-size: var(--font-size-body);
}
.ticket-section-heading span, .ticket-control-card header span {
color: var(--text-muted);
font-size: 0.7rem;
font-size: var(--font-size-compact);
}
.ticket-editor, .ticket-control-form {
display: grid;
@@ -332,7 +346,7 @@
display: grid;
gap: 0.3rem;
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-body);
}
.ticket-editor input,
.ticket-editor textarea,
@@ -349,8 +363,8 @@
}
.ticket-worker-card p {
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.5;
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
.ticket-role-actions {
gap: 0.55rem;
@@ -365,14 +379,14 @@
details.ticket-control-card summary {
cursor: pointer;
color: var(--text-strong);
font-size: 0.85rem;
font-size: var(--font-size-compact);
font-weight: 700;
}
details.ticket-control-card[open] summary {
margin-bottom: var(--space-3);
}
.ticket-close-card {
border-color: color-mix(in srgb, #a63f3f 55%, var(--line));
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
}
.ticket-relations-list, .ticket-blocker-list {
display: grid;
@@ -391,13 +405,13 @@
text-decoration: none;
}
.ticket-blocker-list a {
border-color: #a97531;
border-color: var(--warning);
}
.ticket-relations-list span,
.ticket-relations-list small,
.ticket-blocker-list span {
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
}
.ticket-timeline {
display: grid;
@@ -435,7 +449,7 @@
}
.ticket-timeline time, .ticket-event-author {
color: var(--text-muted);
font-size: 0.68rem;
font-size: var(--font-size-compact);
}
.ticket-event-author {
margin: 0.2rem 0;
@@ -9,10 +9,10 @@
.section-action {
margin-left: auto;
border: 1px solid var(--line);
border-radius: 999px;
border-radius: var(--radius-soft);
background: var(--bg-subtle);
color: var(--text-strong);
font-size: 0.72rem;
font-size: var(--font-size-compact);
padding: 0.2rem 0.55rem;
cursor: pointer;
text-decoration: none;
@@ -35,7 +35,7 @@
display: grid;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.78rem;
font-size: var(--font-size-body);
}
.worker-launch-form input,
@@ -85,8 +85,8 @@
}
.worker-new-page-header h1 {
color: var(--text-strong);
font-size: clamp(1.6rem, 4vw, 2.4rem);
line-height: 1.05;
font-size: var(--font-size-title);
line-height: var(--line-height-title);
}
.worker-new-page-header p:not(.eyebrow) {
margin-top: var(--space-2);
@@ -110,11 +110,11 @@
.worker-ticket-context span,
.worker-ticket-context code {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
}
.worker-ticket-context a {
color: var(--accent);
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.worker-launch-form {
display: grid;
@@ -128,14 +128,14 @@
.worker-form-section h2 {
margin: 0;
color: var(--text-strong);
font-size: 1rem;
font-size: var(--font-size-body);
}
.worker-launch-sentence {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.95rem;
font-size: var(--font-size-body);
white-space: nowrap;
}
.worker-inline-select {
@@ -154,7 +154,7 @@
.new-working-directory-panel h3 {
margin: 0;
color: var(--text-strong);
font-size: 0.95rem;
font-size: var(--font-size-body);
}
.new-working-directory-fields,
.worker-detail-grid {
@@ -1,3 +1,4 @@
@layer reset, tokens, base, layout, components;
@layer components {
.workspace-catalog-shell {
width: min(1120px, calc(100% - 2rem));
@@ -32,7 +33,7 @@
.workspace-catalog-eyebrow {
color: var(--accent) !important;
font-size: 0.75rem;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
@@ -74,13 +75,22 @@
}
.workspace-card-heading > span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
text-transform: uppercase;
}
.workspace-card-heading > span::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.workspace-card-heading > .workspace-state-active {
border-color: color-mix(in srgb, var(--success) 50%, var(--line));
color: var(--success);
@@ -112,7 +122,7 @@
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.8rem;
font-size: var(--font-size-body);
}
.workspace-create-panel input,
@@ -172,7 +182,7 @@
}
.workspace-switcher label {
font-size: 0.68rem;
font-size: var(--font-size-compact);
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: uppercase;
@@ -181,18 +191,18 @@
.workspace-switcher select {
width: 100%;
padding: 0.45rem 0.55rem;
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.workspace-switcher-actions {
display: flex;
gap: 0.75rem;
font-size: 0.72rem;
font-size: var(--font-size-compact);
}
.workspace-switcher-error {
color: var(--danger);
font-size: 0.7rem;
font-size: var(--font-size-compact);
}
@media (max-width: 760px) {
@@ -1,5 +1,63 @@
@layer reset, tokens, base, layout, components;
@layer components {
.main-content .stack {
display: grid;
gap: var(--space-2);
}
.main-content .card {
min-width: 0;
padding: 0;
}
.main-content .section-note {
margin: 0;
color: var(--text-muted);
}
.main-content .section-state {
margin: var(--space-3) 0 0;
color: var(--text-muted);
}
.main-content .section-state.error {
color: var(--danger);
}
.main-content .muted {
color: var(--text-muted);
}
.main-content .secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: 0.45rem 0.75rem;
background: var(--bg-raised);
color: var(--text-strong);
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.main-content .secondary-button:hover:not(:disabled),
.main-content .secondary-button:focus-visible:not(:disabled) {
border-color: var(--accent);
background: var(--interactive-hover);
}
.main-content .secondary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.main-content small {
display: block;
margin-top: var(--space-1);
}
.workspace-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -10,7 +68,6 @@
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
@@ -24,7 +81,7 @@
color: var(--text-muted);
}
.workspace-action-card span {
font-size: 0.78rem;
font-size: var(--font-size-compact);
}
.workspace-action-card strong {
color: var(--text-strong);
@@ -51,7 +108,6 @@
align-items: start;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
@@ -96,7 +152,7 @@
.state-pill {
flex: 0 0 auto;
color: var(--success);
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
@@ -105,7 +161,7 @@
.objective-summary {
margin: 0;
color: var(--text-muted);
font-size: 0.88rem;
font-size: var(--font-size-compact);
line-height: 1.4;
}
.ticket-meta,
@@ -115,7 +171,7 @@
justify-content: flex-end;
gap: var(--space-2);
color: var(--text-faint);
font-size: 0.78rem;
font-size: var(--font-size-compact);
line-height: 1.35;
text-align: right;
white-space: nowrap;
@@ -155,7 +211,7 @@
}
.detail-heading span {
color: var(--text-muted);
font-size: 0.8rem;
font-size: var(--font-size-compact);
}
.ticket-detail-grid {
margin-bottom: var(--space-4);
@@ -168,70 +224,77 @@
}
.risk-flags span,
.warning-pill {
border-radius: 999px;
padding: 0.18rem 0.55rem;
background: var(--bg-subtle);
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--warning);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.risk-flags span::before,
.warning-pill::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.ticket-body pre {
overflow-x: auto;
margin: 0;
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: var(--text);
white-space: pre-wrap;
}
dl {
.main-content dl {
display: grid;
gap: var(--space-1);
margin: 0;
}
dl > div {
.main-content dl > div {
display: grid;
grid-template-columns: minmax(6.5rem, 10rem) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
dt {
.main-content dt {
color: var(--text-faint);
font-size: 0.72rem;
font-size: var(--font-size-compact);
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
dd {
.main-content dd {
min-width: 0;
margin: 0;
}
dd small {
.main-content dd small {
display: inline;
margin-top: 0;
margin-left: var(--space-2);
}
.table-wrap {
.main-content .table-wrap {
overflow-x: auto;
}
table {
.main-content table {
width: 100%;
border-collapse: collapse;
}
th,
td {
.main-content th,
.main-content td {
border-bottom: 1px solid var(--line);
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
th {
.main-content th {
color: var(--text-faint);
font-size: 0.78rem;
font-size: var(--font-size-compact);
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -251,8 +314,8 @@
.page-header-row h1,
.workers-page-header h1 {
color: var(--text-strong);
font-size: clamp(1.6rem, 4vw, 2.4rem);
line-height: 1.05;
font-size: var(--font-size-title);
line-height: var(--line-height-title);
}
.page-header-row p:not(.breadcrumb),
.workers-page-header p {
@@ -262,7 +325,7 @@
.breadcrumb {
margin-bottom: var(--space-2);
color: var(--text-muted);
font-size: 0.85rem;
font-size: var(--font-size-compact);
}
.breadcrumb a {
color: inherit;
@@ -0,0 +1,129 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Props = {
children: Snippet<[]>;
top?: boolean;
right?: boolean;
bottom?: boolean;
left?: boolean;
fill?: boolean;
invalid?: boolean;
as?: 'span' | 'div';
class?: string;
};
let {
children,
top = true,
right = true,
bottom = true,
left = true,
fill = false,
invalid = false,
as = 'span',
class: className = '',
}: Props = $props();
</script>
<svelte:element
this={as}
class={`bevel ${fill ? 'bevel--fill' : ''} ${className}`.trim()}
data-top={top ? 'true' : 'false'}
data-right={right ? 'true' : 'false'}
data-bottom={bottom ? 'true' : 'false'}
data-left={left ? 'true' : 'false'}
data-invalid={invalid || undefined}
>
{@render children()}
</svelte:element>
<style>
.bevel {
--bevel-radius: var(--radius-soft);
--bevel-top-left-radius: var(--bevel-radius);
--bevel-top-right-radius: var(--bevel-radius);
--bevel-bottom-right-radius: var(--bevel-radius);
--bevel-bottom-left-radius: var(--bevel-radius);
display: inline-grid;
min-width: 0;
border-style: solid;
border-color: var(--line-strong);
border-width: 1px;
border-radius:
var(--bevel-top-left-radius)
var(--bevel-top-right-radius)
var(--bevel-bottom-right-radius)
var(--bevel-bottom-left-radius);
vertical-align: middle;
}
.bevel[data-top='false'] {
--bevel-top-left-radius: 0px;
--bevel-top-right-radius: 0px;
border-top-width: 0;
}
.bevel[data-right='false'] {
--bevel-top-right-radius: 0px;
--bevel-bottom-right-radius: 0px;
border-right-width: 0;
}
.bevel[data-bottom='false'] {
--bevel-bottom-right-radius: 0px;
--bevel-bottom-left-radius: 0px;
border-bottom-width: 0;
}
.bevel[data-left='false'] {
--bevel-bottom-left-radius: 0px;
--bevel-top-left-radius: 0px;
border-left-width: 0;
}
.bevel--fill {
width: 100%;
}
.bevel > :global(*) {
min-width: 0;
border-radius: inherit;
}
.bevel > :global(:is(button, input, select, textarea)) {
border-color: transparent;
}
.bevel[data-invalid='true'],
.bevel:has(> :global([aria-invalid='true'])) {
border-color: var(--danger);
}
.bevel:has(> :global(:is(:disabled, [aria-disabled='true']))) {
opacity: 0.58;
}
@media (forced-colors: active) {
.bevel {
border-color: ButtonText;
forced-color-adjust: auto;
}
.bevel[data-invalid='true'],
.bevel:has(> :global([aria-invalid='true'])) {
border-color: Mark;
}
}
@media print {
.bevel {
border-color: currentColor;
}
}
</style>
@@ -0,0 +1,64 @@
<script lang="ts">
export type BevelLineDirection = 'x' | 'y';
type Props = {
direction?: BevelLineDirection;
length?: string;
decorative?: boolean;
as?: 'span' | 'div';
class?: string;
};
let {
direction = 'x',
length = '100%',
decorative = false,
as = 'span',
class: className = '',
}: Props = $props();
</script>
<svelte:element
this={as}
class={`bevel-line ${className}`.trim()}
data-direction={direction}
role={decorative ? undefined : 'separator'}
aria-orientation={decorative ? undefined : direction === 'x' ? 'horizontal' : 'vertical'}
aria-hidden={decorative ? 'true' : undefined}
style:width={direction === 'x' ? length : undefined}
style:height={direction === 'y' ? length : undefined}
></svelte:element>
<style>
.bevel-line {
display: block;
flex: 0 0 auto;
border-color: var(--line-strong);
pointer-events: none;
}
.bevel-line[data-direction='x'] {
height: 0;
border-top-style: solid;
border-top-width: 1px;
}
.bevel-line[data-direction='y'] {
width: 0;
border-left-style: solid;
border-left-width: 1px;
}
@media (forced-colors: active) {
.bevel-line {
border-color: CanvasText;
forced-color-adjust: auto;
}
}
@media print {
.bevel-line {
border-color: currentColor;
}
}
</style>
@@ -0,0 +1,178 @@
<script lang="ts">
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
type Placement = 'top' | 'bottom';
type Alignment = 'start' | 'center' | 'end';
let {
id,
text,
placement = 'top',
align = 'center',
children,
}: {
id: string;
text: string;
placement?: Placement;
align?: Alignment;
children: Snippet<[descriptionId: string]>;
} = $props();
let open = $state(false);
let pointerInside = false;
let focusInside = false;
let hoverTimer: number | undefined;
let rootElement: HTMLSpanElement | undefined;
let tooltipElement: HTMLSpanElement | undefined;
let tooltipLeft = $state(0);
let tooltipTop = $state(0);
function clearHoverTimer() {
if (hoverTimer === undefined) return;
globalThis.clearTimeout(hoverTimer);
hoverTimer = undefined;
}
function positionTooltip() {
if (!open || rootElement === undefined || tooltipElement === undefined) return;
const root = rootElement.getBoundingClientRect();
const tooltip = tooltipElement.getBoundingClientRect();
const margin = 8;
const gap = 8;
let left = root.left + (root.width - tooltip.width) / 2;
if (align === 'start') left = root.left;
if (align === 'end') left = root.right - tooltip.width;
left = Math.max(margin, Math.min(left, globalThis.innerWidth - tooltip.width - margin));
let top = placement === 'top' ? root.top - tooltip.height - gap : root.bottom + gap;
if (top < margin) top = root.bottom + gap;
if (top + tooltip.height > globalThis.innerHeight - margin) {
top = root.top - tooltip.height - gap;
}
top = Math.max(margin, Math.min(top, globalThis.innerHeight - tooltip.height - margin));
tooltipLeft = Math.round(left);
tooltipTop = Math.round(top);
}
function reveal() {
open = true;
void tick().then(positionTooltip);
}
function showAfterDelay() {
pointerInside = true;
clearHoverTimer();
hoverTimer = globalThis.setTimeout(() => {
reveal();
hoverTimer = undefined;
}, 300);
}
function showImmediately() {
focusInside = true;
clearHoverTimer();
reveal();
}
function dismiss() {
clearHoverTimer();
open = false;
}
function handlePointerLeave() {
pointerInside = false;
clearHoverTimer();
if (!focusInside) open = false;
}
function handleFocusOut(event: FocusEvent) {
const root = event.currentTarget;
if (root instanceof HTMLElement && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
return;
}
focusInside = false;
if (!pointerInside) open = false;
}
function handleWindowKeydown(event: KeyboardEvent) {
if (open && event.key === 'Escape') dismiss();
}
onMount(() => {
const reposition = () => positionTooltip();
globalThis.addEventListener('resize', reposition);
document.addEventListener('scroll', reposition, true);
return () => {
globalThis.removeEventListener('resize', reposition);
document.removeEventListener('scroll', reposition, true);
};
});
onDestroy(clearHoverTimer);
</script>
<svelte:window onkeydown={handleWindowKeydown} />
<span
class="tooltip-root"
bind:this={rootElement}
onpointerenter={showAfterDelay}
onpointerleave={handlePointerLeave}
onfocusin={showImmediately}
onfocusout={handleFocusOut}
>
{@render children(id)}
<span
{id}
class:open
class="tooltip-content"
role="tooltip"
bind:this={tooltipElement}
style:left={`${tooltipLeft}px`}
style:top={`${tooltipTop}px`}
>
{text}
</span>
</span>
<style>
.tooltip-root {
display: inline-flex;
width: fit-content;
}
.tooltip-content {
position: fixed;
z-index: 50;
width: max-content;
max-width: min(20rem, calc(100vw - var(--space-4)));
border: 1px solid var(--line-strong);
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-3);
background: var(--text-strong);
box-shadow: var(--shadow-overlay);
color: var(--bg);
font-family: var(--font-sans);
font-size: var(--font-size-compact);
font-weight: 500;
line-height: 16px;
opacity: 0;
pointer-events: none;
text-align: left;
visibility: hidden;
}
.tooltip-content.open {
opacity: 1;
visibility: visible;
}
@media (prefers-reduced-motion: no-preference) {
.tooltip-content {
transition: opacity 100ms ease;
}
}
</style>
+74 -27
View File
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { setContext } from 'svelte';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
@@ -16,6 +17,11 @@
sidebar = activeSidebar;
});
const headerController = $state<HeaderController>({ content: null });
let sidebarFolded = $state(false);
function toggleSidebar() {
sidebarFolded = !sidebarFolded;
}
provideHeaderController(headerController);
setContext<SidebarController>(SIDEBAR_CONTEXT, {
@@ -25,8 +31,8 @@
<WorkspaceAlerts />
<div class="app-shell">
<SidebarFrame>
<div class="app-shell" class:sidebar-open={!sidebarFolded}>
<SidebarFrame bind:folded={sidebarFolded}>
<GlobalSidebar
currentPath={page.url.pathname}
content={sidebar}
@@ -34,19 +40,39 @@
workspaceError={data.workspaceCatalogError}
/>
</SidebarFrame>
<header class="app-shell__topbar">
<div class="app-shell__topbar-location">
{#if headerController.content}{@render headerController.content()}{/if}
</div>
<nav class="app-shell__topbar-actions" aria-label="Global navigation">
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
</nav>
</header>
<Bevel as="div" class="app-shell__topbar-bevel" top={false} right={false} left={false} fill>
<header class="app-shell__topbar">
<div class="app-shell__topbar-location">
{#if headerController.content}{@render headerController.content()}{/if}
</div>
<nav class="app-shell__topbar-actions" aria-label="Global navigation">
<button
class="app-shell__icon-button app-shell__mobile-sidebar-toggle"
type="button"
aria-label={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
aria-expanded={!sidebarFolded}
title={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
onclick={toggleSidebar}
>
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
{#if sidebarFolded}
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
{:else}
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
{/if}
</svg>
</button>
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
</nav>
</header>
</Bevel>
<main class="app-shell__main">
{@render children()}
</main>
@@ -65,11 +91,14 @@
min-width: 0;
}
.app-shell__topbar {
position: relative;
.app-shell :global(.bevel.app-shell__topbar-bevel) {
z-index: 30;
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.app-shell__topbar {
display: flex;
align-items: center;
justify-content: space-between;
@@ -77,7 +106,6 @@
min-width: 0;
min-height: 3.25rem;
padding: 0 var(--space-5);
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
backdrop-filter: blur(14px);
}
@@ -111,6 +139,14 @@
color: var(--text-muted);
}
.app-shell__mobile-sidebar-toggle {
display: none;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.app-shell__icon {
width: 1.1rem;
height: 1.1rem;
@@ -138,25 +174,36 @@
@media (max-width: 760px) {
.app-shell {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
width: 100vw;
height: auto;
min-height: 100dvh;
overflow: visible;
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.app-shell :global(.bevel.app-shell__topbar-bevel) {
grid-column: 1;
grid-row: 1;
}
.app-shell__topbar {
grid-column: 1;
grid-row: 2;
padding: 0 var(--space-4);
}
.app-shell__mobile-sidebar-toggle {
display: inline-flex;
}
.app-shell__main {
grid-column: 1;
grid-row: 3;
overflow: visible;
grid-row: 2;
overflow-y: auto;
padding: var(--space-5) var(--space-4);
}
.app-shell.sidebar-open .app-shell__main {
display: none;
}
}
</style>
@@ -0,0 +1,51 @@
<script lang="ts">
import { page } from '$app/state';
import { setContext } from 'svelte';
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import {
getSidebarController,
SIDEBAR_CONTEXT,
type SidebarController,
type SidebarSnippet,
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import WorkspaceSidebarFixture from './WorkspaceSidebarFixture.svelte';
import { designLabBasePath } from './workspace-navigation';
import './showroom.css';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
sidebarContent = activeContent;
});
setContext<SidebarController>(SIDEBAR_CONTEXT, {
registerSidebar: sidebarContentOverrides.register,
});
const inSettings = $derived(page.url.pathname.startsWith(`${designLabBasePath}/settings`));
</script>
{#snippet designLabHeader()}
<nav class="design-lab-header" aria-label="Current location">
{#if inSettings}
<a href={designLabBasePath}>Workspace Web UX</a>
<span aria-hidden="true">/</span>
<span aria-current="page">Settings</span>
{:else}
<span aria-current="page">Workspace Web UX</span>
{/if}
</nav>
{/snippet}
{#snippet workspaceSidebar()}
<WorkspaceSidebarFixture currentPath={page.url.pathname} content={sidebarContent} />
{/snippet}
<HeaderOverride content={designLabHeader} />
<SidebarOverride controller={parentSidebarController} sidebar={workspaceSidebar} />
{@render children()}
@@ -0,0 +1,277 @@
<script lang="ts">
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import Tooltip from '$lib/workspace/ui/Tooltip.svelte';
</script>
<div class="showroom">
<section class="showroom-section" aria-labelledby="action-heading">
<h1 id="action-heading" class="showroom-heading">Action</h1>
<BevelLine direction="x" length="100%" decorative />
<div class="action-row">
<Bevel>
<button class="action action--primary" type="button">Create Worker</button>
</Bevel>
<Tooltip id="rerun-help" text="Runs validation against the current Workspace configuration." placement="bottom" align="start">
{#snippet children(descriptionId)}
<Bevel>
<button class="action action--secondary" type="button" aria-describedby={descriptionId}>Re-run checks</button>
</Bevel>
{/snippet}
</Tooltip>
<button class="action action--text" type="button">View log</button>
<Bevel>
<button class="action action--destructive" type="button">Remove repository</button>
</Bevel>
<Bevel>
<button class="action action--secondary" type="button" disabled>Running checks…</button>
</Bevel>
</div>
<div class="border-matrix">
<Bevel as="div" fill>
<div class="tactile-group" role="group" aria-label="Bordered validation controls">
<span>
<strong>Closed border</strong>
<small>3 checks passed at 14:32</small>
</span>
<Bevel>
<button class="compact-control" type="button" aria-pressed="true">Live</button>
</Bevel>
</div>
</Bevel>
<Bevel as="div" fill>
<div class="tactile-group" role="group" aria-label="Read-only bordered controls">
<span>
<strong>Read-only border</strong>
<small>Read-only control group</small>
</span>
<span class="status"><span class="status__marker" data-tone="muted"></span>Locked</span>
</div>
</Bevel>
<Bevel as="div" right={false} bottom={false} fill>
<div class="tactile-group" role="group" aria-label="Top and left edge">
<span>
<strong>Top + left</strong>
<small>Only the shared top-left corner is rounded</small>
</span>
</div>
</Bevel>
<Bevel as="div" top={false} left={false} fill>
<div class="tactile-group" role="group" aria-label="Right and bottom groove">
<span>
<strong>Right + bottom</strong>
<small>Only the shared bottom-right corner is rounded</small>
</span>
</div>
</Bevel>
</div>
</section>
<section class="showroom-section" aria-labelledby="line-heading">
<h2 id="line-heading" class="showroom-heading">Line</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="line-showcase">
<div class="line-sample line-sample--horizontal">
<span class="line-sample__label">Horizontal · 100%</span>
<BevelLine direction="x" length="100%" />
<span class="line-sample__label">Horizontal · 160px</span>
<BevelLine direction="x" length="160px" />
</div>
<div class="line-sample line-sample--vertical">
<span>Before</span>
<BevelLine direction="y" length="72px" />
<span>After</span>
</div>
</div>
</section>
<section class="showroom-section" aria-labelledby="status-heading">
<h2 id="status-heading" class="showroom-heading">Status</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="status-row">
<span class="status"><span class="status__marker" data-tone="success"></span>Ready</span>
<span class="status"><span class="status__marker" data-tone="accent"></span>Running</span>
<span class="status"><span class="status__marker" data-tone="warning"></span>Needs review</span>
<span class="status"><span class="status__marker" data-tone="danger"></span>Failed</span>
<span class="status"><span class="status__marker" data-tone="muted"></span>Unavailable</span>
</div>
</section>
<section class="showroom-section" aria-labelledby="feedback-heading">
<h2 id="feedback-heading" class="showroom-heading">Feedback</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="feedback-grid">
<div class="feedback" role="status">
<span class="status"><span class="status__marker status__marker--loading" data-tone="accent"></span>Loading Merge Requests…</span>
</div>
<div class="feedback">
<strong>No Objectives.</strong>
</div>
<div class="feedback feedback--error" role="alert">
<div>
<strong>Merge failed.</strong>
<p>Target branch changed after review. Refresh the target before retrying.</p>
</div>
<Bevel>
<button class="action action--secondary" type="button">Refresh target</button>
</Bevel>
</div>
<div class="feedback feedback--permission">
<div>
<strong>Repository Access is read-only.</strong>
<p>Workspace owner permission is required to change credentials.</p>
</div>
</div>
</div>
</section>
<section id="resource-row" class="showroom-section" aria-labelledby="resource-row-heading">
<h2 id="resource-row-heading" class="showroom-heading">Resource row</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="resource-list">
<a class="resource-row" href="/design-lab/workspace-web-ux?ticket=YOI-248">
<span class="resource-row__identity">
<code>YOI-248</code>
<strong>Preserve nested navigation ownership across route transitions</strong>
</span>
<span class="resource-row__meta">High priority</span>
<span class="status"><span class="status__marker" data-tone="warning"></span>Blocked</span>
</a>
<BevelLine direction="x" length="100%" decorative />
<a class="resource-row" href="/design-lab/workspace-web-ux?ticket=YOI-251">
<span class="resource-row__identity">
<code>YOI-251</code>
<strong>Validate repository access projection</strong>
</span>
<span class="resource-row__meta">Updated 14 min ago</span>
<span class="status"><span class="status__marker" data-tone="success"></span>Ready</span>
</a>
<BevelLine direction="x" length="100%" decorative />
<a class="resource-row" href="/design-lab/workspace-web-ux?ticket=YOI-252">
<span class="resource-row__identity">
<code>YOI-252-WITH-A-LONG-CANONICAL-RESOURCE-KEY</code>
<strong>Keep very long translated resource names readable without pushing the current state out of the row</strong>
</span>
<span class="resource-row__meta">No owner</span>
<span class="status"><span class="status__marker" data-tone="muted"></span>Draft</span>
</a>
</div>
</section>
<section class="showroom-section" aria-labelledby="comparable-heading">
<h2 id="comparable-heading" class="showroom-heading">Comparable data</h2>
<BevelLine direction="x" length="100%" decorative />
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable comparison must be keyboard-focusable.) -->
<div class="table-scroll" tabindex="0" role="region" aria-label="Runtime comparison">
<table>
<thead>
<tr>
<th scope="col">Runtime</th>
<th scope="col">Kind</th>
<th scope="col">State</th>
<th scope="col">
<Tooltip id="last-seen-help" text="Most recent observation from the Runtime host." placement="top" align="end">
{#snippet children(descriptionId)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Focusable column label exposes contextual help to keyboard users.) -->
<span class="column-help" tabindex="0" aria-describedby={descriptionId}>Last seen</span>
{/snippet}
</Tooltip>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><span>Local development</span><code>local</code></th>
<td>Built-in</td>
<td><span class="status"><span class="status__marker" data-tone="success"></span>Available</span></td>
<td>Now</td>
</tr>
<tr>
<th scope="row"><span>Remote Linux runner with a long operational name</span><code>runner-eu-central-01</code></th>
<td>Remote</td>
<td><span class="status"><span class="status__marker" data-tone="danger"></span>Unavailable</span></td>
<td>2 hours ago</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="showroom-split">
<section class="showroom-section" aria-labelledby="key-value-heading">
<h2 id="key-value-heading" class="showroom-heading">Key-value</h2>
<BevelLine direction="x" length="100%" decorative />
<dl class="key-value">
<div><dt>Repository</dt><dd>yoi</dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Default selector</dt><dd><code>refs/heads/main</code></dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Access</dt><dd><span class="status"><span class="status__marker" data-tone="success"></span>Configured</span></dd></div>
</dl>
</section>
<section class="showroom-section" aria-labelledby="tooltip-heading">
<h2 id="tooltip-heading" class="showroom-heading">Tooltip</h2>
<BevelLine direction="x" length="100%" decorative />
<Tooltip id="source-help" text="The source that currently supplies this configuration value." placement="bottom" align="start">
{#snippet children(descriptionId)}
<button class="help-label" type="button" aria-describedby={descriptionId}>Configuration source <span aria-hidden="true">?</span></button>
{/snippet}
</Tooltip>
</section>
</div>
<section class="showroom-section" aria-labelledby="disclosure-heading">
<h2 id="disclosure-heading" class="showroom-heading">Disclosure</h2>
<BevelLine direction="x" length="100%" decorative />
<details class="disclosure">
<summary>Technical details</summary>
<dl class="technical-values">
<div><dt>Revision</dt><dd><code>rev_01J8WZ7C8AV6P3Y2N0M4KQ9B1F</code></dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Digest</dt><dd><code>sha256:2f8d9bb168afb7b19f447463f3fbe8b4728d8e87a64eaf1afbea1ca4d4c41f21</code></dd></div>
</dl>
</details>
</section>
<section class="showroom-section" aria-labelledby="color-heading">
<h2 id="color-heading" class="showroom-heading">Color</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="token-grid">
<div class="color-token"><span style="--sample: var(--bg)"></span><code>--bg</code></div>
<div class="color-token"><span style="--sample: var(--bg-raised)"></span><code>--bg-raised</code></div>
<div class="color-token"><span style="--sample: var(--bg-subtle)"></span><code>--bg-subtle</code></div>
<div class="color-token"><span style="--sample: var(--text-strong)"></span><code>--text-strong</code></div>
<div class="color-token"><span style="--sample: var(--accent)"></span><code>--accent</code></div>
<div class="color-token"><span style="--sample: var(--success)"></span><code>--success</code></div>
<div class="color-token"><span style="--sample: var(--warning)"></span><code>--warning</code></div>
<div class="color-token"><span style="--sample: var(--danger)"></span><code>--danger</code></div>
</div>
</section>
<div class="showroom-split">
<section class="showroom-section" aria-labelledby="typography-heading">
<h2 id="typography-heading" class="showroom-heading">Typography</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="type-samples">
<div><span class="type-route">Workspace identity</span><code>24 / 32</code></div>
<div><span class="type-body">Repository access</span><code>14 / 20</code></div>
<div><span class="type-meta">refs/heads/main</span><code>12 / 16</code></div>
</div>
</section>
<section class="showroom-section" aria-labelledby="spacing-heading">
<h2 id="spacing-heading" class="showroom-heading">Spacing</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="space-samples">
<div><span style="--size: var(--space-1)"></span><code>4</code></div>
<div><span style="--size: var(--space-2)"></span><code>8</code></div>
<div><span style="--size: var(--space-3)"></span><code>12</code></div>
<div><span style="--size: var(--space-4)"></span><code>16</code></div>
<div><span style="--size: var(--space-5)"></span><code>24</code></div>
<div><span style="--size: var(--space-6)"></span><code>32</code></div>
</div>
</section>
</div>
</div>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { designLabBasePath, workspaceNavigation, workspaceWorkers } from './workspace-navigation';
let {
currentPath,
content = null,
}: {
currentPath: string;
content?: Snippet<[]> | null;
} = $props();
const settingsPath = `${designLabBasePath}/settings`;
</script>
<div class="workspace-sidebar">
<header class="sidebar-header">
<nav class="workspace-sidebar-shortcuts" aria-label="Workspace shortcuts">
<a
class="workspace-sidebar-shortcut"
class:active={currentPath === designLabBasePath}
href={designLabBasePath}
aria-label="Workspace home"
title="Workspace home"
aria-current={currentPath === designLabBasePath ? 'page' : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m3 11 9-8 9 8"></path>
<path d="M5 10v10h14V10"></path>
<path d="M9 20v-6h6v6"></path>
</svg>
</a>
<a
class="workspace-sidebar-shortcut"
class:active={currentPath.startsWith(settingsPath)}
href={settingsPath}
aria-label="Workspace settings"
title="Workspace settings"
aria-current={currentPath === settingsPath ? 'page' : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 5h16M4 12h16M4 19h16"></path>
<path d="M8 3v4M16 10v4M10 17v4"></path>
</svg>
</a>
</nav>
</header>
{#if content}
{@render content()}
{:else}
<nav class="sidebar-sections" aria-label="Workspace sections">
{#each workspaceNavigation as item}
{#if item.children}
<section class="sidebar-nav-section sidebar-nav-section--category">
<h2 class="sidebar-nav-section__header">{item.label}</h2>
{#each item.children as child}
<a class="sidebar-link" href={child.href}>{child.label}</a>
{/each}
</section>
{:else if item.label === 'Workers'}
<section class="sidebar-nav-section" aria-labelledby="design-lab-workers-heading">
<div class="section-heading-row">
<h2 id="design-lab-workers-heading">
<a class="section-heading-link" href={item.href}>workers</a>
</h2>
<a class="section-action" href={`${designLabBasePath}?worker=new`}>New</a>
<span class="section-count">{workspaceWorkers.length}</span>
</div>
<ul class="nav-list" aria-label="Workers">
{#each workspaceWorkers as worker}
<li class="worker-nav-item">
<a class="worker-nav-link" href={`${designLabBasePath}?worker=${worker.key}`}>
<span class="worker-status-indicator" aria-hidden="true">
<span class="worker-status-dot"></span>
</span>
<span class="worker-nav-label" title={worker.label}>{worker.label}</span>
<small class="worker-nav-meta" title={`${worker.state} · ${worker.repository}`}>
{worker.state} · {worker.repository}
</small>
</a>
</li>
{/each}
</ul>
</section>
{:else}
<section class="sidebar-nav-section sidebar-nav-section--resource">
<a class="sidebar-link" href={item.href}>{item.label}</a>
</section>
{/if}
{/each}
</nav>
{/if}
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import { page } from '$app/state';
import { setContext } from 'svelte';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import {
getSidebarController,
SIDEBAR_CONTEXT,
type SidebarController,
type SidebarSnippet,
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import SettingsSidebarFixture from './SettingsSidebarFixture.svelte';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
sidebarContent = activeContent;
});
setContext<SidebarController>(SIDEBAR_CONTEXT, {
registerSidebar: sidebarContentOverrides.register,
});
</script>
{#snippet settingsSidebar()}
<SettingsSidebarFixture currentPath={page.url.pathname} content={sidebarContent} />
{/snippet}
<SidebarOverride controller={parentSidebarController} sidebar={settingsSidebar} />
{@render children()}
@@ -0,0 +1,100 @@
<script lang="ts">
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import Tooltip from '$lib/workspace/ui/Tooltip.svelte';
function keepSampleFormLocal(event: SubmitEvent) {
event.preventDefault();
}
</script>
<div class="showroom showroom--settings">
<section class="showroom-section" aria-labelledby="form-field-heading">
<h1 id="form-field-heading" class="showroom-heading">Form field</h1>
<BevelLine direction="x" length="100%" decorative />
<form class="sample-form" onsubmit={keepSampleFormLocal}>
<div class="form-grid">
<label class="form-field">
<span>Runtime label</span>
<Bevel fill>
<input name="runtime-label" value="Remote Linux runner" />
</Bevel>
</label>
<label class="form-field">
<span>Endpoint</span>
<Bevel fill invalid>
<input
name="runtime-endpoint"
value="http://runner.internal.example:8787/api/runtime"
aria-describedby="endpoint-constraint endpoint-error"
aria-invalid="true"
/>
</Bevel>
<small id="endpoint-constraint">Use an HTTPS endpoint reachable from the Workspace server.</small>
<span id="endpoint-error" class="field-error">This endpoint uses HTTP. Enter an HTTPS URL.</span>
</label>
<label class="form-field">
<span>Architecture</span>
<Bevel fill>
<select name="architecture">
<option>arm64</option>
<option selected>x86_64</option>
</select>
</Bevel>
</label>
</div>
<BevelLine direction="x" length="100%" decorative />
<div class="form-footer">
<p class="unsaved"><span class="status__marker" data-tone="warning"></span>Unsaved changes</p>
<Bevel>
<button class="action action--primary" type="submit">Save runtime</button>
</Bevel>
</div>
</form>
</section>
<section class="showroom-section" aria-labelledby="operation-error-heading">
<h2 id="operation-error-heading" class="showroom-heading">Operation error</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="feedback feedback--error" role="alert">
<div>
<strong>Runtime was not saved.</strong>
<p>The endpoint did not respond within 10 seconds. Check the address and try again.</p>
</div>
<Tooltip id="retry-runtime-help" text="Checks this endpoint again without saving other fields." placement="bottom" align="end">
{#snippet children(descriptionId)}
<Bevel>
<button class="action action--secondary" type="button" aria-describedby={descriptionId}>Test endpoint</button>
</Bevel>
{/snippet}
</Tooltip>
</div>
</section>
<section class="showroom-section" aria-labelledby="permission-boundary-heading">
<h2 id="permission-boundary-heading" class="showroom-heading">Permission boundary</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="permission-read-view">
<dl class="key-value">
<div><dt>Credential source</dt><dd>Workspace secret store</dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Access</dt><dd><span class="status"><span class="status__marker" data-tone="muted"></span>Read-only</span></dd></div>
</dl>
<p>Workspace owner permission is required to replace repository credentials.</p>
</div>
</section>
<section class="showroom-section" aria-labelledby="empty-heading">
<h2 id="empty-heading" class="showroom-heading">Empty</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="feedback">
<strong>No Profile Sources.</strong>
<Bevel>
<button class="action action--secondary" type="button">Add profile source</button>
</Bevel>
</div>
</section>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { settingsBasePath, settingsNavigation } from './settings-navigation';
let {
currentPath,
content = null,
}: {
currentPath: string;
content?: Snippet<[]> | null;
} = $props();
</script>
<div class="settings-sidebar">
{#if content}
{@render content()}
{:else}
<nav class="sidebar-sections" aria-label="Settings sections">
<div class="sidebar-nav-section">
<div class="sidebar-list">
{#each settingsNavigation as item, index}
<a
class="sidebar-link"
class:active={index === 0 && currentPath === settingsBasePath}
href={item.href}
aria-current={index === 0 && currentPath === settingsBasePath ? 'page' : undefined}
>
<span class="sidebar-link-label">{item.label}</span>
</a>
{/each}
</div>
</div>
</nav>
{/if}
</div>
@@ -0,0 +1,12 @@
import { designLabBasePath } from '../workspace-navigation';
export const settingsBasePath = `${designLabBasePath}/settings`;
export const settingsNavigation = [
{ label: 'Runtimes', href: settingsBasePath },
{ label: 'Configuration Sources', href: `${settingsBasePath}?section=configuration-sources` },
{ label: 'Repositories', href: `${settingsBasePath}?section=repositories` },
{ label: 'Repository Access', href: `${settingsBasePath}?section=repository-access` },
{ label: 'Profile Sources', href: `${settingsBasePath}?section=profile-sources` },
{ label: 'Workspace Identity', href: `${settingsBasePath}?section=workspace-identity` },
] as const;
@@ -0,0 +1,683 @@
@layer reset, tokens, base, layout, components;
@layer components {
.design-lab-header {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 20px;
}
.design-lab-header a {
color: var(--text-muted);
text-decoration: none;
}
.design-lab-header a:hover {
color: var(--text-strong);
text-decoration: underline;
text-underline-offset: 3px;
}
.design-lab-header [aria-current="page"] {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-sidebar .section-action {
margin-left: auto;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-soft);
color: var(--accent);
font-size: var(--font-size-compact);
font-weight: 650;
line-height: 16px;
text-decoration: none;
}
.workspace-sidebar .section-action:hover,
.workspace-sidebar .section-action:focus-visible {
background: var(--interactive-hover);
}
.showroom {
width: min(100%, 1180px);
margin: 0 auto;
padding: clamp(var(--space-5), 4vw, var(--space-6));
display: grid;
gap: var(--space-6);
}
.showroom--settings {
width: min(100%, 900px);
margin-inline: 0;
}
.showroom-section {
min-width: 0;
display: grid;
align-content: start;
gap: var(--space-3);
}
.showroom-heading {
margin: 0;
padding-bottom: var(--space-2);
color: var(--text-strong);
font-size: var(--font-size-body);
font-weight: 650;
line-height: 20px;
}
.showroom-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: calc(var(--space-6) * 1.5);
}
.action-row,
.status-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.action {
min-height: 36px;
border: 1px solid transparent;
border-radius: var(--radius-soft);
padding: 7px var(--space-4);
background: transparent;
color: inherit;
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 20px;
cursor: pointer;
}
.action--primary {
background: var(--accent);
color: var(--bg);
}
.action--primary:hover {
background: color-mix(in oklab, var(--accent), var(--text-strong) 14%);
}
.action--secondary {
background: var(--bg-raised);
color: var(--text-strong);
}
.action--secondary:hover {
background: var(--interactive-hover);
}
.action--destructive {
background: color-mix(in oklab, var(--danger) 14%, var(--bg-raised));
color: var(--danger);
}
.action--destructive:hover {
background: color-mix(in oklab, var(--danger) 22%, var(--bg-raised));
}
.action--text {
padding-inline: var(--space-2);
color: var(--accent);
}
.action--text:hover {
background: var(--interactive-hover);
color: var(--accent-muted);
}
.action:disabled {
cursor: not-allowed;
}
.compact-control {
min-height: 30px;
border: 1px solid transparent;
border-radius: inherit;
padding: var(--space-1) var(--space-3);
background: var(--interactive-selected);
color: var(--text-strong);
cursor: pointer;
font: 600 var(--font-size-compact) / var(--line-height-compact)
var(--font-mono);
}
.border-matrix {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.tactile-group {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: inherit;
color: var(--text);
}
.tactile-group > span {
display: grid;
gap: var(--space-1);
}
.tactile-group small {
color: var(--text-muted);
font-family: var(--font-mono);
}
.line-showcase {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: var(--space-6);
}
.line-sample--horizontal {
display: grid;
align-content: center;
gap: var(--space-3);
}
.line-sample--vertical {
display: flex;
min-height: 96px;
align-items: center;
gap: var(--space-3);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
}
.line-sample__label {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
}
.status {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 18px;
white-space: nowrap;
}
.status__marker {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--text-faint);
}
.status__marker[data-tone="success"] {
background: var(--success);
}
.status__marker[data-tone="accent"] {
background: var(--accent);
}
.status__marker[data-tone="warning"] {
background: var(--warning);
}
.status__marker[data-tone="danger"] {
background: var(--danger);
}
.status__marker--loading {
border: 2px solid color-mix(in oklch, var(--accent), transparent 65%);
border-top-color: var(--accent);
background: transparent !important;
animation: showroom-spin 800ms linear infinite;
}
@keyframes showroom-spin {
to {
transform: rotate(360deg);
}
}
.feedback-grid {
display: grid;
gap: var(--space-2);
}
.feedback {
min-height: 48px;
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
border-left: 3px solid var(--line-strong);
background: var(--bg-raised);
}
.feedback strong {
color: var(--text-strong);
font-size: var(--font-size-body);
}
.feedback p {
margin: 2px 0 0;
color: var(--text-muted);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
.feedback--error {
border-left-color: var(--danger);
}
.feedback--permission {
border-left-color: var(--warning);
}
.resource-list {
min-width: 0;
margin-inline: var(--space-2);
}
.resource-row {
min-width: 0;
min-height: 64px;
padding: var(--space-3) var(--space-2);
display: grid;
grid-template-columns:
minmax(0, 1fr) minmax(110px, auto) minmax(90px, auto);
align-items: center;
gap: var(--space-4);
color: inherit;
text-decoration: none;
}
.resource-row:hover {
border-radius: var(--radius-soft);
background: var(--interactive-hover);
}
.resource-row__identity {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.resource-row__identity strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-strong);
font-weight: 600;
}
.resource-row__identity code {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: 16px;
}
.resource-row__meta {
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: 16px;
text-align: right;
}
.table-scroll {
max-width: 100%;
overflow-x: auto;
}
.table-scroll:focus-visible {
border-radius: var(--radius-soft);
}
.showroom table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
text-align: left;
}
.showroom th,
.showroom td {
padding: var(--space-3) var(--space-4);
vertical-align: middle;
}
.showroom tbody tr:nth-child(even) {
background: var(--bg-raised);
}
.showroom thead th {
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 16px;
}
.showroom tbody th {
max-width: 360px;
color: var(--text-strong);
font-size: var(--font-size-compact);
font-weight: 600;
}
.showroom tbody th span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.showroom tbody th code {
display: block;
margin-top: 2px;
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 400;
}
.column-help {
color: inherit;
text-decoration: underline dotted;
text-underline-offset: 0.2em;
cursor: help;
}
.key-value,
.technical-values {
margin: 0 var(--space-2);
}
.key-value > div:not(.bevel-line),
.technical-values > div:not(.bevel-line) {
padding: var(--space-2) 0;
display: grid;
grid-template-columns: minmax(110px, 0.65fr) minmax(0, 1fr);
gap: var(--space-4);
}
.key-value dt,
.technical-values dt {
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 18px;
}
.key-value dd,
.technical-values dd {
min-width: 0;
margin: 0;
color: var(--text-strong);
font-size: var(--font-size-compact);
line-height: 18px;
}
.technical-values code {
overflow-wrap: anywhere;
}
.help-label {
min-height: 36px;
border: 0;
border-bottom: 1px dotted var(--line-strong);
background: transparent;
color: var(--text-strong);
font-weight: 600;
cursor: help;
}
.help-label span {
color: var(--text-muted);
}
.disclosure {
padding-block: var(--space-2);
}
.disclosure summary {
padding: var(--space-3) var(--space-2);
color: var(--text-strong);
font-weight: 600;
cursor: pointer;
}
.disclosure summary:hover {
border-radius: var(--radius-soft);
background: var(--interactive-hover);
}
.technical-values {
padding: 0 var(--space-2) var(--space-4);
}
.token-grid {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: var(--space-4);
}
.color-token {
min-width: 0;
display: grid;
gap: var(--space-2);
}
.color-token span {
height: 48px;
border: 1px solid var(--line);
background: var(--sample);
}
.color-token code,
.type-samples code,
.space-samples code {
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: 16px;
}
.type-samples {
display: grid;
gap: var(--space-4);
}
.type-samples > div {
display: grid;
gap: var(--space-1);
}
.type-route {
color: var(--text-strong);
font-size: var(--font-size-title);
font-weight: 650;
line-height: var(--line-height-title);
}
.type-body {
color: var(--text);
font-size: var(--font-size-body);
line-height: 20px;
}
.type-meta {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
line-height: 16px;
}
.space-samples {
display: grid;
gap: var(--space-2);
}
.space-samples > div {
display: grid;
grid-template-columns: minmax(0, 1fr) 32px;
align-items: center;
gap: var(--space-3);
}
.space-samples span {
width: var(--size);
height: 8px;
min-width: 4px;
background: var(--accent-muted);
}
.sample-form {
display: grid;
gap: var(--space-5);
margin-inline: var(--space-2);
}
.form-grid {
display: grid;
gap: var(--space-5);
}
.form-field {
display: grid;
gap: var(--space-2);
color: var(--text-strong);
font-size: var(--font-size-compact);
font-weight: 600;
}
.form-field input,
.form-field select {
width: 100%;
min-height: 40px;
border: 1px solid transparent;
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-3);
background: var(--bg);
color: var(--text-strong);
}
.form-field small {
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 400;
line-height: 16px;
}
.field-error {
color: var(--danger);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 16px;
}
.form-footer {
padding-top: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.unsaved {
margin: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-strong);
font-size: var(--font-size-compact);
font-weight: 600;
}
.permission-read-view {
display: grid;
gap: var(--space-3);
}
.permission-read-view p {
margin: 0;
color: var(--text-muted);
font-size: var(--font-size-body);
}
@media (prefers-reduced-motion: reduce) {
.status__marker--loading {
animation: none;
}
}
@media (max-width: 760px) {
.showroom {
padding: var(--space-5) var(--space-4);
gap: var(--space-5);
}
.showroom-split,
.line-showcase,
.border-matrix {
grid-template-columns: 1fr;
gap: var(--space-6);
}
.resource-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2) var(--space-3);
}
.resource-row__identity {
grid-column: 1 / -1;
}
.resource-row__meta {
text-align: left;
}
.feedback {
align-items: flex-start;
flex-direction: column;
}
.token-grid {
grid-template-columns: repeat(2, minmax(100px, 1fr));
}
}
@media (max-width: 420px) {
.key-value > div,
.technical-values > div {
grid-template-columns: 1fr;
gap: var(--space-1);
}
.form-footer,
.tactile-group {
align-items: stretch;
flex-direction: column;
}
}
}
@@ -0,0 +1,37 @@
export const designLabBasePath = '/design-lab/workspace-web-ux';
export type WorkspaceNavigationItem = {
label: string;
href: string;
children?: WorkspaceNavigationItem[];
};
export const workspaceNavigation: WorkspaceNavigationItem[] = [
{ label: 'Tickets', href: `${designLabBasePath}?resource=tickets` },
{ label: 'Objectives', href: `${designLabBasePath}?resource=objectives` },
{ label: 'Merge Requests', href: `${designLabBasePath}?resource=merge-requests` },
{
label: 'Memory',
href: `${designLabBasePath}?resource=memory`,
children: [
{ label: 'Document', href: `${designLabBasePath}?resource=memory-document` },
{ label: 'Staging', href: `${designLabBasePath}?resource=memory-staging` },
],
},
{ label: 'Workers', href: `${designLabBasePath}?resource=workers` },
];
export const workspaceWorkers = [
{
key: 'wrk-language-review',
label: 'Language review',
state: 'Running',
repository: 'yoi',
},
{
key: 'wrk-accessibility-check',
label: 'Accessibility check with a deliberately long display name',
state: 'Idle',
repository: 'workspace-web',
},
] as const;
@@ -81,7 +81,7 @@
.memory-document-summary span {
color: var(--text-muted);
display: block;
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -90,7 +90,7 @@
.memory-document-summary strong {
color: var(--text);
display: block;
font-size: 0.95rem;
font-size: var(--font-size-compact);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
@@ -101,8 +101,8 @@
border-radius: 0.9rem;
color: var(--text);
font-family: var(--font-mono);
font-size: 0.88rem;
line-height: 1.6;
font-size: var(--font-size-body);
line-height: var(--line-height-body);
margin: 1rem 0 0;
overflow: auto;
padding: 1rem;
@@ -170,7 +170,7 @@
.staging-meta dt {
color: var(--text-muted);
display: block;
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -179,7 +179,7 @@
.staging-summary-grid strong {
color: var(--text);
display: block;
font-size: 0.95rem;
font-size: var(--font-size-compact);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
@@ -206,7 +206,7 @@
.staging-entry h3 {
color: var(--text-strong);
font-size: 1rem;
font-size: var(--font-size-body);
margin: 0.35rem 0 0;
}
@@ -225,7 +225,7 @@
border-radius: 999px;
color: var(--accent);
display: inline-flex;
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.18rem 0.55rem;
@@ -384,7 +384,7 @@
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: var(--font-size-compact); }
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
@@ -187,9 +187,9 @@
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--surface);
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
@@ -276,9 +276,9 @@
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--surface);
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
@@ -2120,7 +2120,7 @@
color: var(--text-muted);
padding: 0.42rem 0.65rem;
font: inherit;
font-size: 0.7rem;
font-size: var(--font-size-compact);
font-weight: 700;
cursor: pointer;
}
@@ -2147,7 +2147,7 @@
.rewind-targets h3 {
margin: 0;
font-size: 0.9rem;
font-size: var(--font-size-body);
}
.rewind-target-list {
@@ -2209,7 +2209,7 @@
background: var(--bg-raised);
color: var(--text-muted);
cursor: pointer;
font-size: 0.76rem;
font-size: var(--font-size-compact);
font-weight: 800;
padding: 0.35rem 0.5rem;
text-align: left;
@@ -2240,7 +2240,7 @@
.pending-submissions {
margin: 0 var(--space-3);
color: var(--muted);
font-size: 0.75rem;
font-size: var(--font-size-compact);
}
.pending-submissions ol {
@@ -2306,7 +2306,7 @@
.console-side-panel dt {
color: var(--text-muted);
font-size: 0.72rem;
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
@@ -2320,7 +2320,7 @@
.metadata-details {
color: var(--text-muted);
font-size: 0.84rem;
font-size: var(--font-size-compact);
}
.metadata-details summary {
@@ -2380,7 +2380,7 @@
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.2rem 0.5rem;
font: 500 0.75rem/1.2 var(--font-mono);
font: 500 var(--font-size-compact) / var(--line-height-compact) var(--font-mono);
}
.composer-attachment.failed {
@@ -2469,12 +2469,12 @@
gap: var(--space-2);
align-items: center;
color: var(--text-muted);
font-size: 0.86rem;
font-size: var(--font-size-compact);
}
.composer-notice {
color: var(--text-muted);
font-size: 0.86rem;
font-size: var(--font-size-compact);
}
.composer-actions {
+132
View File
@@ -0,0 +1,132 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Bevel draws a plain selectable border around semantic children", async () => {
const source = await Deno.readTextFile(
new URL("../src/lib/workspace/ui/Bevel.svelte", import.meta.url),
);
const appCss = await Deno.readTextFile(
new URL("../src/app.css", import.meta.url),
);
const designLanguage = await Deno.readTextFile(
new URL(
"../../../docs/development/ui-ux/design-language.md",
import.meta.url,
),
);
for (
const contract of [
"children: Snippet<[]>",
"top?: boolean",
"right?: boolean",
"bottom?: boolean",
"left?: boolean",
"data-invalid={invalid || undefined}",
"border-style: solid",
"border-color: var(--line-strong)",
"border-width: 1px",
"aria-invalid='true'",
"forced-colors: active",
]
) {
assert(source.includes(contract), `Bevel must preserve ${contract}`);
}
assert(
source.includes("data-top={top ? 'true' : 'false'}") &&
source.includes("data-right={right ? 'true' : 'false'}") &&
source.includes("data-bottom={bottom ? 'true' : 'false'}") &&
source.includes("data-left={left ? 'true' : 'false'}") &&
source.includes("border-top-width: 0") &&
source.includes("border-right-width: 0") &&
source.includes("border-bottom-width: 0") &&
source.includes("border-left-width: 0") &&
source.includes("--bevel-top-left-radius: 0px") &&
source.includes("--bevel-bottom-right-radius: 0px"),
"Bevel must draw only selected sides and round only corners shared by enabled sides",
);
assert(
!source.includes("BevelProfile") &&
!source.includes("BevelDepth") &&
!source.includes("profile") &&
!source.includes("depth") &&
!source.includes("pressed") &&
!source.includes("::before") &&
!source.includes("::after") &&
!source.includes("gradient") &&
!source.includes("mask") &&
!source.includes("color-mix") &&
!source.includes("cos(") &&
!source.includes("pow(") &&
!appCss.includes("--bevel-highlight") &&
!appCss.includes("--bevel-shadow") &&
!appCss.includes("--bevel-highlight-curve") &&
!appCss.includes("--bevel-face-width"),
"Bevel must remain a plain border without lighting or depth simulation",
);
assert(
!source.includes("background:") &&
!source.includes("background-color:") &&
source.includes("button, input, select, textarea"),
"Bevel must not own surface color and must keep semantic controls as children",
);
assert(
designLanguage.includes("1pxの単色borderを描くvisual wrapper") &&
designLanguage.includes(
"照明、raised/inset、ridge/grooveを表現しない",
) &&
designLanguage.includes("text/content areaへ適用する"),
"The design language must define Bevel as a plain border without surface ownership",
);
});
Deno.test("Workspace showroom exercises closed and adjacent borders", async () => {
const workspaceSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/+page.svelte",
import.meta.url,
),
);
const settingsSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/settings/+page.svelte",
import.meta.url,
),
);
const showroomCss = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/showroom.css",
import.meta.url,
),
);
const tactileGroupRule = showroomCss.match(
/\.tactile-group \{([\s\S]*?)\n \}/,
)?.[1];
assert(
workspaceSource.includes('<div class="border-matrix">') &&
workspaceSource.includes("right={false} bottom={false}") &&
workspaceSource.includes("top={false} left={false}") &&
workspaceSource.includes("Closed border") &&
workspaceSource.includes("Read-only border") &&
!workspaceSource.includes("profile=") &&
!workspaceSource.includes("depth=") &&
!workspaceSource.includes("<Bevel pressed"),
"Workspace showroom must demonstrate closed and adjacent plain borders",
);
assert(
tactileGroupRule && !tactileGroupRule.includes("background"),
"Showroom bordered items must leave their content area background unset",
);
assert(
settingsSource.includes("<Bevel fill>") &&
settingsSource.includes("<Bevel fill invalid>") &&
settingsSource.includes('aria-invalid="true"') &&
!settingsSource.includes("profile=") &&
!settingsSource.includes("depth="),
"Settings showroom must demonstrate plain and invalid borders",
);
});
@@ -0,0 +1,132 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("BevelLine exposes a semantic directional separator", async () => {
const source = await Deno.readTextFile(
new URL("../src/lib/workspace/ui/BevelLine.svelte", import.meta.url),
);
for (
const contract of [
"export type BevelLineDirection = 'x' | 'y'",
"length?: string",
"as?: 'span' | 'div'",
"role={decorative ? undefined : 'separator'}",
"'horizontal' : 'vertical'",
"style:width={direction === 'x' ? length : undefined}",
"style:height={direction === 'y' ? length : undefined}",
"pointer-events: none",
"forced-colors: active",
]
) {
assert(source.includes(contract), `BevelLine must preserve ${contract}`);
}
assert(
source.includes("border-color: var(--line-strong)") &&
source.includes("border-top-style: solid") &&
source.includes("border-top-width: 1px") &&
source.includes("border-left-style: solid") &&
source.includes("border-left-width: 1px") &&
!source.includes("background:") &&
!source.includes("gradient") &&
!source.includes("color-mix") &&
!source.includes("BevelLineDepth") &&
!source.includes("depth"),
"BevelLine must remain a plain one-pixel directional border",
);
assert(
!source.includes("padding-inline") &&
!source.includes("padding-block") &&
!source.includes("margin-inline") &&
!source.includes("margin-block"),
"BevelLine must fill its parent layer without independent endpoint spacing",
);
});
Deno.test("Workspace showroom demonstrates both BevelLine directions", async () => {
const source = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/+page.svelte",
import.meta.url,
),
);
assert(
source.includes('direction="x" length="100%"') &&
source.includes('direction="y" length="72px"') &&
source.includes('direction="x" length="160px"') &&
!source.includes("depth=") &&
!source.includes("weight="),
"Workspace showroom must demonstrate horizontal and vertical plain separators",
);
});
Deno.test("Shell regions use selected Bevel edges while open separators use BevelLine", async () => {
const rootLayout = await Deno.readTextFile(
new URL("../src/routes/+layout.svelte", import.meta.url),
);
const sidebarFrame = await Deno.readTextFile(
new URL(
"../src/lib/workspace/sidebar/SidebarFrame.svelte",
import.meta.url,
),
);
const sidebarCss = await Deno.readTextFile(
new URL("../src/lib/workspace/sidebar/sidebar.css", import.meta.url),
);
const showroomCss = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/showroom.css",
import.meta.url,
),
);
const designLanguage = await Deno.readTextFile(
new URL(
"../../../docs/development/ui-ux/design-language.md",
import.meta.url,
),
);
assert(
rootLayout.includes('class="app-shell__topbar-bevel"') &&
rootLayout.includes("top={false} right={false} left={false}") &&
rootLayout.includes("bind:folded={sidebarFolded}") &&
rootLayout.includes("app-shell__mobile-sidebar-toggle") &&
rootLayout.includes("class:sidebar-open={!sidebarFolded}") &&
!rootLayout.includes("depth=") &&
!rootLayout.includes("BevelLine") &&
sidebarFrame.includes('class="sidebar-frame__bevel"') &&
sidebarFrame.includes("top={false} bottom={false} left={false}") &&
sidebarFrame.includes("$bindable(false)") &&
!sidebarFrame.includes("depth=") &&
!sidebarFrame.includes("BevelLine") &&
sidebarCss.includes(".sidebar-frame.folded") &&
sidebarCss.includes("display: none") &&
sidebarCss.includes("display: grid"),
"Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state",
);
assert(
designLanguage.includes(
"Headerのbottom edgeとSidebarのright edgeだけを有効",
) &&
designLanguage.includes("Mobileのfold controlはHeaderに置く") &&
designLanguage.includes("main contentと同時表示しない") &&
designLanguage.includes("同じlayerではheading、本文、Lineの端を揃え") &&
designLanguage.includes(
"親layoutがそのlayer全体へinline方向の余白を与え",
) &&
showroomCss.includes(".resource-list {") &&
showroomCss.includes("margin-inline: var(--space-2)"),
"The design language and showroom must preserve adjacent area and line hierarchy",
);
assert(
!sidebarCss.includes("border-right:") &&
!sidebarCss.includes("border-bottom:") &&
!showroomCss.includes("border-top: 1px solid") &&
!showroomCss.includes("border-bottom: 1px solid"),
"Shell and showroom structural boundaries must use shared Bevel components",
);
});
@@ -0,0 +1,59 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Tooltip exposes the same contextual help to pointer and keyboard users", async () => {
const source = await Deno.readTextFile(
new URL("../src/lib/workspace/ui/Tooltip.svelte", import.meta.url),
);
for (
const contract of [
'role="tooltip"',
"onpointerenter={showAfterDelay}",
"onfocusin={showImmediately}",
"event.key === 'Escape'",
"children: Snippet<[descriptionId: string]>",
"max-width: min(20rem, calc(100vw - var(--space-4)))",
]
) {
assert(source.includes(contract), `Tooltip must preserve ${contract}`);
}
});
Deno.test("Tooltip showroom binds help to actions and column headings", async () => {
const workspaceSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/+page.svelte",
import.meta.url,
),
);
const settingsSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/settings/+page.svelte",
import.meta.url,
),
);
for (const descriptionId of ["rerun-help", "last-seen-help", "source-help"]) {
assert(
workspaceSource.includes(descriptionId),
`Workspace showroom must expose contextual help ${descriptionId}`,
);
}
assert(
settingsSource.includes("retry-runtime-help"),
"Settings showroom must expose retry operation help",
);
assert(
workspaceSource.includes('class="column-help"') &&
workspaceSource.includes("aria-describedby={descriptionId}>Last seen"),
"Column help must be bound to the column label rather than a separate info button",
);
assert(
!workspaceSource.includes("Actions explain") &&
!workspaceSource.includes("Use a table when"),
"Showroom must not restore persistent operation or table explanation",
);
});
@@ -0,0 +1,84 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
async function collectStyleSources(directory: URL): Promise<URL[]> {
const files: URL[] = [];
for await (const entry of Deno.readDir(directory)) {
const url = new URL(entry.name, directory);
if (entry.isDirectory) {
url.pathname += "/";
files.push(...await collectStyleSources(url));
} else if (
entry.isFile &&
(entry.name.endsWith(".css") || entry.name.endsWith(".svelte"))
) {
files.push(url);
}
}
return files;
}
Deno.test("Workspace typography uses only the title body and compact scales", async () => {
const sourceRoot = new URL("../src/", import.meta.url);
const allowedSizes = new Set([
"var(--font-size-title)",
"var(--font-size-body)",
"var(--font-size-compact)",
"inherit",
]);
for (const url of await collectStyleSources(sourceRoot)) {
const source = await Deno.readTextFile(url);
for (const match of source.matchAll(/font-size:\s*([^;]+);/g)) {
const value = match[1].trim();
assert(
allowedSizes.has(value),
`${url.pathname} must not use the font-size value ${value}`,
);
}
assert(
!/font:\s*[^;]*(?:\d+(?:\.\d+)?(?:px|rem|em))[^;]*;/g.test(source),
`${url.pathname} font shorthand must use a shared typography token`,
);
}
});
Deno.test("Workspace typography tokens bind the documented three-level scale", async () => {
const appCss = await Deno.readTextFile(
new URL("../src/app.css", import.meta.url),
);
const designLanguage = await Deno.readTextFile(
new URL(
"../../../docs/development/ui-ux/design-language.md",
import.meta.url,
),
);
for (
const contract of [
"--font-size-title: 24px",
"--line-height-title: 32px",
"--font-size-body: 14px",
"--line-height-body: 20px",
"--font-size-compact: 12px",
"--line-height-compact: 16px",
"font-size: var(--font-size-title)",
"font-size: var(--font-size-body)",
"font-size: var(--font-size-compact)",
]
) {
assert(appCss.includes(contract), `app.css must preserve ${contract}`);
}
assert(
designLanguage.includes("--font-size-title /* 24px */") &&
designLanguage.includes("--font-size-body /* 14px */") &&
designLanguage.includes("--font-size-compact /* 12px */") &&
designLanguage.includes("`12px`未満と`13px`を使わない"),
"The design language must preserve the title, body, and compact typography contract",
);
});