feat: establish workspace web design language

This commit is contained in:
2026-09-17 01:31:54 +09:00
parent c8eb767d63
commit ab6450b9fe
39 changed files with 3626 additions and 614 deletions
+45
View File
@@ -55,3 +55,48 @@ the Vite dev server proxy plus the Rust backend command above.
deno task check
deno task build
```
## Design and architecture authority
- [`../../docs/development/ui-ux/design-language.md`](../../docs/development/ui-ux/design-language.md): visual and interaction language.
- [`../../docs/development/ui-ux/product-ux.md`](../../docs/development/ui-ux/product-ux.md): resource and navigation IA.
- [`../../docs/development/ui-ux/application-architecture.md`](../../docs/development/ui-ux/application-architecture.md): shell and nested override architecture.
- [`../../docs/development/ui-ux/visual-review.md`](../../docs/development/ui-ux/visual-review.md): implementer-owned visual validation.
This README owns frontend implementation guidance and concrete source paths. It does not redefine product or visual rules from those documents.
## CSS ownership
`src/app.css` is the global foundation and owns only:
- font imports;
- cascade layer order;
- semantic tokens and light/dark theme;
- reset and base element typography;
- global focus and text-selection behavior.
It must not own page layout, feature class selectors, table or definition-list layout, button variants, status presentation, helper spacing, or anchor color. Navigation, inline links, and action links own their color in the relevant component stylesheet.
Feature styles are owned by their implementation area:
- `src/lib/workspace/styles/workspace-pages.css`: Workspace main-content primitives.
- `src/lib/workspace/styles/tickets.css`: Ticket surfaces.
- `src/lib/workspace/styles/workers.css`: Worker surfaces.
- `src/lib/workspace/styles/settings.css`: Settings, Account, and form surfaces.
- `src/lib/workspace/styles/workspace-catalog.css`: Workspace catalog.
- `src/lib/workspace/sidebar/sidebar.css`: Sidebar components.
- Svelte-local styles: behavior-specific presentation that is not reused outside that component.
Use semantic tokens from `app.css`. Do not introduce component-local color, spacing, font, or z-index systems.
## Source map
- `src/routes/+layout.svelte`: root application shell and override contexts.
- `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration.
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
- `src/lib/workspace/header/`: Header frame, context, and overrides.
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
- `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`.
- `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom.
When one of these paths changes, update this source map in the same change. Stable architecture and UX authority remain in `docs/`; this map follows the current implementation.
+53 -121
View File
@@ -6,16 +6,20 @@
@import "@fontsource/ibm-plex-mono/latin-500.css";
@import "@fontsource/ibm-plex-mono/latin-600.css";
@import "@fontsource/ibm-plex-mono/latin-700.css";
@layer reset, tokens, base, layout, components;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
textarea,
@@ -23,9 +27,11 @@
font: inherit;
}
}
@layer tokens {
:root {
color-scheme: light dark;
--bg: oklch(98.5% 0 0);
--bg-raised: oklch(96% 0 0);
--bg-subtle: oklch(93% 0 0);
@@ -41,6 +47,25 @@
--success: oklch(48% 0.11 145);
--warning: oklch(62% 0.12 85);
--danger: oklch(54% 0.14 25);
--interactive-hover: oklch(94.5% 0 0);
--interactive-selected: oklch(93% 0.02 230);
--bevel-highlight: #fff;
--bevel-shadow: #000;
--bevel-face-width: 2px;
--shadow-overlay: 0 12px 32px oklch(0% 0 0 / 0.14);
--font-sans: "Gen Interface JP", sans-serif;
--font-mono: "IBM Plex Mono", monospace;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--radius-soft: 8px;
--tui-green: #76946a;
--tui-red: #c34043;
--tui-yellow: #c0a36e;
@@ -49,19 +74,8 @@
--tui-magenta: #957fb8;
--tui-gray: #727169;
--tui-dark-gray: #54546d;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--radius-soft: 8px;
--radius-panel: 12px;
--interactive-hover: oklch(94.5% 0 0);
--interactive-selected: oklch(93% 0.02 230);
--font-sans: "Gen Interface JP", sans-serif;
--font-mono: "IBM Plex Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: oklch(16% 0 0);
@@ -81,21 +95,22 @@
--danger: oklch(76% 0.14 25);
--interactive-hover: oklch(29% 0 0);
--interactive-selected: oklch(30% 0.025 230);
--shadow-overlay: 0 14px 36px oklch(0% 0 0 / 0.38);
}
}
}
@layer base {
html {
font-size: 14px;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
}
a {
color: var(--accent);
}
h1,
h2,
h3,
@@ -104,127 +119,44 @@
dd {
overflow-wrap: anywhere;
}
h1,
h2,
h3 {
color: var(--text-strong);
margin-top: 0;
color: var(--text-strong);
}
p {
line-height: 1.55;
}
code,
pre {
color: var(--code);
font-family: var(--font-mono);
}
code {
color: var(--code);
}
small {
color: var(--text-muted);
display: block;
margin-top: var(--space-1);
}
}
@layer layout {
.grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
min-width: 0;
}
.runtime {
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
}
.stack {
display: grid;
gap: var(--space-4);
}
}
@layer components {
.section-note,
.section-state,
.muted {
color: var(--text-muted);
font-size: 0.82rem;
}
.section-note,
.section-state {
margin: 0;
line-height: 1.45;
}
.card {
min-width: 0;
padding: 0;
}
dl {
display: grid;
gap: var(--space-1);
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: minmax(6.5rem, 10rem) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
dt {
color: var(--text-faint);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
dd {
min-width: 0;
margin: 0;
}
dd small {
display: inline;
margin-top: 0;
margin-left: var(--space-2);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
th {
color: var(--text-faint);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.inline-link,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
border-radius: 999px;
border: 1px solid var(--line);
padding: 0.4rem 0.75rem;
background: var(--bg);
color: var(--accent);
font-size: 0.85rem;
font-weight: 800;
text-decoration: none;
cursor: pointer;
font-size: 0.85em;
}
.secondary-button:hover,
.inline-link:hover {
border-color: var(--line-strong);
background: var(--bg-raised);
:where(
a,
button,
input,
select,
textarea,
summary,
[tabindex]
):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
::selection {
background: var(--interactive-selected);
color: var(--text-strong);
}
}
@@ -231,7 +231,7 @@
}
.compaction-error {
color: var(--tui-error);
color: var(--danger);
}
.activity-summary {
@@ -245,7 +245,7 @@
}
.console-line.error .activity-summary {
color: var(--tui-error);
color: var(--danger);
}
.run-stats {
@@ -158,8 +158,8 @@
text-align: left;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-panel) 92%, transparent);
box-shadow: var(--shadow-soft);
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
box-shadow: var(--shadow-overlay);
opacity: 1;
padding: 0.25rem 0.45rem;
transform: none;
@@ -78,6 +78,134 @@ Deno.test("workspace feature css is owned outside app css", async () => {
);
});
Deno.test("workspace app css is limited to global foundation", async () => {
const appCss = await Deno.readTextFile(
new URL("./../../../app.css", import.meta.url),
);
const workspacePagesCss = await Deno.readTextFile(
new URL(
"./../styles/workspace-pages.css",
import.meta.url,
),
);
const settingsCss = await Deno.readTextFile(
new URL("./../styles/settings.css", import.meta.url),
);
const sidebarCss = await Deno.readTextFile(
new URL("./../sidebar/sidebar.css", import.meta.url),
);
for (
const selector of [
".card",
".stack",
".section-note",
".section-state",
".muted",
".table-wrap",
".inline-link",
".secondary-button",
]
) {
assert(
!appCss.includes(selector),
`app.css must not own feature selector ${selector}`,
);
}
assert(
!/^\s*a\s*\{[^}]*\bcolor\s*:/ms.test(appCss),
"app.css must not override component-owned link colors",
);
for (
const legacyToken of [
"--surface",
"--border",
"--bg-panel",
"--interactive-muted",
"--radius-card",
"--radius-panel",
"--shadow-soft",
"--tui-error",
]
) {
assert(
!appCss.includes(legacyToken),
`app.css must not retain legacy token ${legacyToken}`,
);
}
assert(
workspacePagesCss.includes(".main-content .card") &&
workspacePagesCss.includes(".main-content table") &&
workspacePagesCss.includes(".main-content dl"),
"workspace content primitives must be scoped to the main content owner",
);
assert(
settingsCss.includes(".main-content .inline-link") &&
settingsCss.includes(".main-content .secondary-button"),
"settings CSS must own its link and button variants",
);
assert(
sidebarCss.includes(".section-state"),
"sidebar CSS must own sidebar status presentation",
);
});
Deno.test("design lab demonstrates nested sidebar slots without flattening levels", async () => {
const workspaceLayout = await Deno.readTextFile(
new URL(
"./../../../routes/design-lab/workspace-web-ux/+layout.svelte",
import.meta.url,
),
);
const settingsLayout = await Deno.readTextFile(
new URL(
"./../../../routes/design-lab/workspace-web-ux/settings/+layout.svelte",
import.meta.url,
),
);
assert(
workspaceLayout.includes("createOverrideStack<SidebarSnippet>"),
"Workspace design-lab layout must provide a child sidebar slot",
);
assert(
workspaceLayout.includes("<WorkspaceSidebarFixture"),
"Workspace design-lab layout must register its WorkspaceSidebar fixture",
);
assert(
workspaceLayout.includes("content={sidebarContent}"),
"Workspace design-lab layout must expose its child override slot",
);
assert(
!workspaceLayout.includes("<GlobalNavSections"),
"Workspace design-lab layout must not flatten Global navigation",
);
assert(
!workspaceLayout.includes("<SettingsSidebar"),
"Workspace design-lab layout must not flatten Settings navigation",
);
assert(
settingsLayout.includes("createOverrideStack<SidebarSnippet>"),
"Settings design-lab layout must provide a child sidebar slot",
);
assert(
settingsLayout.includes("<SettingsSidebarFixture"),
"Settings design-lab layout must register its SettingsSidebar fixture",
);
assert(
settingsLayout.includes("controller={parentSidebarController}"),
"Settings design-lab layout must register into its parent slot",
);
assert(
!settingsLayout.includes("<WorkspaceSidebar"),
"Settings design-lab layout must not duplicate WorkspaceSidebar",
);
});
Deno.test("workspace Worker list lives on the dedicated Workers page", async () => {
const workspacePage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
@@ -65,7 +65,7 @@
'.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
'.cm-fixed-schema-wrapper': {
color: 'var(--text-muted)',
backgroundColor: 'var(--interactive-muted)',
backgroundColor: 'var(--interactive-hover)',
fontWeight: '600',
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import './sidebar.css';
type Props = {
@@ -43,4 +44,17 @@
{/if}
</button>
</div>
<BevelLine
class="sidebar-frame__divider sidebar-frame__divider--vertical"
direction="y"
length="100%"
decorative
/>
<BevelLine
class="sidebar-frame__divider sidebar-frame__divider--horizontal"
direction="x"
length="100%"
decorative
/>
</aside>
@@ -7,6 +7,7 @@
grid-column: 1;
grid-row: 1 / 3;
position: relative;
display: flex;
flex-direction: column;
width: clamp(220px, 20vw, 280px);
@@ -14,7 +15,20 @@
min-height: 0;
overflow: hidden;
padding-block: var(--space-4);
border-right: 1px solid var(--line);
}
.sidebar-frame__divider {
position: absolute;
z-index: 2;
pointer-events: none;
}
.sidebar-frame__divider--vertical {
inset-block: 0;
inset-inline-end: 0;
}
.sidebar-frame__divider--horizontal {
display: none;
inset-inline: 0;
inset-block-end: 0;
}
@media (prefers-color-scheme: dark) {
.sidebar-frame {
@@ -207,7 +221,7 @@
border-radius: calc(var(--radius-soft) + 0.2rem);
padding: var(--space-2);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
box-shadow: var(--shadow-overlay);
}
.workspace-menu-item {
display: flex;
@@ -287,6 +301,12 @@
.workspace-menu-status.error {
color: var(--danger);
}
.section-state {
margin: var(--space-2) 0 0;
color: var(--text-muted);
font-size: 0.74rem;
line-height: 1.35;
}
.workspace-status {
margin: var(--space-1) 0 0;
color: var(--text-muted);
@@ -311,7 +331,7 @@
.sidebar-fold-button {
border: 1px solid var(--line);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
box-shadow: var(--shadow-overlay);
color: var(--text-strong);
cursor: pointer;
}
@@ -465,7 +485,7 @@
border-radius: var(--radius-soft);
padding: var(--space-1);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
box-shadow: var(--shadow-overlay);
}
.worker-actions-menu button {
border: 0;
@@ -613,8 +633,12 @@
grid-column: 1;
grid-row: 1;
width: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.sidebar-frame__divider--vertical {
display: none;
}
.sidebar-frame__divider--horizontal {
display: block;
}
}
}
@@ -1,5 +1,40 @@
@layer reset, tokens, base, layout, components;
@layer components {
.main-content .muted {
color: var(--text-muted);
}
.main-content .inline-link {
color: var(--accent);
font-weight: 700;
text-underline-offset: 0.15em;
}
.main-content .secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: 0.45rem 0.75rem;
background: var(--bg-raised);
color: var(--text-strong);
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.main-content .secondary-button:hover:not(:disabled),
.main-content .secondary-button:focus-visible:not(:disabled) {
border-color: var(--accent);
background: var(--interactive-hover);
}
.main-content .secondary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.settings-button {
text-decoration: none;
cursor: pointer;
@@ -32,10 +67,10 @@
}
.badge {
display: inline-flex;
flex: 0 0 auto;
border-radius: 999px;
padding: 0.35rem 0.65rem;
background: var(--bg-subtle);
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 800;
@@ -43,6 +78,14 @@
text-transform: uppercase;
}
.badge::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.badge.warning {
color: var(--warning);
}
@@ -62,7 +105,6 @@
max-width: 24rem;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: var(--text-muted);
}
@@ -83,7 +125,6 @@
min-width: min(16rem, 100%);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
@@ -128,7 +169,6 @@
.settings-pattern {
padding: var(--space-4);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
@@ -223,7 +263,7 @@
border: 1px solid var(--line);
border-radius: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
background: color-mix(in srgb, var(--text-strong) 3%, transparent);
}
.settings-runtime-table-wrap {
@@ -288,7 +328,7 @@
.settings-runtime-detail-row td {
padding-top: 0;
background: rgba(255, 255, 255, 0.025);
background: color-mix(in srgb, var(--text-strong) 2.5%, transparent);
}
.settings-muted-action {
@@ -325,18 +365,18 @@
}
.settings-diagnostics-list li.error {
border-color: rgba(255, 99, 99, 0.55);
border-color: color-mix(in srgb, var(--danger) 55%, transparent);
}
.settings-diagnostics-list li.warning {
border-color: rgba(255, 205, 86, 0.55);
border-color: color-mix(in srgb, var(--warning) 55%, transparent);
}
.settings-test-result {
display: grid;
gap: 0.3rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.04);
background: color-mix(in srgb, var(--text-strong) 4%, transparent);
padding: 0.75rem;
}
.settings-page {
@@ -354,7 +394,6 @@
min-width: min(16rem, 100%);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-section-card:hover,
@@ -450,7 +489,6 @@
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-profile-list strong,
@@ -493,22 +531,28 @@
color: var(--text-strong);
}
.section-status-pill {
display: inline-flex;
width: fit-content;
border-radius: 999px;
padding: 0.25rem 0.55rem;
background: var(--bg-subtle);
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.section-status-pill::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.config-source-shell {
display: grid;
grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
min-height: 40rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.config-source-tree {
@@ -689,7 +733,6 @@
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-form h2 {
@@ -728,7 +771,6 @@
.settings-repository-table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-table {
@@ -26,9 +26,9 @@
color: var(--text-strong);
}
.workspace-danger-button {
border: 1px solid #a63f3f;
background: #a63f3f;
color: #fff;
border: 1px solid var(--danger);
background: var(--danger);
color: var(--bg-raised);
}
.workspace-primary-button:disabled,
.workspace-secondary-button:disabled,
@@ -47,8 +47,8 @@
padding: 0.7rem 0.85rem;
}
.workspace-callout.is-error {
border-color: #a63f3f;
color: #d66;
border-color: var(--danger);
color: var(--danger);
}
.workspace-empty-copy {
margin: 0;
@@ -104,10 +104,10 @@
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #a75454;
background: var(--danger);
}
.orchestrator-status[data-online="true"] .orchestrator-status-dot {
background: #43a66d;
background: var(--success);
}
.ticket-panel-summary {
display: grid;
@@ -160,10 +160,10 @@
background: var(--text-muted);
}
.ticket-lane[data-state="ready-planning"] .ticket-state-dot {
background: #43a66d;
background: var(--success);
}
.ticket-lane[data-state="inprogress-queued"] .ticket-state-dot {
background: #d09b37;
background: var(--warning);
}
.ticket-lane[data-state="done-closed"] .ticket-state-dot {
background: var(--accent);
@@ -247,12 +247,19 @@
gap: 0.25rem;
}
.ticket-risk-list span {
border: 1px solid #a97531;
border-radius: 999px;
color: #d3a55d;
padding: 0.1rem 0.35rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--warning);
font-size: 0.62rem;
}
.ticket-risk-list span::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.ticket-lane-empty {
padding: 1.2rem 0.4rem;
color: var(--text-muted);
@@ -282,13 +289,20 @@
font-size: 0.72rem;
}
.workspace-status-pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.45rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.68rem;
text-transform: uppercase;
}
.workspace-status-pill::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.ticket-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
@@ -372,7 +386,7 @@
margin-bottom: var(--space-3);
}
.ticket-close-card {
border-color: color-mix(in srgb, #a63f3f 55%, var(--line));
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
}
.ticket-relations-list, .ticket-blocker-list {
display: grid;
@@ -391,7 +405,7 @@
text-decoration: none;
}
.ticket-blocker-list a {
border-color: #a97531;
border-color: var(--warning);
}
.ticket-relations-list span,
.ticket-relations-list small,
@@ -9,7 +9,7 @@
.section-action {
margin-left: auto;
border: 1px solid var(--line);
border-radius: 999px;
border-radius: var(--radius-soft);
background: var(--bg-subtle);
color: var(--text-strong);
font-size: 0.72rem;
@@ -74,13 +74,22 @@
}
.workspace-card-heading > span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.5rem;
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.7rem;
text-transform: uppercase;
}
.workspace-card-heading > span::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.workspace-card-heading > .workspace-state-active {
border-color: color-mix(in srgb, var(--success) 50%, var(--line));
color: var(--success);
@@ -1,5 +1,63 @@
@layer reset, tokens, base, layout, components;
@layer components {
.main-content .stack {
display: grid;
gap: var(--space-2);
}
.main-content .card {
min-width: 0;
padding: 0;
}
.main-content .section-note {
margin: 0;
color: var(--text-muted);
}
.main-content .section-state {
margin: var(--space-3) 0 0;
color: var(--text-muted);
}
.main-content .section-state.error {
color: var(--danger);
}
.main-content .muted {
color: var(--text-muted);
}
.main-content .secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: 0.45rem 0.75rem;
background: var(--bg-raised);
color: var(--text-strong);
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.main-content .secondary-button:hover:not(:disabled),
.main-content .secondary-button:focus-visible:not(:disabled) {
border-color: var(--accent);
background: var(--interactive-hover);
}
.main-content .secondary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.main-content small {
display: block;
margin-top: var(--space-1);
}
.workspace-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -10,7 +68,6 @@
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
@@ -51,7 +108,6 @@
align-items: start;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
@@ -168,68 +224,75 @@
}
.risk-flags span,
.warning-pill {
border-radius: 999px;
padding: 0.18rem 0.55rem;
background: var(--bg-subtle);
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--warning);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.risk-flags span::before,
.warning-pill::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.ticket-body pre {
overflow-x: auto;
margin: 0;
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: var(--text);
white-space: pre-wrap;
}
dl {
.main-content dl {
display: grid;
gap: var(--space-1);
margin: 0;
}
dl > div {
.main-content dl > div {
display: grid;
grid-template-columns: minmax(6.5rem, 10rem) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
dt {
.main-content dt {
color: var(--text-faint);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
dd {
.main-content dd {
min-width: 0;
margin: 0;
}
dd small {
.main-content dd small {
display: inline;
margin-top: 0;
margin-left: var(--space-2);
}
.table-wrap {
.main-content .table-wrap {
overflow-x: auto;
}
table {
.main-content table {
width: 100%;
border-collapse: collapse;
}
th,
td {
.main-content th,
.main-content td {
border-bottom: 1px solid var(--line);
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
th {
.main-content th {
color: var(--text-faint);
font-size: 0.78rem;
letter-spacing: 0.08em;
@@ -0,0 +1,348 @@
<script lang="ts">
import type { Snippet } from 'svelte';
export type BevelProfile = 'edge' | 'ridge';
export type BevelDepth = 'raised' | 'inset';
type Props = {
children: Snippet<[]>;
profile?: BevelProfile;
depth?: BevelDepth;
top?: boolean;
right?: boolean;
bottom?: boolean;
left?: boolean;
fill?: boolean;
invalid?: boolean;
pressed?: boolean;
as?: 'span' | 'div';
class?: string;
};
let {
children,
profile = 'edge',
depth = 'raised',
top = true,
right = true,
bottom = true,
left = true,
fill = false,
invalid = false,
pressed = false,
as = 'span',
class: className = '',
}: Props = $props();
</script>
<svelte:element
this={as}
class={`bevel ${fill ? 'bevel--fill' : ''} ${className}`.trim()}
data-profile={profile}
data-depth={depth}
data-top={top ? 'true' : 'false'}
data-right={right ? 'true' : 'false'}
data-bottom={bottom ? 'true' : 'false'}
data-left={left ? 'true' : 'false'}
data-invalid={invalid || undefined}
data-pressed={pressed || undefined}
>
{@render children()}
</svelte:element>
<style>
.bevel {
--bevel-light: 315deg;
--bevel-radius: var(--radius-soft);
--bevel-top-left-radius: var(--bevel-radius);
--bevel-top-right-radius: var(--bevel-radius);
--bevel-bottom-right-radius: var(--bevel-radius);
--bevel-bottom-left-radius: var(--bevel-radius);
--bevel-top-inset: var(--bevel-face-width);
--bevel-right-inset: var(--bevel-face-width);
--bevel-bottom-inset: var(--bevel-face-width);
--bevel-left-inset: var(--bevel-face-width);
position: relative;
isolation: isolate;
display: inline-grid;
min-width: 0;
border-radius:
var(--bevel-top-left-radius)
var(--bevel-top-right-radius)
var(--bevel-bottom-right-radius)
var(--bevel-bottom-left-radius);
vertical-align: middle;
}
.bevel[data-top='false'] {
--bevel-top-left-radius: 0px;
--bevel-top-right-radius: 0px;
--bevel-top-inset: 0px;
}
.bevel[data-right='false'] {
--bevel-top-right-radius: 0px;
--bevel-bottom-right-radius: 0px;
--bevel-right-inset: 0px;
}
.bevel[data-bottom='false'] {
--bevel-bottom-right-radius: 0px;
--bevel-bottom-left-radius: 0px;
--bevel-bottom-inset: 0px;
}
.bevel[data-left='false'] {
--bevel-bottom-left-radius: 0px;
--bevel-top-left-radius: 0px;
--bevel-left-inset: 0px;
}
.bevel--fill {
width: 100%;
}
.bevel > :global(*) {
position: relative;
z-index: 1;
min-width: 0;
border-radius: inherit;
}
.bevel > :global(:is(button, input, select, textarea)) {
border-color: transparent;
}
.bevel::before,
.bevel[data-profile='ridge']::after {
--_W: var(--bevel-face-width);
--_top-w: var(--_W);
--_right-w: var(--_W);
--_bottom-w: var(--_W);
--_left-w: var(--_W);
content: '';
position: absolute;
z-index: 2;
inset: 0;
box-sizing: border-box;
border-style: solid;
border-color: var(--line-strong);
border-width: var(--_top-w) var(--_right-w) var(--_bottom-w) var(--_left-w);
border-radius: inherit;
pointer-events: none;
}
.bevel[data-top='false']::before,
.bevel[data-top='false'][data-profile='ridge']::after {
--_top-w: 0px;
}
.bevel[data-right='false']::before,
.bevel[data-right='false'][data-profile='ridge']::after {
--_right-w: 0px;
}
.bevel[data-bottom='false']::before,
.bevel[data-bottom='false'][data-profile='ridge']::after {
--_bottom-w: 0px;
}
.bevel[data-left='false']::before,
.bevel[data-left='false'][data-profile='ridge']::after {
--_left-w: 0px;
}
.bevel[data-profile='edge'][data-depth='inset']::before {
border-color: var(--line);
}
.bevel[data-profile='ridge'][data-depth='inset']::before {
border-color: var(--line);
}
.bevel[data-profile='ridge']::after {
inset:
var(--bevel-top-inset)
var(--bevel-right-inset)
var(--bevel-bottom-inset)
var(--bevel-left-inset);
border-color: var(--line);
border-radius:
max(0px, calc(var(--bevel-top-left-radius) - var(--bevel-face-width)))
max(0px, calc(var(--bevel-top-right-radius) - var(--bevel-face-width)))
max(0px, calc(var(--bevel-bottom-right-radius) - var(--bevel-face-width)))
max(0px, calc(var(--bevel-bottom-left-radius) - var(--bevel-face-width)));
}
.bevel[data-profile='ridge'][data-depth='inset']::after {
border-color: var(--line-strong);
}
.bevel[data-invalid='true'],
.bevel:has(> :global([aria-invalid='true'])) {
box-shadow: 0 0 0 1px var(--danger);
}
.bevel:has(> :global(:is(:disabled, [aria-disabled='true']))) {
opacity: 0.58;
}
@supports
(color: color-mix(in oklab, white 50%, black)) and
(width: calc(cos(0deg) * 1px)) and
((mask-composite: exclude) or (-webkit-mask-composite: xor)) {
.bevel {
--_s0: var(--bevel-highlight);
--_s1: color-mix(in oklab, var(--bevel-highlight) 96.5926%, var(--bevel-shadow));
--_s2: color-mix(in oklab, var(--bevel-highlight) 86.6025%, var(--bevel-shadow));
--_s3: color-mix(in oklab, var(--bevel-highlight) 70.7107%, var(--bevel-shadow));
--_s4: color-mix(in oklab, var(--bevel-highlight) 50%, var(--bevel-shadow));
--_s5: color-mix(in oklab, var(--bevel-highlight) 25.8819%, var(--bevel-shadow));
--_s6: var(--bevel-shadow);
--_shade:
var(--_s0) 0deg,
var(--_s1) 15deg,
var(--_s2) 30deg,
var(--_s3) 45deg,
var(--_s4) 60deg,
var(--_s5) 75deg,
var(--_s6) 90deg,
var(--_s6) 270deg,
var(--_s5) 285deg,
var(--_s4) 300deg,
var(--_s3) 315deg,
var(--_s2) 330deg,
var(--_s1) 345deg,
var(--_s0) 360deg;
}
.bevel::before,
.bevel[data-profile='ridge']::after {
--_W: var(--bevel-face-width);
--_RTL: var(--bevel-top-left-radius);
--_RTR: var(--bevel-top-right-radius);
--_RBR: var(--bevel-bottom-right-radius);
--_RBL: var(--bevel-bottom-left-radius);
--_L: var(--bevel-light);
--_top: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(0deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_right: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(90deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_bottom: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(180deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_left: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(270deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
border-style: solid;
border-color: transparent;
border-width: var(--_top-w) var(--_right-w) var(--_bottom-w) var(--_left-w);
border-radius: var(--_RTL) var(--_RTR) var(--_RBR) var(--_RBL);
background:
conic-gradient(from var(--_L) at 100% 100%, var(--_shade)) 0 0 / var(--_RTL) var(--_RTL) no-repeat,
conic-gradient(from var(--_L) at 0 100%, var(--_shade)) 100% 0 / var(--_RTR) var(--_RTR) no-repeat,
conic-gradient(from var(--_L) at 0 0, var(--_shade)) 100% 100% / var(--_RBR) var(--_RBR) no-repeat,
conic-gradient(from var(--_L) at 100% 0, var(--_shade)) 0 100% / var(--_RBL) var(--_RBL) no-repeat,
linear-gradient(var(--_top) 0 0) var(--_RTL) 0 /
calc(100% - var(--_RTL) - var(--_RTR)) var(--_top-w) no-repeat,
linear-gradient(var(--_bottom) 0 0) var(--_RBL) 100% /
calc(100% - var(--_RBL) - var(--_RBR)) var(--_bottom-w) no-repeat,
linear-gradient(var(--_left) 0 0) 0 var(--_RTL) / var(--_left-w)
calc(100% - var(--_RTL) - var(--_RBL)) no-repeat,
linear-gradient(var(--_right) 0 0) 100% var(--_RTR) / var(--_right-w)
calc(100% - var(--_RTR) - var(--_RBR)) no-repeat;
background-origin: border-box;
background-clip: border-box;
box-shadow: none;
-webkit-mask:
linear-gradient(#000 0 0) padding-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) padding-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
.bevel[data-depth='inset']::before,
.bevel[data-profile='edge'][data-pressed='true']::before,
.bevel[data-profile='edge'][data-depth='raised']:has(
> :global(:is(button, [role='button']):active)
)::before,
.bevel[data-profile='edge'][data-depth='raised']:has(
> :global([aria-pressed='true'])
)::before {
--_L: calc(var(--bevel-light) + 180deg);
}
.bevel[data-profile='ridge']::before {
--_W: var(--bevel-face-width);
}
.bevel[data-profile='ridge']::after {
--_W: var(--bevel-face-width);
--_RTL: max(0px, calc(var(--bevel-top-left-radius) - var(--bevel-face-width)));
--_RTR: max(0px, calc(var(--bevel-top-right-radius) - var(--bevel-face-width)));
--_RBR: max(0px, calc(var(--bevel-bottom-right-radius) - var(--bevel-face-width)));
--_RBL: max(0px, calc(var(--bevel-bottom-left-radius) - var(--bevel-face-width)));
inset:
var(--bevel-top-inset)
var(--bevel-right-inset)
var(--bevel-bottom-inset)
var(--bevel-left-inset);
}
.bevel[data-profile='ridge'][data-depth='raised']::after {
--_L: calc(var(--bevel-light) + 180deg);
}
.bevel[data-profile='ridge'][data-depth='inset']::after {
--_L: var(--bevel-light);
}
}
@media (forced-colors: active) {
.bevel {
forced-color-adjust: auto;
}
.bevel::before,
.bevel[data-profile='ridge']::after {
border-color: ButtonText;
background: none;
box-shadow: none;
-webkit-mask: none;
mask: none;
}
.bevel[data-invalid='true'],
.bevel:has(> :global([aria-invalid='true'])) {
outline: 2px solid Mark;
outline-offset: 1px;
box-shadow: none;
}
}
@media print {
.bevel::before,
.bevel[data-profile='ridge']::after {
border-color: currentColor;
background: none;
-webkit-mask: none;
mask: none;
}
}
</style>
@@ -0,0 +1,146 @@
<script lang="ts">
export type BevelLineDirection = 'x' | 'y';
export type BevelLineDepth = 'raised' | 'inset';
type Props = {
direction?: BevelLineDirection;
length?: string;
depth?: BevelLineDepth;
decorative?: boolean;
as?: 'span' | 'div';
class?: string;
};
let {
direction = 'x',
length = '100%',
depth = 'raised',
decorative = false,
as = 'span',
class: className = '',
}: Props = $props();
</script>
<svelte:element
this={as}
class={`bevel-line ${className}`.trim()}
data-direction={direction}
data-depth={depth}
role={decorative ? undefined : 'separator'}
aria-orientation={decorative ? undefined : direction === 'x' ? 'horizontal' : 'vertical'}
aria-hidden={decorative ? 'true' : undefined}
style:width={direction === 'x' ? length : undefined}
style:height={direction === 'y' ? length : undefined}
></svelte:element>
<style>
.bevel-line {
--bevel-light: 315deg;
--bevel-line-face-width: var(--bevel-face-width);
--bevel-line-width: calc(var(--bevel-line-face-width) * 2);
display: block;
flex: 0 0 auto;
overflow: hidden;
border-radius: 999px;
background: var(--line-strong);
pointer-events: none;
}
.bevel-line[data-direction='x'] {
height: var(--bevel-line-width);
}
.bevel-line[data-direction='y'] {
width: var(--bevel-line-width);
}
@supports
(color: color-mix(in oklab, white 50%, black)) and
(width: calc(cos(0deg) * 1px)) {
.bevel-line {
--_L: var(--bevel-light);
--_s0: var(--bevel-highlight);
--_s1: color-mix(in oklab, var(--bevel-highlight) 96.5926%, var(--bevel-shadow));
--_s2: color-mix(in oklab, var(--bevel-highlight) 86.6025%, var(--bevel-shadow));
--_s3: color-mix(in oklab, var(--bevel-highlight) 70.7107%, var(--bevel-shadow));
--_s4: color-mix(in oklab, var(--bevel-highlight) 50%, var(--bevel-shadow));
--_s5: color-mix(in oklab, var(--bevel-highlight) 25.8819%, var(--bevel-shadow));
--_s6: var(--bevel-shadow);
--_shade:
var(--_s0) 0deg,
var(--_s1) 15deg,
var(--_s2) 30deg,
var(--_s3) 45deg,
var(--_s4) 60deg,
var(--_s5) 75deg,
var(--_s6) 90deg,
var(--_s6) 270deg,
var(--_s5) 285deg,
var(--_s4) 300deg,
var(--_s3) 315deg,
var(--_s2) 330deg,
var(--_s1) 345deg,
var(--_s0) 360deg;
--_top: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(0deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_right: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(90deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_bottom: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(180deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
--_left: color-mix(
in oklab,
var(--bevel-highlight) calc(max(cos(270deg - var(--_L)), 0) * 100%),
var(--bevel-shadow)
);
}
.bevel-line[data-depth='inset'] {
--_L: calc(var(--bevel-light) + 180deg);
}
.bevel-line[data-direction='x'] {
background:
conic-gradient(from var(--_L) at 100% 50%, var(--_shade)) 0 0 /
var(--bevel-line-face-width) var(--bevel-line-width) no-repeat,
conic-gradient(from var(--_L) at 0 50%, var(--_shade)) 100% 0 /
var(--bevel-line-face-width) var(--bevel-line-width) no-repeat,
linear-gradient(to bottom, var(--_top) 0 50%, var(--_bottom) 50% 100%)
var(--bevel-line-face-width) 0 /
calc(100% - var(--bevel-line-width)) 100% no-repeat;
}
.bevel-line[data-direction='y'] {
background:
conic-gradient(from var(--_L) at 50% 100%, var(--_shade)) 0 0 /
var(--bevel-line-width) var(--bevel-line-face-width) no-repeat,
conic-gradient(from var(--_L) at 50% 0, var(--_shade)) 0 100% /
var(--bevel-line-width) var(--bevel-line-face-width) no-repeat,
linear-gradient(to right, var(--_left) 0 50%, var(--_right) 50% 100%)
0 var(--bevel-line-face-width) / 100%
calc(100% - var(--bevel-line-width)) no-repeat;
}
}
@media (forced-colors: active) {
.bevel-line {
background: CanvasText;
forced-color-adjust: auto;
}
}
@media print {
.bevel-line {
background: currentColor;
}
}
</style>
@@ -0,0 +1,178 @@
<script lang="ts">
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
type Placement = 'top' | 'bottom';
type Alignment = 'start' | 'center' | 'end';
let {
id,
text,
placement = 'top',
align = 'center',
children,
}: {
id: string;
text: string;
placement?: Placement;
align?: Alignment;
children: Snippet<[descriptionId: string]>;
} = $props();
let open = $state(false);
let pointerInside = false;
let focusInside = false;
let hoverTimer: number | undefined;
let rootElement: HTMLSpanElement | undefined;
let tooltipElement: HTMLSpanElement | undefined;
let tooltipLeft = $state(0);
let tooltipTop = $state(0);
function clearHoverTimer() {
if (hoverTimer === undefined) return;
globalThis.clearTimeout(hoverTimer);
hoverTimer = undefined;
}
function positionTooltip() {
if (!open || rootElement === undefined || tooltipElement === undefined) return;
const root = rootElement.getBoundingClientRect();
const tooltip = tooltipElement.getBoundingClientRect();
const margin = 8;
const gap = 8;
let left = root.left + (root.width - tooltip.width) / 2;
if (align === 'start') left = root.left;
if (align === 'end') left = root.right - tooltip.width;
left = Math.max(margin, Math.min(left, globalThis.innerWidth - tooltip.width - margin));
let top = placement === 'top' ? root.top - tooltip.height - gap : root.bottom + gap;
if (top < margin) top = root.bottom + gap;
if (top + tooltip.height > globalThis.innerHeight - margin) {
top = root.top - tooltip.height - gap;
}
top = Math.max(margin, Math.min(top, globalThis.innerHeight - tooltip.height - margin));
tooltipLeft = Math.round(left);
tooltipTop = Math.round(top);
}
function reveal() {
open = true;
void tick().then(positionTooltip);
}
function showAfterDelay() {
pointerInside = true;
clearHoverTimer();
hoverTimer = globalThis.setTimeout(() => {
reveal();
hoverTimer = undefined;
}, 300);
}
function showImmediately() {
focusInside = true;
clearHoverTimer();
reveal();
}
function dismiss() {
clearHoverTimer();
open = false;
}
function handlePointerLeave() {
pointerInside = false;
clearHoverTimer();
if (!focusInside) open = false;
}
function handleFocusOut(event: FocusEvent) {
const root = event.currentTarget;
if (root instanceof HTMLElement && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
return;
}
focusInside = false;
if (!pointerInside) open = false;
}
function handleWindowKeydown(event: KeyboardEvent) {
if (open && event.key === 'Escape') dismiss();
}
onMount(() => {
const reposition = () => positionTooltip();
globalThis.addEventListener('resize', reposition);
document.addEventListener('scroll', reposition, true);
return () => {
globalThis.removeEventListener('resize', reposition);
document.removeEventListener('scroll', reposition, true);
};
});
onDestroy(clearHoverTimer);
</script>
<svelte:window onkeydown={handleWindowKeydown} />
<span
class="tooltip-root"
bind:this={rootElement}
onpointerenter={showAfterDelay}
onpointerleave={handlePointerLeave}
onfocusin={showImmediately}
onfocusout={handleFocusOut}
>
{@render children(id)}
<span
{id}
class:open
class="tooltip-content"
role="tooltip"
bind:this={tooltipElement}
style:left={`${tooltipLeft}px`}
style:top={`${tooltipTop}px`}
>
{text}
</span>
</span>
<style>
.tooltip-root {
display: inline-flex;
width: fit-content;
}
.tooltip-content {
position: fixed;
z-index: 50;
width: max-content;
max-width: min(20rem, calc(100vw - var(--space-4)));
border: 1px solid var(--line-strong);
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-3);
background: var(--text-strong);
box-shadow: var(--shadow-overlay);
color: var(--bg);
font-family: var(--font-sans);
font-size: 12px;
font-weight: 500;
line-height: 16px;
opacity: 0;
pointer-events: none;
text-align: left;
visibility: hidden;
}
.tooltip-content.open {
opacity: 1;
visibility: visible;
}
@media (prefers-reduced-motion: no-preference) {
.tooltip-content {
transition: opacity 100ms ease;
}
}
</style>
+13 -1
View File
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { setContext } from 'svelte';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
@@ -46,6 +47,12 @@
</svg>
</a>
</nav>
<BevelLine
class="app-shell__topbar-divider"
direction="x"
length="100%"
decorative
/>
</header>
<main class="app-shell__main">
{@render children()}
@@ -77,11 +84,16 @@
min-width: 0;
min-height: 3.25rem;
padding: 0 var(--space-5);
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
backdrop-filter: blur(14px);
}
:global(.app-shell__topbar-divider) {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
}
.app-shell__topbar-location {
flex: 1 1 auto;
min-width: 0;
@@ -0,0 +1,51 @@
<script lang="ts">
import { page } from '$app/state';
import { setContext } from 'svelte';
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import {
getSidebarController,
SIDEBAR_CONTEXT,
type SidebarController,
type SidebarSnippet,
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import WorkspaceSidebarFixture from './WorkspaceSidebarFixture.svelte';
import { designLabBasePath } from './workspace-navigation';
import './showroom.css';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
sidebarContent = activeContent;
});
setContext<SidebarController>(SIDEBAR_CONTEXT, {
registerSidebar: sidebarContentOverrides.register,
});
const inSettings = $derived(page.url.pathname.startsWith(`${designLabBasePath}/settings`));
</script>
{#snippet designLabHeader()}
<nav class="design-lab-header" aria-label="Current location">
{#if inSettings}
<a href={designLabBasePath}>Workspace Web UX</a>
<span aria-hidden="true">/</span>
<span aria-current="page">Settings</span>
{:else}
<span aria-current="page">Workspace Web UX</span>
{/if}
</nav>
{/snippet}
{#snippet workspaceSidebar()}
<WorkspaceSidebarFixture currentPath={page.url.pathname} content={sidebarContent} />
{/snippet}
<HeaderOverride content={designLabHeader} />
<SidebarOverride controller={parentSidebarController} sidebar={workspaceSidebar} />
{@render children()}
@@ -0,0 +1,277 @@
<script lang="ts">
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import Tooltip from '$lib/workspace/ui/Tooltip.svelte';
</script>
<div class="showroom">
<section class="showroom-section" aria-labelledby="action-heading">
<h1 id="action-heading" class="showroom-heading">Action</h1>
<BevelLine direction="x" length="100%" decorative />
<div class="action-row">
<Bevel profile="edge" depth="raised">
<button class="action action--primary" type="button">Create Worker</button>
</Bevel>
<Tooltip id="rerun-help" text="Runs validation against the current Workspace configuration." placement="bottom" align="start">
{#snippet children(descriptionId)}
<Bevel>
<button class="action action--secondary" type="button" aria-describedby={descriptionId}>Re-run checks</button>
</Bevel>
{/snippet}
</Tooltip>
<button class="action action--text" type="button">View log</button>
<Bevel>
<button class="action action--destructive" type="button">Remove repository</button>
</Bevel>
<Bevel>
<button class="action action--secondary" type="button" disabled>Running checks…</button>
</Bevel>
</div>
<div class="ridge-matrix">
<Bevel as="div" profile="ridge" depth="raised" fill>
<div class="tactile-group" role="group" aria-label="Raised validation controls">
<span>
<strong>Raised ridge</strong>
<small>3 checks passed at 14:32</small>
</span>
<Bevel pressed>
<button class="compact-control" type="button" aria-pressed="true">Live</button>
</Bevel>
</div>
</Bevel>
<Bevel as="div" profile="ridge" depth="inset" fill>
<div class="tactile-group" role="group" aria-label="Inset ridge controls">
<span>
<strong>Inset ridge</strong>
<small>Read-only control group</small>
</span>
<span class="status"><span class="status__marker" data-tone="muted"></span>Locked</span>
</div>
</Bevel>
<Bevel as="div" right={false} bottom={false} fill>
<div class="tactile-group" role="group" aria-label="Top and left edge">
<span>
<strong>Top + left</strong>
<small>Only the shared top-left corner is rounded</small>
</span>
</div>
</Bevel>
<Bevel as="div" profile="ridge" depth="inset" top={false} left={false} fill>
<div class="tactile-group" role="group" aria-label="Right and bottom groove">
<span>
<strong>Right + bottom</strong>
<small>Only the shared bottom-right corner is rounded</small>
</span>
</div>
</Bevel>
</div>
</section>
<section class="showroom-section" aria-labelledby="line-heading">
<h2 id="line-heading" class="showroom-heading">Line</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="line-showcase">
<div class="line-sample line-sample--horizontal">
<span class="line-sample__label">Raised · x · 100%</span>
<BevelLine direction="x" length="100%" />
<span class="line-sample__label">Inset · x · 160px</span>
<BevelLine direction="x" length="160px" depth="inset" />
</div>
<div class="line-sample line-sample--vertical">
<span>Before</span>
<BevelLine direction="y" length="72px" />
<span>After</span>
</div>
</div>
</section>
<section class="showroom-section" aria-labelledby="status-heading">
<h2 id="status-heading" class="showroom-heading">Status</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="status-row">
<span class="status"><span class="status__marker" data-tone="success"></span>Ready</span>
<span class="status"><span class="status__marker" data-tone="accent"></span>Running</span>
<span class="status"><span class="status__marker" data-tone="warning"></span>Needs review</span>
<span class="status"><span class="status__marker" data-tone="danger"></span>Failed</span>
<span class="status"><span class="status__marker" data-tone="muted"></span>Unavailable</span>
</div>
</section>
<section class="showroom-section" aria-labelledby="feedback-heading">
<h2 id="feedback-heading" class="showroom-heading">Feedback</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="feedback-grid">
<div class="feedback" role="status">
<span class="status"><span class="status__marker status__marker--loading" data-tone="accent"></span>Loading Merge Requests…</span>
</div>
<div class="feedback">
<strong>No Objectives.</strong>
</div>
<div class="feedback feedback--error" role="alert">
<div>
<strong>Merge failed.</strong>
<p>Target branch changed after review. Refresh the target before retrying.</p>
</div>
<Bevel>
<button class="action action--secondary" type="button">Refresh target</button>
</Bevel>
</div>
<div class="feedback feedback--permission">
<div>
<strong>Repository Access is read-only.</strong>
<p>Workspace owner permission is required to change credentials.</p>
</div>
</div>
</div>
</section>
<section id="resource-row" class="showroom-section" aria-labelledby="resource-row-heading">
<h2 id="resource-row-heading" class="showroom-heading">Resource row</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="resource-list">
<a class="resource-row" href="/design-lab/workspace-web-ux?ticket=YOI-248">
<span class="resource-row__identity">
<code>YOI-248</code>
<strong>Preserve nested navigation ownership across route transitions</strong>
</span>
<span class="resource-row__meta">High priority</span>
<span class="status"><span class="status__marker" data-tone="warning"></span>Blocked</span>
</a>
<BevelLine direction="x" length="100%" decorative />
<a class="resource-row" href="/design-lab/workspace-web-ux?ticket=YOI-251">
<span class="resource-row__identity">
<code>YOI-251</code>
<strong>Validate repository access projection</strong>
</span>
<span class="resource-row__meta">Updated 14 min ago</span>
<span class="status"><span class="status__marker" data-tone="success"></span>Ready</span>
</a>
<BevelLine direction="x" length="100%" decorative />
<a class="resource-row" href="/design-lab/workspace-web-ux?ticket=YOI-252">
<span class="resource-row__identity">
<code>YOI-252-WITH-A-LONG-CANONICAL-RESOURCE-KEY</code>
<strong>Keep very long translated resource names readable without pushing the current state out of the row</strong>
</span>
<span class="resource-row__meta">No owner</span>
<span class="status"><span class="status__marker" data-tone="muted"></span>Draft</span>
</a>
</div>
</section>
<section class="showroom-section" aria-labelledby="comparable-heading">
<h2 id="comparable-heading" class="showroom-heading">Comparable data</h2>
<BevelLine direction="x" length="100%" decorative />
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable comparison must be keyboard-focusable.) -->
<div class="table-scroll" tabindex="0" role="region" aria-label="Runtime comparison">
<table>
<thead>
<tr>
<th scope="col">Runtime</th>
<th scope="col">Kind</th>
<th scope="col">State</th>
<th scope="col">
<Tooltip id="last-seen-help" text="Most recent observation from the Runtime host." placement="top" align="end">
{#snippet children(descriptionId)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Focusable column label exposes contextual help to keyboard users.) -->
<span class="column-help" tabindex="0" aria-describedby={descriptionId}>Last seen</span>
{/snippet}
</Tooltip>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><span>Local development</span><code>local</code></th>
<td>Built-in</td>
<td><span class="status"><span class="status__marker" data-tone="success"></span>Available</span></td>
<td>Now</td>
</tr>
<tr>
<th scope="row"><span>Remote Linux runner with a long operational name</span><code>runner-eu-central-01</code></th>
<td>Remote</td>
<td><span class="status"><span class="status__marker" data-tone="danger"></span>Unavailable</span></td>
<td>2 hours ago</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="showroom-split">
<section class="showroom-section" aria-labelledby="key-value-heading">
<h2 id="key-value-heading" class="showroom-heading">Key-value</h2>
<BevelLine direction="x" length="100%" decorative />
<dl class="key-value">
<div><dt>Repository</dt><dd>yoi</dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Default selector</dt><dd><code>refs/heads/main</code></dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Access</dt><dd><span class="status"><span class="status__marker" data-tone="success"></span>Configured</span></dd></div>
</dl>
</section>
<section class="showroom-section" aria-labelledby="tooltip-heading">
<h2 id="tooltip-heading" class="showroom-heading">Tooltip</h2>
<BevelLine direction="x" length="100%" decorative />
<Tooltip id="source-help" text="The source that currently supplies this configuration value." placement="bottom" align="start">
{#snippet children(descriptionId)}
<button class="help-label" type="button" aria-describedby={descriptionId}>Configuration source <span aria-hidden="true">?</span></button>
{/snippet}
</Tooltip>
</section>
</div>
<section class="showroom-section" aria-labelledby="disclosure-heading">
<h2 id="disclosure-heading" class="showroom-heading">Disclosure</h2>
<BevelLine direction="x" length="100%" decorative />
<details class="disclosure">
<summary>Technical details</summary>
<dl class="technical-values">
<div><dt>Revision</dt><dd><code>rev_01J8WZ7C8AV6P3Y2N0M4KQ9B1F</code></dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Digest</dt><dd><code>sha256:2f8d9bb168afb7b19f447463f3fbe8b4728d8e87a64eaf1afbea1ca4d4c41f21</code></dd></div>
</dl>
</details>
</section>
<section class="showroom-section" aria-labelledby="color-heading">
<h2 id="color-heading" class="showroom-heading">Color</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="token-grid">
<div class="color-token"><span style="--sample: var(--bg)"></span><code>--bg</code></div>
<div class="color-token"><span style="--sample: var(--bg-raised)"></span><code>--bg-raised</code></div>
<div class="color-token"><span style="--sample: var(--bg-subtle)"></span><code>--bg-subtle</code></div>
<div class="color-token"><span style="--sample: var(--text-strong)"></span><code>--text-strong</code></div>
<div class="color-token"><span style="--sample: var(--accent)"></span><code>--accent</code></div>
<div class="color-token"><span style="--sample: var(--success)"></span><code>--success</code></div>
<div class="color-token"><span style="--sample: var(--warning)"></span><code>--warning</code></div>
<div class="color-token"><span style="--sample: var(--danger)"></span><code>--danger</code></div>
</div>
</section>
<div class="showroom-split">
<section class="showroom-section" aria-labelledby="typography-heading">
<h2 id="typography-heading" class="showroom-heading">Typography</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="type-samples">
<div><span class="type-route">Workspace identity</span><code>24 / 32</code></div>
<div><span class="type-body">Repository access</span><code>14 / 20</code></div>
<div><span class="type-meta">refs/heads/main</span><code>12 / 16</code></div>
</div>
</section>
<section class="showroom-section" aria-labelledby="spacing-heading">
<h2 id="spacing-heading" class="showroom-heading">Spacing</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="space-samples">
<div><span style="--size: var(--space-1)"></span><code>4</code></div>
<div><span style="--size: var(--space-2)"></span><code>8</code></div>
<div><span style="--size: var(--space-3)"></span><code>12</code></div>
<div><span style="--size: var(--space-4)"></span><code>16</code></div>
<div><span style="--size: var(--space-5)"></span><code>24</code></div>
<div><span style="--size: var(--space-6)"></span><code>32</code></div>
</div>
</section>
</div>
</div>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { designLabBasePath, workspaceNavigation, workspaceWorkers } from './workspace-navigation';
let {
currentPath,
content = null,
}: {
currentPath: string;
content?: Snippet<[]> | null;
} = $props();
const settingsPath = `${designLabBasePath}/settings`;
</script>
<div class="workspace-sidebar">
<header class="sidebar-header">
<nav class="workspace-sidebar-shortcuts" aria-label="Workspace shortcuts">
<a
class="workspace-sidebar-shortcut"
class:active={currentPath === designLabBasePath}
href={designLabBasePath}
aria-label="Workspace home"
title="Workspace home"
aria-current={currentPath === designLabBasePath ? 'page' : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m3 11 9-8 9 8"></path>
<path d="M5 10v10h14V10"></path>
<path d="M9 20v-6h6v6"></path>
</svg>
</a>
<a
class="workspace-sidebar-shortcut"
class:active={currentPath.startsWith(settingsPath)}
href={settingsPath}
aria-label="Workspace settings"
title="Workspace settings"
aria-current={currentPath === settingsPath ? 'page' : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 5h16M4 12h16M4 19h16"></path>
<path d="M8 3v4M16 10v4M10 17v4"></path>
</svg>
</a>
</nav>
</header>
{#if content}
{@render content()}
{:else}
<nav class="sidebar-sections" aria-label="Workspace sections">
{#each workspaceNavigation as item}
{#if item.children}
<section class="sidebar-nav-section sidebar-nav-section--category">
<h2 class="sidebar-nav-section__header">{item.label}</h2>
{#each item.children as child}
<a class="sidebar-link" href={child.href}>{child.label}</a>
{/each}
</section>
{:else if item.label === 'Workers'}
<section class="sidebar-nav-section" aria-labelledby="design-lab-workers-heading">
<div class="section-heading-row">
<h2 id="design-lab-workers-heading">
<a class="section-heading-link" href={item.href}>workers</a>
</h2>
<a class="section-action" href={`${designLabBasePath}?worker=new`}>New</a>
<span class="section-count">{workspaceWorkers.length}</span>
</div>
<ul class="nav-list" aria-label="Workers">
{#each workspaceWorkers as worker}
<li class="worker-nav-item">
<a class="worker-nav-link" href={`${designLabBasePath}?worker=${worker.key}`}>
<span class="worker-status-indicator" aria-hidden="true">
<span class="worker-status-dot"></span>
</span>
<span class="worker-nav-label" title={worker.label}>{worker.label}</span>
<small class="worker-nav-meta" title={`${worker.state} · ${worker.repository}`}>
{worker.state} · {worker.repository}
</small>
</a>
</li>
{/each}
</ul>
</section>
{:else}
<section class="sidebar-nav-section sidebar-nav-section--resource">
<a class="sidebar-link" href={item.href}>{item.label}</a>
</section>
{/if}
{/each}
</nav>
{/if}
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import { page } from '$app/state';
import { setContext } from 'svelte';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import {
getSidebarController,
SIDEBAR_CONTEXT,
type SidebarController,
type SidebarSnippet,
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import SettingsSidebarFixture from './SettingsSidebarFixture.svelte';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
sidebarContent = activeContent;
});
setContext<SidebarController>(SIDEBAR_CONTEXT, {
registerSidebar: sidebarContentOverrides.register,
});
</script>
{#snippet settingsSidebar()}
<SettingsSidebarFixture currentPath={page.url.pathname} content={sidebarContent} />
{/snippet}
<SidebarOverride controller={parentSidebarController} sidebar={settingsSidebar} />
{@render children()}
@@ -0,0 +1,100 @@
<script lang="ts">
import Bevel from '$lib/workspace/ui/Bevel.svelte';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import Tooltip from '$lib/workspace/ui/Tooltip.svelte';
function keepSampleFormLocal(event: SubmitEvent) {
event.preventDefault();
}
</script>
<div class="showroom showroom--settings">
<section class="showroom-section" aria-labelledby="form-field-heading">
<h1 id="form-field-heading" class="showroom-heading">Form field</h1>
<BevelLine direction="x" length="100%" decorative />
<form class="sample-form" onsubmit={keepSampleFormLocal}>
<div class="form-grid">
<label class="form-field">
<span>Runtime label</span>
<Bevel profile="edge" depth="inset" fill>
<input name="runtime-label" value="Remote Linux runner" />
</Bevel>
</label>
<label class="form-field">
<span>Endpoint</span>
<Bevel profile="edge" depth="inset" fill invalid>
<input
name="runtime-endpoint"
value="http://runner.internal.example:8787/api/runtime"
aria-describedby="endpoint-constraint endpoint-error"
aria-invalid="true"
/>
</Bevel>
<small id="endpoint-constraint">Use an HTTPS endpoint reachable from the Workspace server.</small>
<span id="endpoint-error" class="field-error">This endpoint uses HTTP. Enter an HTTPS URL.</span>
</label>
<label class="form-field">
<span>Architecture</span>
<Bevel profile="edge" depth="inset" fill>
<select name="architecture">
<option>arm64</option>
<option selected>x86_64</option>
</select>
</Bevel>
</label>
</div>
<BevelLine direction="x" length="100%" decorative />
<div class="form-footer">
<p class="unsaved"><span class="status__marker" data-tone="warning"></span>Unsaved changes</p>
<Bevel>
<button class="action action--primary" type="submit">Save runtime</button>
</Bevel>
</div>
</form>
</section>
<section class="showroom-section" aria-labelledby="operation-error-heading">
<h2 id="operation-error-heading" class="showroom-heading">Operation error</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="feedback feedback--error" role="alert">
<div>
<strong>Runtime was not saved.</strong>
<p>The endpoint did not respond within 10 seconds. Check the address and try again.</p>
</div>
<Tooltip id="retry-runtime-help" text="Checks this endpoint again without saving other fields." placement="bottom" align="end">
{#snippet children(descriptionId)}
<Bevel>
<button class="action action--secondary" type="button" aria-describedby={descriptionId}>Test endpoint</button>
</Bevel>
{/snippet}
</Tooltip>
</div>
</section>
<section class="showroom-section" aria-labelledby="permission-boundary-heading">
<h2 id="permission-boundary-heading" class="showroom-heading">Permission boundary</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="permission-read-view">
<dl class="key-value">
<div><dt>Credential source</dt><dd>Workspace secret store</dd></div>
<BevelLine as="div" direction="x" length="100%" decorative />
<div><dt>Access</dt><dd><span class="status"><span class="status__marker" data-tone="muted"></span>Read-only</span></dd></div>
</dl>
<p>Workspace owner permission is required to replace repository credentials.</p>
</div>
</section>
<section class="showroom-section" aria-labelledby="empty-heading">
<h2 id="empty-heading" class="showroom-heading">Empty</h2>
<BevelLine direction="x" length="100%" decorative />
<div class="feedback">
<strong>No Profile Sources.</strong>
<Bevel>
<button class="action action--secondary" type="button">Add profile source</button>
</Bevel>
</div>
</section>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { settingsBasePath, settingsNavigation } from './settings-navigation';
let {
currentPath,
content = null,
}: {
currentPath: string;
content?: Snippet<[]> | null;
} = $props();
</script>
<div class="settings-sidebar">
{#if content}
{@render content()}
{:else}
<nav class="sidebar-sections" aria-label="Settings sections">
<div class="sidebar-nav-section">
<div class="sidebar-list">
{#each settingsNavigation as item, index}
<a
class="sidebar-link"
class:active={index === 0 && currentPath === settingsBasePath}
href={item.href}
aria-current={index === 0 && currentPath === settingsBasePath ? 'page' : undefined}
>
<span class="sidebar-link-label">{item.label}</span>
</a>
{/each}
</div>
</div>
</nav>
{/if}
</div>
@@ -0,0 +1,12 @@
import { designLabBasePath } from '../workspace-navigation';
export const settingsBasePath = `${designLabBasePath}/settings`;
export const settingsNavigation = [
{ label: 'Runtimes', href: settingsBasePath },
{ label: 'Configuration Sources', href: `${settingsBasePath}?section=configuration-sources` },
{ label: 'Repositories', href: `${settingsBasePath}?section=repositories` },
{ label: 'Repository Access', href: `${settingsBasePath}?section=repository-access` },
{ label: 'Profile Sources', href: `${settingsBasePath}?section=profile-sources` },
{ label: 'Workspace Identity', href: `${settingsBasePath}?section=workspace-identity` },
] as const;
@@ -0,0 +1,680 @@
@layer components {
.design-lab-header {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.design-lab-header a {
color: var(--text-muted);
text-decoration: none;
}
.design-lab-header a:hover {
color: var(--text-strong);
text-decoration: underline;
text-underline-offset: 3px;
}
.design-lab-header [aria-current="page"] {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-sidebar .section-action {
margin-left: auto;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-soft);
color: var(--accent);
font-size: 12px;
font-weight: 650;
line-height: 16px;
text-decoration: none;
}
.workspace-sidebar .section-action:hover,
.workspace-sidebar .section-action:focus-visible {
background: var(--interactive-hover);
}
.showroom {
width: min(100%, 1180px);
margin: 0 auto;
padding: clamp(var(--space-5), 4vw, var(--space-6));
display: grid;
gap: var(--space-6);
}
.showroom--settings {
width: min(100%, 900px);
margin-inline: 0;
}
.showroom-section {
min-width: 0;
display: grid;
align-content: start;
gap: var(--space-3);
}
.showroom-heading {
margin: 0;
padding-bottom: var(--space-2);
color: var(--text-strong);
font-size: 14px;
font-weight: 650;
line-height: 20px;
}
.showroom-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: calc(var(--space-6) * 1.5);
}
.action-row,
.status-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.action {
min-height: 36px;
border: 1px solid transparent;
border-radius: var(--radius-soft);
padding: 7px var(--space-4);
background: transparent;
color: inherit;
font-size: 14px;
font-weight: 600;
line-height: 20px;
cursor: pointer;
}
.action--primary {
background: var(--accent);
color: var(--bg);
}
.action--primary:hover {
background: color-mix(in oklab, var(--accent), var(--text-strong) 14%);
}
.action--secondary {
background: var(--bg-raised);
color: var(--text-strong);
}
.action--secondary:hover {
background: var(--interactive-hover);
}
.action--destructive {
background: color-mix(in oklab, var(--danger) 14%, var(--bg-raised));
color: var(--danger);
}
.action--destructive:hover {
background: color-mix(in oklab, var(--danger) 22%, var(--bg-raised));
}
.action--text {
padding-inline: var(--space-2);
color: var(--accent);
}
.action--text:hover {
background: var(--interactive-hover);
color: var(--accent-muted);
}
.action:disabled {
cursor: not-allowed;
}
.compact-control {
min-height: 30px;
border: 1px solid transparent;
border-radius: inherit;
padding: var(--space-1) var(--space-3);
background: var(--interactive-selected);
color: var(--text-strong);
cursor: pointer;
font: 600 12px/1 var(--font-mono);
}
.ridge-matrix {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.tactile-group {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: inherit;
background: var(--bg-raised);
color: var(--text);
}
.tactile-group > span {
display: grid;
gap: var(--space-1);
}
.tactile-group small {
color: var(--text-muted);
font-family: var(--font-mono);
}
.line-showcase {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: var(--space-6);
}
.line-sample--horizontal {
display: grid;
align-content: center;
gap: var(--space-3);
}
.line-sample--vertical {
display: flex;
min-height: 96px;
align-items: center;
gap: var(--space-3);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
}
.line-sample__label {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
}
.status {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text);
font-size: 13px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
}
.status__marker {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--text-faint);
}
.status__marker[data-tone="success"] {
background: var(--success);
}
.status__marker[data-tone="accent"] {
background: var(--accent);
}
.status__marker[data-tone="warning"] {
background: var(--warning);
}
.status__marker[data-tone="danger"] {
background: var(--danger);
}
.status__marker--loading {
border: 2px solid color-mix(in oklch, var(--accent), transparent 65%);
border-top-color: var(--accent);
background: transparent !important;
animation: showroom-spin 800ms linear infinite;
}
@keyframes showroom-spin {
to {
transform: rotate(360deg);
}
}
.feedback-grid {
display: grid;
gap: var(--space-2);
}
.feedback {
min-height: 48px;
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
border-left: 3px solid var(--line-strong);
background: var(--bg-raised);
}
.feedback strong {
color: var(--text-strong);
font-size: 14px;
}
.feedback p {
margin: 2px 0 0;
color: var(--text-muted);
font-size: 13px;
line-height: 18px;
}
.feedback--error {
border-left-color: var(--danger);
}
.feedback--permission {
border-left-color: var(--warning);
}
.resource-list {
min-width: 0;
}
.resource-row {
min-width: 0;
min-height: 64px;
padding: var(--space-3) var(--space-2);
display: grid;
grid-template-columns:
minmax(0, 1fr) minmax(110px, auto) minmax(90px, auto);
align-items: center;
gap: var(--space-4);
color: inherit;
text-decoration: none;
}
.resource-row:hover {
border-radius: var(--radius-soft);
background: var(--interactive-hover);
}
.resource-row__identity {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.resource-row__identity strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-strong);
font-weight: 600;
}
.resource-row__identity code {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-muted);
font-size: 12px;
line-height: 16px;
}
.resource-row__meta {
color: var(--text-muted);
font-size: 12px;
line-height: 16px;
text-align: right;
}
.table-scroll {
max-width: 100%;
overflow-x: auto;
}
.table-scroll:focus-visible {
border-radius: var(--radius-soft);
}
.showroom table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
text-align: left;
}
.showroom th,
.showroom td {
padding: var(--space-3) var(--space-4);
vertical-align: middle;
}
.showroom tbody tr:nth-child(even) {
background: var(--bg-raised);
}
.showroom thead th {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.showroom tbody th {
max-width: 360px;
color: var(--text-strong);
font-size: 14px;
font-weight: 600;
}
.showroom tbody th span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.showroom tbody th code {
display: block;
margin-top: 2px;
color: var(--text-muted);
font-size: 12px;
font-weight: 400;
}
.column-help {
color: inherit;
text-decoration: underline dotted;
text-underline-offset: 0.2em;
cursor: help;
}
.key-value,
.technical-values {
margin: 0;
}
.key-value > div:not(.bevel-line),
.technical-values > div:not(.bevel-line) {
padding: var(--space-2) 0;
display: grid;
grid-template-columns: minmax(110px, 0.65fr) minmax(0, 1fr);
gap: var(--space-4);
}
.key-value dt,
.technical-values dt {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.key-value dd,
.technical-values dd {
min-width: 0;
margin: 0;
color: var(--text-strong);
font-size: 13px;
line-height: 18px;
}
.technical-values code {
overflow-wrap: anywhere;
}
.help-label {
min-height: 36px;
border: 0;
border-bottom: 1px dotted var(--line-strong);
background: transparent;
color: var(--text-strong);
font-weight: 600;
cursor: help;
}
.help-label span {
color: var(--text-muted);
}
.disclosure {
padding-block: var(--space-2);
}
.disclosure summary {
padding: var(--space-3) var(--space-2);
color: var(--text-strong);
font-weight: 600;
cursor: pointer;
}
.disclosure summary:hover {
border-radius: var(--radius-soft);
background: var(--interactive-hover);
}
.technical-values {
padding: 0 var(--space-2) var(--space-4);
}
.token-grid {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: var(--space-4);
}
.color-token {
min-width: 0;
display: grid;
gap: var(--space-2);
}
.color-token span {
height: 48px;
border: 1px solid var(--line);
background: var(--sample);
}
.color-token code,
.type-samples code,
.space-samples code {
color: var(--text-muted);
font-size: 12px;
line-height: 16px;
}
.type-samples {
display: grid;
gap: var(--space-4);
}
.type-samples > div {
display: grid;
gap: var(--space-1);
}
.type-route {
color: var(--text-strong);
font-size: 24px;
font-weight: 650;
line-height: 32px;
}
.type-body {
color: var(--text);
font-size: 14px;
line-height: 20px;
}
.type-meta {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
line-height: 16px;
}
.space-samples {
display: grid;
gap: var(--space-2);
}
.space-samples > div {
display: grid;
grid-template-columns: minmax(0, 1fr) 32px;
align-items: center;
gap: var(--space-3);
}
.space-samples span {
width: var(--size);
height: 8px;
min-width: 4px;
background: var(--accent-muted);
}
.sample-form {
display: grid;
gap: var(--space-5);
}
.form-grid {
display: grid;
gap: var(--space-5);
}
.form-field {
display: grid;
gap: var(--space-2);
color: var(--text-strong);
font-size: 13px;
font-weight: 600;
}
.form-field input,
.form-field select {
width: 100%;
min-height: 40px;
border: 1px solid transparent;
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-3);
background: var(--bg);
color: var(--text-strong);
}
.form-field small {
color: var(--text-muted);
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
.field-error {
color: var(--danger);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.form-footer {
padding-top: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.unsaved {
margin: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-strong);
font-size: 13px;
font-weight: 600;
}
.permission-read-view {
display: grid;
gap: var(--space-3);
}
.permission-read-view p {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
@media (prefers-reduced-motion: reduce) {
.status__marker--loading {
animation: none;
}
}
@media (max-width: 760px) {
.showroom {
padding: var(--space-5) var(--space-4);
gap: var(--space-5);
}
.showroom-split,
.line-showcase,
.ridge-matrix {
grid-template-columns: 1fr;
gap: var(--space-6);
}
.resource-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2) var(--space-3);
}
.resource-row__identity {
grid-column: 1 / -1;
}
.resource-row__meta {
text-align: left;
}
.feedback {
align-items: flex-start;
flex-direction: column;
}
.token-grid {
grid-template-columns: repeat(2, minmax(100px, 1fr));
}
}
@media (max-width: 420px) {
.key-value > div,
.technical-values > div {
grid-template-columns: 1fr;
gap: var(--space-1);
}
.form-footer,
.tactile-group {
align-items: stretch;
flex-direction: column;
}
}
}
@@ -0,0 +1,37 @@
export const designLabBasePath = '/design-lab/workspace-web-ux';
export type WorkspaceNavigationItem = {
label: string;
href: string;
children?: WorkspaceNavigationItem[];
};
export const workspaceNavigation: WorkspaceNavigationItem[] = [
{ label: 'Tickets', href: `${designLabBasePath}?resource=tickets` },
{ label: 'Objectives', href: `${designLabBasePath}?resource=objectives` },
{ label: 'Merge Requests', href: `${designLabBasePath}?resource=merge-requests` },
{
label: 'Memory',
href: `${designLabBasePath}?resource=memory`,
children: [
{ label: 'Document', href: `${designLabBasePath}?resource=memory-document` },
{ label: 'Staging', href: `${designLabBasePath}?resource=memory-staging` },
],
},
{ label: 'Workers', href: `${designLabBasePath}?resource=workers` },
];
export const workspaceWorkers = [
{
key: 'wrk-language-review',
label: 'Language review',
state: 'Running',
repository: 'yoi',
},
{
key: 'wrk-accessibility-check',
label: 'Accessibility check with a deliberately long display name',
state: 'Idle',
repository: 'workspace-web',
},
] as const;
@@ -187,9 +187,9 @@
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--surface);
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
@@ -275,9 +275,9 @@
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--surface);
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
+136
View File
@@ -0,0 +1,136 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Bevel keeps depth visual while semantic controls remain children", async () => {
const source = await Deno.readTextFile(
new URL("../src/lib/workspace/ui/Bevel.svelte", import.meta.url),
);
const appCss = await Deno.readTextFile(
new URL("../src/app.css", import.meta.url),
);
const designLanguage = await Deno.readTextFile(
new URL(
"../../../docs/development/ui-ux/design-language.md",
import.meta.url,
),
);
for (
const contract of [
"export type BevelProfile = 'edge' | 'ridge'",
"export type BevelDepth = 'raised' | 'inset'",
"children: Snippet<[]>",
"top?: boolean",
"right?: boolean",
"bottom?: boolean",
"left?: boolean",
"pointer-events: none",
"data-pressed={pressed || undefined}",
"aria-invalid='true'",
"forced-colors: active",
"mask-composite: exclude",
]
) {
assert(source.includes(contract), `Bevel must preserve ${contract}`);
}
assert(
source.includes("@supports") &&
source.includes("border-style: solid") &&
source.includes(
"border-width: var(--_top-w) var(--_right-w) var(--_bottom-w) var(--_left-w)",
),
"Bevel must retain a side-selective plain border fallback around the enhanced edge",
);
assert(
source.includes("calc(max(cos(180deg - var(--_L)), 0) * 100%)") &&
source.includes("var(--_s6) 90deg") &&
source.includes("var(--_s6) 270deg") &&
!source.includes("+ 1) * 50%"),
"Bevel must clamp unlit faces to the configured shadow without ambient light",
);
assert(
appCss.includes("--bevel-face-width: 2px") &&
source.includes("--_W: var(--bevel-face-width)") &&
source.includes("inset: var(--bevel-face-width)") &&
!source.includes("BevelSize") &&
!source.includes("data-size"),
"Bevel must use the project-wide 2px edge and compose ridge from two 2px faces",
);
assert(
source.includes("data-top={top ? 'true' : 'false'}") &&
source.includes("data-right={right ? 'true' : 'false'}") &&
source.includes("data-bottom={bottom ? 'true' : 'false'}") &&
source.includes("data-left={left ? 'true' : 'false'}") &&
source.includes(".bevel[data-top='false'] {") &&
source.includes("--bevel-top-left-radius: 0px") &&
source.includes("--bevel-top-right-radius: 0px") &&
source.includes(".bevel[data-right='false'] {") &&
source.includes("--bevel-bottom-right-radius: 0px") &&
source.includes(".bevel[data-bottom='false'] {") &&
source.includes("--bevel-bottom-left-radius: 0px") &&
source.includes(".bevel[data-left='false'] {") &&
source.includes("--bevel-top-inset: 0px") &&
source.includes("--bevel-right-inset: 0px") &&
source.includes("--bevel-bottom-inset: 0px") &&
source.includes("--bevel-left-inset: 0px"),
"Bevel must draw selected sides and round only corners shared by two enabled sides",
);
assert(
source.includes("data-profile={profile}") &&
source.includes("[data-profile='ridge'][data-depth='raised']::after") &&
source.includes("[data-profile='ridge'][data-depth='inset']::after") &&
!source.includes("data-depth='ridge'"),
"Bevel must keep edge/ridge profile orthogonal to raised/inset depth",
);
assert(
appCss.includes("--bevel-highlight: #fff") &&
appCss.includes("--bevel-shadow: #000") &&
!source.includes("surface") &&
!source.includes("tone") &&
!source.includes("background-color:"),
"Bevel must use the shared highlight and shadow palette without owning a surface tone",
);
assert(
designLanguage.includes("Bevelはedge") &&
designLanguage.includes("lightingだけを所有する") &&
designLanguage.includes("text/content areaだけとする"),
"The design language must prohibit Bevel surface colors outside the child content area",
);
assert(
source.includes("button, input, select, textarea"),
"Bevel must leave native semantic controls inside the visual wrapper",
);
});
Deno.test("Workspace showroom exercises raised inset and ridge edges", async () => {
const workspaceSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/+page.svelte",
import.meta.url,
),
);
const settingsSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/settings/+page.svelte",
import.meta.url,
),
);
assert(
workspaceSource.includes('profile="edge" depth="raised"') &&
workspaceSource.includes('profile="ridge" depth="raised"') &&
workspaceSource.includes('profile="ridge" depth="inset"') &&
workspaceSource.includes("right={false} bottom={false}") &&
workspaceSource.includes("top={false} left={false}") &&
workspaceSource.includes("pressed"),
"Workspace showroom must demonstrate edge and ridge depths with full and adjacent side selections",
);
assert(
settingsSource.includes('profile="edge" depth="inset"') &&
settingsSource.includes('aria-invalid="true"'),
"Settings showroom must demonstrate inset and invalid fields",
);
});
@@ -0,0 +1,112 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("BevelLine exposes a semantic directional separator", async () => {
const source = await Deno.readTextFile(
new URL("../src/lib/workspace/ui/BevelLine.svelte", import.meta.url),
);
const appCss = await Deno.readTextFile(
new URL("../src/app.css", import.meta.url),
);
for (
const contract of [
"export type BevelLineDirection = 'x' | 'y'",
"length?: string",
"as?: 'span' | 'div'",
"role={decorative ? undefined : 'separator'}",
"'horizontal' : 'vertical'",
"style:width={direction === 'x' ? length : undefined}",
"style:height={direction === 'y' ? length : undefined}",
"pointer-events: none",
"forced-colors: active",
]
) {
assert(source.includes(contract), `BevelLine must preserve ${contract}`);
}
assert(
appCss.includes("--bevel-face-width: 2px") &&
source.includes("--bevel-line-face-width: var(--bevel-face-width)") &&
source.includes(
"--bevel-line-width: calc(var(--bevel-line-face-width) * 2)",
) &&
!source.includes("BevelLineWeight") &&
!source.includes("data-weight"),
"BevelLine must compose a fixed 4px ridge from two project-wide 2px faces",
);
assert(
source.includes("background: var(--line-strong)") &&
source.includes("@supports") &&
source.includes("linear-gradient(to bottom") &&
source.includes("linear-gradient(to right"),
"BevelLine must retain a plain fallback and directional enhanced edges",
);
assert(
source.includes("var(--bevel-highlight)") &&
source.includes("var(--bevel-shadow)") &&
!source.includes("surface"),
"BevelLine must use the shared highlight and shadow palette without a surface color",
);
assert(
source.includes("calc(max(cos(180deg - var(--_L)), 0) * 100%)") &&
source.includes("var(--_s6) 90deg") &&
source.includes("var(--_s6) 270deg") &&
!source.includes("+ 1) * 50%"),
"BevelLine must clamp unlit faces to the configured shadow without ambient light",
);
});
Deno.test("Workspace showroom demonstrates both BevelLine directions", async () => {
const source = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/+page.svelte",
import.meta.url,
),
);
assert(
source.includes('direction="x" length="100%"') &&
source.includes('direction="y" length="72px"') &&
source.includes('direction="x" length="160px" depth="inset"') &&
!source.includes("weight="),
"Workspace showroom must demonstrate horizontal, vertical, and inset BevelLine states",
);
});
Deno.test("Workspace shell and showroom use BevelLine for structural separators", async () => {
const rootLayout = await Deno.readTextFile(
new URL("../src/routes/+layout.svelte", import.meta.url),
);
const sidebarFrame = await Deno.readTextFile(
new URL(
"../src/lib/workspace/sidebar/SidebarFrame.svelte",
import.meta.url,
),
);
const sidebarCss = await Deno.readTextFile(
new URL("../src/lib/workspace/sidebar/sidebar.css", import.meta.url),
);
const showroomCss = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/showroom.css",
import.meta.url,
),
);
assert(
rootLayout.includes("app-shell__topbar-divider") &&
sidebarFrame.includes("sidebar-frame__divider--vertical") &&
sidebarFrame.includes("sidebar-frame__divider--horizontal"),
"Workspace shell must render structural dividers through BevelLine",
);
assert(
!sidebarCss.includes("border-right:") &&
!sidebarCss.includes("border-bottom:") &&
!showroomCss.includes("border-top: 1px solid") &&
!showroomCss.includes("border-bottom: 1px solid"),
"Shell and showroom structural separators must not fall back to one-sided solid borders",
);
});
@@ -0,0 +1,59 @@
// @ts-nocheck
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Tooltip exposes the same contextual help to pointer and keyboard users", async () => {
const source = await Deno.readTextFile(
new URL("../src/lib/workspace/ui/Tooltip.svelte", import.meta.url),
);
for (
const contract of [
'role="tooltip"',
"onpointerenter={showAfterDelay}",
"onfocusin={showImmediately}",
"event.key === 'Escape'",
"children: Snippet<[descriptionId: string]>",
"max-width: min(20rem, calc(100vw - var(--space-4)))",
]
) {
assert(source.includes(contract), `Tooltip must preserve ${contract}`);
}
});
Deno.test("Tooltip showroom binds help to actions and column headings", async () => {
const workspaceSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/+page.svelte",
import.meta.url,
),
);
const settingsSource = await Deno.readTextFile(
new URL(
"../src/routes/design-lab/workspace-web-ux/settings/+page.svelte",
import.meta.url,
),
);
for (const descriptionId of ["rerun-help", "last-seen-help", "source-help"]) {
assert(
workspaceSource.includes(descriptionId),
`Workspace showroom must expose contextual help ${descriptionId}`,
);
}
assert(
settingsSource.includes("retry-runtime-help"),
"Settings showroom must expose retry operation help",
);
assert(
workspaceSource.includes('class="column-help"') &&
workspaceSource.includes("aria-describedby={descriptionId}>Last seen"),
"Column help must be bound to the column label rather than a separate info button",
);
assert(
!workspaceSource.includes("Actions explain") &&
!workspaceSource.includes("Use a table when"),
"Showroom must not restore persistent operation or table explanation",
);
});