feat: refine workspace navigation chrome
This commit is contained in:
@@ -147,9 +147,21 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||
assert(
|
||||
!sidebar.includes("CompanionNavSection") &&
|
||||
sidebar.includes("TicketsNavSection") &&
|
||||
sidebar.includes("MemoryNavSection") &&
|
||||
sidebar.includes("WorkersNavSection"),
|
||||
"standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation",
|
||||
sidebar.includes("MergeRequestsNavSection") &&
|
||||
sidebar.lastIndexOf("MergeRequestsNavSection") <
|
||||
sidebar.lastIndexOf("MemoryNavSection") &&
|
||||
sidebar.includes("WorkersNavSection") &&
|
||||
sidebarCss.includes("gap: var(--space-1)") &&
|
||||
sidebarCss.includes(".sidebar-nav-section--category > .sidebar-link") &&
|
||||
sidebarCss.includes("padding-block: var(--space-1)") &&
|
||||
sidebarCss.includes("margin-left: var(--space-3)") &&
|
||||
sidebarCss.includes("--sidebar-item-hover: oklch(24% 0 0)") &&
|
||||
sidebarCss.includes("--sidebar-item-active: oklch(32% 0 0)") &&
|
||||
sidebarCss.includes("background: var(--sidebar-item-hover)") &&
|
||||
sidebarCss.includes("background: var(--sidebar-item-active)") &&
|
||||
!sidebarCss.includes("background: var(--interactive-selected)") &&
|
||||
!sidebarCss.includes("margin-inline: calc(-1"),
|
||||
"workspace navigation should place Merge Requests before an indented compact Memory category",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,6 +172,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
const objectivesNav = await Deno.readTextFile(
|
||||
new URL("../sidebar/ObjectivesNavSection.svelte", import.meta.url),
|
||||
);
|
||||
const mergeRequestsNav = await Deno.readTextFile(
|
||||
new URL("../sidebar/MergeRequestsNavSection.svelte", import.meta.url),
|
||||
);
|
||||
const ticketsLoad = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/tickets/+page.ts",
|
||||
@@ -205,15 +220,22 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
|
||||
assert(
|
||||
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
||||
ticketsNav.includes('class="primary-nav-link"') &&
|
||||
ticketsNav.includes("sidebar-nav-section--resource") &&
|
||||
ticketsNav.includes('class="sidebar-link"') &&
|
||||
ticketsNav.includes(">Tickets</a>") &&
|
||||
!ticketsNav.includes("Open Tickets") &&
|
||||
!ticketsNav.includes("workspace tickets") &&
|
||||
objectivesNav.includes('class="primary-nav-link"') &&
|
||||
objectivesNav.includes("sidebar-nav-section--resource") &&
|
||||
objectivesNav.includes('class="sidebar-link"') &&
|
||||
objectivesNav.includes(">Objectives</a>") &&
|
||||
!objectivesNav.includes("Open Objectives") &&
|
||||
!objectivesNav.includes("workspace objectives"),
|
||||
"Tickets and Objectives should each be a single sidebar link",
|
||||
!objectivesNav.includes("workspace objectives") &&
|
||||
mergeRequestsNav.includes("sidebar-nav-section--resource") &&
|
||||
mergeRequestsNav.includes('class="sidebar-link"') &&
|
||||
mergeRequestsNav.includes(">Merge Requests</a>") &&
|
||||
!mergeRequestsNav.includes("All Merge Requests") &&
|
||||
!mergeRequestsNav.includes("review and integration resources"),
|
||||
"Tickets, Objectives, and Merge Requests should each be a single primary sidebar link",
|
||||
);
|
||||
assert(
|
||||
ticketsLoad.includes("Object.entries(LANE_STATES)") &&
|
||||
@@ -295,11 +317,19 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
);
|
||||
|
||||
assert(
|
||||
memoryNav.includes("workspaceRoute(workspaceId, '/memory')") &&
|
||||
memoryNav.includes("durable workspace memory") &&
|
||||
memoryNav.includes("workspaceRoute(workspaceId, '/memory/staging')") &&
|
||||
memoryNav.includes("pending extraction candidates"),
|
||||
"Memory sidebar section should link to Document and Staging surfaces",
|
||||
memoryNav.includes('workspaceRoute(workspaceId, "/memory")') &&
|
||||
memoryNav.includes(
|
||||
'<h2 class="sidebar-nav-section__header">Memory</h2>',
|
||||
) &&
|
||||
memoryNav.includes("Document</a>") &&
|
||||
memoryNav.includes("sidebar-nav-section--category") &&
|
||||
memoryNav.includes('class="sidebar-link"') &&
|
||||
memoryNav.includes('workspaceRoute(workspaceId, "/memory/staging")') &&
|
||||
memoryNav.includes("Staging</a>") &&
|
||||
!memoryNav.includes("item-meta") &&
|
||||
!memoryNav.includes("durable workspace memory") &&
|
||||
!memoryNav.includes("pending extraction candidates"),
|
||||
"Memory sidebar section should show Document and Staging as compact single-line links",
|
||||
);
|
||||
assert(
|
||||
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
@@ -430,7 +460,7 @@ Deno.test("Worker Console expands uncapped tool body from the hover detail actio
|
||||
"return detailOpen ? (line.expandedBody ?? line.body) : line.body",
|
||||
) &&
|
||||
consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
|
||||
consoleLine.includes('class={`tool-status') &&
|
||||
consoleLine.includes("class={`tool-status") &&
|
||||
consoleLine.includes('class="tool-detail-button"') &&
|
||||
consoleLine.includes("aria-expanded={detailOpen}") &&
|
||||
consoleLine.includes("detailOpen = !detailOpen") &&
|
||||
@@ -513,7 +543,7 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
|
||||
"padding: var(--space-3) var(--space-6) var(--space-4)",
|
||||
) &&
|
||||
!page.includes("margin-inline: calc(-1 * var(--space-6))") &&
|
||||
page.includes('import { pushWorkspaceAlert }') &&
|
||||
page.includes("import { pushWorkspaceAlert }") &&
|
||||
page.includes('title: "Worker control"') &&
|
||||
page.includes('title: "Rewind targets"') &&
|
||||
page.includes('pushWorkspaceAlert("error"') &&
|
||||
@@ -698,7 +728,9 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
||||
assert(
|
||||
consolePage.includes("const token = advanceReloadToken();") &&
|
||||
consolePage.includes("worker = targetWorker;") &&
|
||||
consolePage.includes("if (!targetWorker) void loadWorker(target, token);") &&
|
||||
consolePage.includes(
|
||||
"if (!targetWorker) void loadWorker(target, token);",
|
||||
) &&
|
||||
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
|
||||
"target-change effect should install route data and guard fallback loading with the new target token",
|
||||
);
|
||||
@@ -789,18 +821,21 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||
rootLayout.includes("SIDEBAR_CONTEXT") &&
|
||||
rootLayout.includes("GlobalSidebar") &&
|
||||
rootLayout.includes("SidebarFrame") &&
|
||||
rootLayout.includes("{@render sidebar()}") &&
|
||||
rootLayout.includes("content={sidebar}") &&
|
||||
!rootLayout.includes("WorkspaceSidebar") &&
|
||||
rootLayout.includes("workspace-topbar") &&
|
||||
rootLayout.includes("topbar-icon-button") &&
|
||||
rootLayout.includes('class="app-shell"') &&
|
||||
rootLayout.includes('class="app-shell__main"') &&
|
||||
rootLayout.includes("app-shell__topbar") &&
|
||||
rootLayout.includes("app-shell__icon-button") &&
|
||||
rootLayout.includes('href="/account"') &&
|
||||
rootLayout.includes("Open Account") &&
|
||||
!sidebar.includes("accountHref") &&
|
||||
!sidebar.includes("Open Account"),
|
||||
"Root layout chrome should render a registered sidebar snippet or default global sidebar while account navigation stays in the header",
|
||||
"Root layout chrome should keep GlobalSidebar as the root slot owner while account navigation stays in the header",
|
||||
);
|
||||
assert(
|
||||
globalSidebar.includes("Global") &&
|
||||
globalSidebar.includes('aria-label="Global pages"') &&
|
||||
!globalSidebar.includes('<p class="sidebar-section-label">') &&
|
||||
globalSidebar.includes("/account") &&
|
||||
globalSidebar.includes("/login/device") &&
|
||||
!globalSidebar.includes("Tickets") &&
|
||||
@@ -810,12 +845,11 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||
assert(
|
||||
workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
|
||||
workspaceLayout.includes("WorkspaceSidebar") &&
|
||||
workspaceLayout.includes(
|
||||
"<SidebarOverride sidebar={workspaceSidebar} />",
|
||||
) &&
|
||||
workspaceLayout.includes("controller={parentSidebarController}") &&
|
||||
workspaceLayout.includes("sidebar={workspaceSidebar}") &&
|
||||
workspaceLayoutLoad.includes("params.workspaceId") &&
|
||||
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
|
||||
"Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
|
||||
"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)") &&
|
||||
@@ -872,8 +906,8 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
|
||||
"Sidebar and Console should share one Workspace multiplexer and route Worker methods through a subscription lane",
|
||||
);
|
||||
assert(
|
||||
multiplexer.includes('nextMultiplexerId') &&
|
||||
!multiplexer.includes('crypto.randomUUID'),
|
||||
multiplexer.includes("nextMultiplexerId") &&
|
||||
!multiplexer.includes("crypto.randomUUID"),
|
||||
"Workspace subscription correlation IDs should not require secure-context crypto APIs",
|
||||
);
|
||||
assert(
|
||||
@@ -881,7 +915,9 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
|
||||
multiplexer.includes("this.#sendSubscribe(subscription)") &&
|
||||
consolePage.includes("const targetWorker = data.worker") &&
|
||||
consolePage.includes("worker = targetWorker") &&
|
||||
consolePage.includes("const consoleTarget = $derived({ workspaceId, runtimeId, workerId })"),
|
||||
consolePage.includes(
|
||||
"const consoleTarget = $derived({ workspaceId, runtimeId, workerId })",
|
||||
),
|
||||
"A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker",
|
||||
);
|
||||
});
|
||||
@@ -945,7 +981,9 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
|
||||
consolePage.includes("selectedConsoleProjection.lines") &&
|
||||
consolePage.includes("selectedConsoleProjection.tasks") &&
|
||||
consolePage.includes("onSelectWorkerView") &&
|
||||
consolePage.includes("selectConsoleWorkerView(resolvedSessionId, false)") &&
|
||||
consolePage.includes(
|
||||
"selectConsoleWorkerView(resolvedSessionId, false)",
|
||||
) &&
|
||||
consolePage.includes("consoleWorkerViewSelectionIsResolved") &&
|
||||
!consolePage.includes("internal-worker-pane") &&
|
||||
!consolePage.includes("flattenInternalWorkers"),
|
||||
|
||||
@@ -1,33 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { buildWorkspaceBreadcrumbs } from './breadcrumb-model';
|
||||
import { page } from "$app/state";
|
||||
import WorkspaceSwitcher from "$lib/workspace/sidebar/WorkspaceSwitcher.svelte";
|
||||
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
||||
import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model";
|
||||
|
||||
let { workspaceId }: { workspaceId: string } = $props();
|
||||
type Props = {
|
||||
workspaceId: string;
|
||||
workspace?: WorkspaceResponse | null;
|
||||
workspaceError?: string | null;
|
||||
};
|
||||
|
||||
let { workspaceId, workspace = null, workspaceError = null }: Props = $props();
|
||||
|
||||
const workerName = $derived.by(() => {
|
||||
const data = page.data as Record<string, unknown>;
|
||||
const worker = data.worker as { display_name?: string | null; label?: string | null } | null | undefined;
|
||||
const worker = data.worker as
|
||||
| { display_name?: string | null; label?: string | null }
|
||||
| null
|
||||
| undefined;
|
||||
return worker?.display_name ?? worker?.label ?? null;
|
||||
});
|
||||
const breadcrumbs = $derived(buildWorkspaceBreadcrumbs(page.url.pathname, workspaceId, { workerName }));
|
||||
const workspaceRoot = $derived(`/w/${encodeURIComponent(workspaceId)}`);
|
||||
const breadcrumbs = $derived(
|
||||
buildWorkspaceBreadcrumbs(page.url.pathname, workspaceId, { workerName }),
|
||||
);
|
||||
const currentWorkspaceName = $derived(
|
||||
workspaceError ? workspaceId : workspace?.display_name || workspaceId,
|
||||
);
|
||||
</script>
|
||||
|
||||
<nav class="workspace-breadcrumbs" aria-label="Current workspace location">
|
||||
<a class="workspace-breadcrumb-root" href={workspaceRoot} aria-label="Workspace home">/</a>
|
||||
{#each breadcrumbs as breadcrumb, index (`${index}:${breadcrumb.label}`)}
|
||||
{#if index > 0}<span class="workspace-breadcrumb-separator" aria-hidden="true">/</span>{/if}
|
||||
{#if breadcrumb.href}
|
||||
<a href={breadcrumb.href}>{breadcrumb.label}</a>
|
||||
{:else}
|
||||
<span class="workspace-breadcrumb-label" aria-current={index === breadcrumbs.length - 1 ? 'page' : undefined}>
|
||||
{breadcrumb.label}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="workspace-header-location">
|
||||
<WorkspaceSwitcher
|
||||
variant="header"
|
||||
currentWorkspaceId={workspaceId}
|
||||
{currentWorkspaceName}
|
||||
/>
|
||||
|
||||
{#if breadcrumbs.length > 0}
|
||||
<span class="workspace-breadcrumb-separator" aria-hidden="true">/</span>
|
||||
<nav class="workspace-breadcrumbs" aria-label="Current workspace location">
|
||||
{#each breadcrumbs as breadcrumb, index (`${index}:${breadcrumb.label}`)}
|
||||
{#if index > 0}<span class="workspace-breadcrumb-separator" aria-hidden="true">/</span>{/if}
|
||||
{#if breadcrumb.href}
|
||||
<a href={breadcrumb.href}>{breadcrumb.label}</a>
|
||||
{:else}
|
||||
<span class="workspace-breadcrumb-label" aria-current={index === breadcrumbs.length - 1 ? "page" : undefined}>
|
||||
{breadcrumb.label}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workspace-header-location,
|
||||
.workspace-breadcrumbs {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -58,16 +84,12 @@
|
||||
text-underline-offset: 0.22rem;
|
||||
}
|
||||
|
||||
.workspace-breadcrumb-root {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workspace-breadcrumb-separator {
|
||||
color: color-mix(in srgb, currentColor 45%, transparent);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.workspace-breadcrumbs span[aria-current='page'] {
|
||||
.workspace-breadcrumbs span[aria-current="page"] {
|
||||
color: var(--text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { SidebarSnippet } from './context';
|
||||
import './sidebar.css';
|
||||
|
||||
type Props = {
|
||||
currentPath: string;
|
||||
content?: SidebarSnippet | null;
|
||||
};
|
||||
|
||||
const { currentPath }: Props = $props();
|
||||
const { currentPath, content = null }: Props = $props();
|
||||
|
||||
const items = [
|
||||
{ href: '/', label: 'Workspaces' },
|
||||
@@ -15,20 +17,23 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="global-sidebar" aria-label="Global navigation">
|
||||
<div class="global-sidebar-section">
|
||||
<p class="sidebar-section-label">Global</p>
|
||||
<nav class="sidebar-list" aria-label="Global pages">
|
||||
{#each items as item}
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath === item.href}
|
||||
href={item.href}
|
||||
aria-current={currentPath === item.href ? 'page' : undefined}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if content}
|
||||
{@render content()}
|
||||
{:else}
|
||||
<div class="global-sidebar" aria-label="Global navigation">
|
||||
<div class="global-sidebar-section">
|
||||
<nav class="sidebar-list" aria-label="Global pages">
|
||||
{#each items as item}
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath === item.href}
|
||||
href={item.href}
|
||||
aria-current={currentPath === item.href ? 'page' : undefined}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
||||
|
||||
type Props = {
|
||||
currentPath?: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
let { currentPath = '/', workspaceId }: Props = $props();
|
||||
let documentHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/memory') : '/');
|
||||
let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/memory/staging') : '/');
|
||||
let { currentPath = "/", workspaceId }: Props = $props();
|
||||
let documentHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory") : "/");
|
||||
let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory/staging") : "/");
|
||||
</script>
|
||||
|
||||
<section class="nav-section">
|
||||
<header class="section-header">
|
||||
<span>Memory</span>
|
||||
</header>
|
||||
<section class="sidebar-nav-section sidebar-nav-section--category">
|
||||
<h2 class="sidebar-nav-section__header">Memory</h2>
|
||||
|
||||
<a class="objective-link" class:active={currentPath === documentHref} href={documentHref}>
|
||||
<span class="item-title">Document</span>
|
||||
<span class="item-meta">durable workspace memory</span>
|
||||
</a>
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath === documentHref}
|
||||
href={documentHref}
|
||||
aria-current={currentPath === documentHref ? "page" : undefined}
|
||||
>Document</a>
|
||||
|
||||
<a class="objective-link" class:active={currentPath.startsWith(stagingHref)} href={stagingHref}>
|
||||
<span class="item-title">Staging</span>
|
||||
<span class="item-meta">pending extraction candidates</span>
|
||||
</a>
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath.startsWith(stagingHref)}
|
||||
href={stagingHref}
|
||||
aria-current={currentPath.startsWith(stagingHref) ? "page" : undefined}
|
||||
>Staging</a>
|
||||
</section>
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
let href = $derived(workspaceId ? mergeRequestPagePath(workspaceId) : "/");
|
||||
</script>
|
||||
|
||||
<section class="nav-section">
|
||||
<header class="section-header"><span>Merge Requests</span></header>
|
||||
<a class="objective-link" class:active={currentPath.startsWith(href)} {href}>
|
||||
<span class="item-title">All Merge Requests</span>
|
||||
<span class="item-meta">review and integration resources</span>
|
||||
</a>
|
||||
<section class="sidebar-nav-section sidebar-nav-section--resource">
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath.startsWith(href)}
|
||||
{href}
|
||||
aria-current={currentPath.startsWith(href) ? "page" : undefined}
|
||||
>Merge Requests</a>
|
||||
</section>
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
let objectivesHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/objectives') : '/objectives');
|
||||
</script>
|
||||
|
||||
<section class="nav-section">
|
||||
<section class="sidebar-nav-section sidebar-nav-section--resource">
|
||||
<a
|
||||
class="primary-nav-link"
|
||||
class="sidebar-link"
|
||||
class:active={currentPath.startsWith(objectivesHref)}
|
||||
href={objectivesHref}
|
||||
aria-current={currentPath.startsWith(objectivesHref) ? 'page' : undefined}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model';
|
||||
import type { SidebarSnippet } from './context';
|
||||
|
||||
let {
|
||||
workspaceId,
|
||||
currentPath,
|
||||
content = null,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
currentPath: string;
|
||||
content?: SidebarSnippet | null;
|
||||
} = $props();
|
||||
|
||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
|
||||
|
||||
function sectionHref(path: string): string {
|
||||
return workspaceId ? workspaceRoute(workspaceId, path) : path;
|
||||
}
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="settings-sidebar">
|
||||
<div class="section-heading">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
|
||||
{#if content}
|
||||
{@render content()}
|
||||
{:else}
|
||||
<nav class="sidebar-sections" aria-label="Settings sections">
|
||||
<div class="sidebar-nav-section">
|
||||
<div class="sidebar-list">
|
||||
<a
|
||||
class:active={currentPath === settingsHref}
|
||||
class="sidebar-link"
|
||||
href={settingsHref}
|
||||
aria-current={currentPath === settingsHref ? 'page' : undefined}
|
||||
>
|
||||
<span class="sidebar-link-label">Overview</span>
|
||||
</a>
|
||||
{#each SETTINGS_SECTIONS as section}
|
||||
{@const href = sectionHref(settingsSectionHref(section.id))}
|
||||
<a
|
||||
class:active={isActive(href)}
|
||||
class="sidebar-link"
|
||||
href={href}
|
||||
aria-current={isActive(href) ? 'page' : undefined}
|
||||
>
|
||||
<span class="sidebar-link-label">{section.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,45 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model';
|
||||
|
||||
let { workspaceId, currentPath }: { workspaceId: string; currentPath: string } = $props();
|
||||
|
||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
|
||||
|
||||
function sectionHref(path: string): string {
|
||||
return workspaceId ? workspaceRoute(workspaceId, path) : path;
|
||||
}
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Settings sections">
|
||||
<div class="nav-section">
|
||||
<div class="section-heading">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
<div class="sidebar-list">
|
||||
<a
|
||||
class:active={currentPath === settingsHref}
|
||||
class="sidebar-link"
|
||||
href={settingsHref}
|
||||
aria-current={currentPath === settingsHref ? 'page' : undefined}
|
||||
>
|
||||
<span class="sidebar-link-label">Overview</span>
|
||||
</a>
|
||||
{#each SETTINGS_SECTIONS as section}
|
||||
{@const href = sectionHref(settingsSectionHref(section.id))}
|
||||
<a
|
||||
class:active={isActive(href)}
|
||||
class="sidebar-link"
|
||||
href={href}
|
||||
aria-current={isActive(href) ? 'page' : undefined}
|
||||
>
|
||||
<span class="sidebar-link-label">{section.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { getSidebarController, type SidebarSnippet } from './context';
|
||||
import {
|
||||
getSidebarController,
|
||||
type SidebarController,
|
||||
type SidebarSnippet,
|
||||
} from './context';
|
||||
|
||||
type Props = {
|
||||
sidebar: SidebarSnippet;
|
||||
controller?: SidebarController;
|
||||
};
|
||||
|
||||
const { sidebar }: Props = $props();
|
||||
const controller = getSidebarController();
|
||||
const inheritedController = getSidebarController();
|
||||
const { sidebar, controller = inheritedController }: Props = $props();
|
||||
|
||||
$effect(() => controller.registerSidebar(sidebar));
|
||||
</script>
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
let ticketsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/tickets') : '/');
|
||||
</script>
|
||||
|
||||
<section class="nav-section">
|
||||
<section class="sidebar-nav-section sidebar-nav-section--resource">
|
||||
<a
|
||||
class="primary-nav-link"
|
||||
class="sidebar-link"
|
||||
class:active={currentPath.startsWith(ticketsHref)}
|
||||
href={ticketsHref}
|
||||
aria-current={currentPath.startsWith(ticketsHref) ? 'page' : undefined}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="nav-section" aria-labelledby="workers-heading">
|
||||
<section class="sidebar-nav-section" aria-labelledby="workers-heading">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="workers-heading">
|
||||
<a
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import './sidebar.css';
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import MemoryNavSection from './MemoryNavSection.svelte';
|
||||
import MergeRequestsNavSection from './MergeRequestsNavSection.svelte';
|
||||
import TicketsNavSection from './TicketsNavSection.svelte';
|
||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||
import type { WorkspaceResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
@@ -24,15 +24,44 @@
|
||||
}: Props = $props();
|
||||
|
||||
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||
let workspaceHomeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
|
||||
let workspaceSettingsHref = $derived(
|
||||
workspaceId ? workspaceRoute(workspaceId, '/settings') : '/',
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="workspace-sidebar">
|
||||
<header class="sidebar-header">
|
||||
{#if workspace}
|
||||
<WorkspaceSwitcher
|
||||
currentWorkspaceId={workspaceId}
|
||||
currentWorkspaceName={workspace.display_name}
|
||||
/>
|
||||
<nav class="workspace-sidebar-shortcuts" aria-label="Workspace shortcuts">
|
||||
<a
|
||||
class="workspace-sidebar-shortcut"
|
||||
class:active={currentPath === workspaceHomeHref}
|
||||
href={workspaceHomeHref}
|
||||
aria-label="Workspace home"
|
||||
title="Workspace home"
|
||||
aria-current={currentPath === workspaceHomeHref ? '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(workspaceSettingsHref)}
|
||||
href={workspaceSettingsHref}
|
||||
aria-label="Workspace settings"
|
||||
title="Workspace settings"
|
||||
aria-current={currentPath.startsWith(workspaceSettingsHref) ? '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>
|
||||
{:else}
|
||||
<div class="workspace-label">
|
||||
<div class="workspace-name">Yoi workspace</div>
|
||||
@@ -51,8 +80,8 @@
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<TicketsNavSection {currentPath} {workspaceId} />
|
||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||
<MemoryNavSection {currentPath} {workspaceId} />
|
||||
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
||||
<MemoryNavSection {currentPath} {workspaceId} />
|
||||
<WorkersNavSection {currentPath} {workspaceId} />
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import {
|
||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
||||
type WorkspaceSidebarContentController,
|
||||
} from './workspace-content-context';
|
||||
|
||||
let { content }: { content: Snippet } = $props();
|
||||
|
||||
const controller = getContext<WorkspaceSidebarContentController>(
|
||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
||||
);
|
||||
|
||||
$effect(() => controller.registerContent(content));
|
||||
</script>
|
||||
@@ -8,9 +8,11 @@
|
||||
let {
|
||||
currentWorkspaceId,
|
||||
currentWorkspaceName,
|
||||
variant = "sidebar",
|
||||
}: {
|
||||
currentWorkspaceId: string;
|
||||
currentWorkspaceName: string;
|
||||
variant?: "sidebar" | "header";
|
||||
} = $props();
|
||||
|
||||
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
|
||||
@@ -20,6 +22,7 @@
|
||||
let root = $state.raw<HTMLDivElement>();
|
||||
let trigger = $state.raw<HTMLButtonElement>();
|
||||
let menu = $state.raw<HTMLDivElement>();
|
||||
const menuId = $derived(`workspace-menu-popover-${variant}`);
|
||||
|
||||
const menuWorkspaces = $derived.by(() => {
|
||||
const entries = workspaces.map((workspace) => ({
|
||||
@@ -112,14 +115,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="workspace-menu" bind:this={root}>
|
||||
<div class="workspace-menu" class:workspace-menu-header={variant === "header"} bind:this={root}>
|
||||
<button
|
||||
bind:this={trigger}
|
||||
type="button"
|
||||
class="workspace-menu-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls="workspace-menu-popover"
|
||||
aria-controls={menuId}
|
||||
onclick={toggleMenu}
|
||||
onkeydown={handleTriggerKeydown}
|
||||
>
|
||||
@@ -132,7 +135,7 @@
|
||||
{#if open}
|
||||
<div
|
||||
bind:this={menu}
|
||||
id="workspace-menu-popover"
|
||||
id={menuId}
|
||||
class="workspace-menu-popover"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
|
||||
@@ -75,58 +75,95 @@ Deno.test("sidebar disposers remove only their own registration", () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("settings replaces only WorkspaceSidebar content", async () => {
|
||||
const layoutUrl = new URL(
|
||||
"../../../routes/w/[workspaceId]/settings/+layout.svelte",
|
||||
Deno.test("Global, Workspace, and Settings use one recursive sidebar slot contract", async () => {
|
||||
const rootLayoutUrl = new URL(
|
||||
"../../../routes/+layout.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const workspaceLayoutUrl = new URL(
|
||||
"../../../routes/w/[workspaceId]/+layout.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const settingsLayoutUrl = new URL(
|
||||
"../../../routes/w/[workspaceId]/settings/+layout.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const globalSidebarUrl = new URL("./GlobalSidebar.svelte", import.meta.url);
|
||||
const workspaceSidebarUrl = new URL(
|
||||
"./WorkspaceSidebar.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const settingsContentUrl = new URL(
|
||||
"./SettingsSidebarContent.svelte",
|
||||
const settingsSidebarUrl = new URL(
|
||||
"./SettingsSidebar.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const [layout, workspaceLayout, workspaceSidebar, settingsContent] =
|
||||
await Promise.all([
|
||||
Deno.readTextFile(layoutUrl),
|
||||
Deno.readTextFile(workspaceLayoutUrl),
|
||||
Deno.readTextFile(workspaceSidebarUrl),
|
||||
Deno.readTextFile(settingsContentUrl),
|
||||
]);
|
||||
const settingsErrorUrl = new URL(
|
||||
"../../../routes/w/[workspaceId]/settings/+error.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const [
|
||||
rootLayout,
|
||||
workspaceLayout,
|
||||
settingsLayout,
|
||||
globalSidebar,
|
||||
workspaceSidebar,
|
||||
settingsSidebar,
|
||||
settingsError,
|
||||
] = await Promise.all([
|
||||
Deno.readTextFile(rootLayoutUrl),
|
||||
Deno.readTextFile(workspaceLayoutUrl),
|
||||
Deno.readTextFile(settingsLayoutUrl),
|
||||
Deno.readTextFile(globalSidebarUrl),
|
||||
Deno.readTextFile(workspaceSidebarUrl),
|
||||
Deno.readTextFile(settingsSidebarUrl),
|
||||
Deno.readTextFile(settingsErrorUrl),
|
||||
]);
|
||||
|
||||
assert(
|
||||
layout.includes(
|
||||
"<WorkspaceSidebarContentOverride content={settingsSidebarContent} />",
|
||||
),
|
||||
"settings layout should override the WorkspaceSidebar content slot",
|
||||
rootLayout.includes("<GlobalSidebar") &&
|
||||
rootLayout.includes("content={sidebar}"),
|
||||
"root layout should always render GlobalSidebar as the root slot owner",
|
||||
);
|
||||
for (
|
||||
const [name, layout] of [
|
||||
["Workspace", workspaceLayout],
|
||||
["Settings", settingsLayout],
|
||||
] as const
|
||||
) {
|
||||
assert(
|
||||
layout.includes(
|
||||
"const parentSidebarController = getSidebarController();",
|
||||
) &&
|
||||
layout.includes("setContext<SidebarController>(SIDEBAR_CONTEXT") &&
|
||||
layout.includes("controller={parentSidebarController}"),
|
||||
`${name} layout should register with its parent and provide the same slot contract to children`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
!workspaceLayout.includes("WORKSPACE_SIDEBAR_CONTENT_CONTEXT") &&
|
||||
!settingsLayout.includes("WorkspaceSidebarContentOverride"),
|
||||
"recursive slots should not retain Workspace-specific context or override components",
|
||||
);
|
||||
assert(
|
||||
!layout.includes("<SidebarOverride") && !layout.includes("settings-nav"),
|
||||
"settings layout should not replace the whole sidebar or retain inline navigation",
|
||||
globalSidebar.includes("{@render content()}") &&
|
||||
workspaceSidebar.includes("{@render content()}") &&
|
||||
settingsSidebar.includes("{@render content()}"),
|
||||
"every sidebar layer should render its child through the same content contract",
|
||||
);
|
||||
assert(
|
||||
workspaceLayout.includes(
|
||||
"registerContent: sidebarContentOverrides.register",
|
||||
) &&
|
||||
workspaceLayout.includes("content={sidebarContent}"),
|
||||
"workspace layout should provide and project the active child content",
|
||||
workspaceSidebar.includes('aria-label="Workspace shortcuts"') &&
|
||||
workspaceSidebar.indexOf('aria-label="Workspace shortcuts"') <
|
||||
workspaceSidebar.indexOf("{#if content}"),
|
||||
"WorkspaceSidebar should keep its shortcuts above the recursive child slot",
|
||||
);
|
||||
assert(
|
||||
workspaceSidebar.includes("<WorkspaceSwitcher") &&
|
||||
workspaceSidebar.indexOf("<WorkspaceSwitcher") <
|
||||
workspaceSidebar.indexOf("{#if content}") &&
|
||||
workspaceSidebar.includes("{@render content()}"),
|
||||
"WorkspaceSidebar should retain its header and render child content below it",
|
||||
settingsSidebar.includes("SETTINGS_SECTIONS") &&
|
||||
settingsSidebar.includes('aria-label="Settings sections"'),
|
||||
"SettingsSidebar should render the authoritative settings catalog as its fallback",
|
||||
);
|
||||
assert(
|
||||
settingsContent.includes("SETTINGS_SECTIONS") &&
|
||||
settingsContent.includes('aria-label="Settings sections"'),
|
||||
"SettingsSidebarContent should render the authoritative settings section catalog",
|
||||
settingsError.includes("This settings page could not be loaded") &&
|
||||
settingsError.includes("Back to Settings"),
|
||||
"settings load failures should stay inside the Settings layout and preserve its sidebar",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
@layer components {
|
||||
.sidebar-frame {
|
||||
--sidebar-item-hover: oklch(96% 0 0);
|
||||
--sidebar-item-active: oklch(92.5% 0 0);
|
||||
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
display: flex;
|
||||
@@ -13,6 +16,12 @@
|
||||
padding-block: var(--space-4);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-frame {
|
||||
--sidebar-item-hover: oklch(24% 0 0);
|
||||
--sidebar-item-active: oklch(32% 0 0);
|
||||
}
|
||||
}
|
||||
.sidebar-frame.folded {
|
||||
width: max-content;
|
||||
overflow: hidden;
|
||||
@@ -27,12 +36,14 @@
|
||||
}
|
||||
.sidebar-frame-content,
|
||||
.global-sidebar,
|
||||
.workspace-sidebar {
|
||||
.workspace-sidebar,
|
||||
.settings-sidebar {
|
||||
min-width: 0;
|
||||
}
|
||||
.global-sidebar,
|
||||
.global-sidebar-section,
|
||||
.workspace-sidebar {
|
||||
.workspace-sidebar,
|
||||
.settings-sidebar {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -42,6 +53,40 @@
|
||||
margin-bottom: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.workspace-sidebar-shortcuts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.workspace-sidebar-shortcut {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-soft);
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.workspace-sidebar-shortcut > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.workspace-sidebar-shortcut:hover,
|
||||
.workspace-sidebar-shortcut:focus-visible {
|
||||
background: var(--sidebar-item-hover);
|
||||
}
|
||||
.workspace-sidebar-shortcut.active {
|
||||
background: var(--sidebar-item-active);
|
||||
}
|
||||
.workspace-sidebar-shortcut:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.sidebar-control-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -77,6 +122,10 @@
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.workspace-menu-header {
|
||||
flex: 0 1 auto;
|
||||
max-width: min(28vw, 20rem);
|
||||
}
|
||||
.workspace-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -119,6 +168,21 @@
|
||||
.workspace-menu-trigger[aria-expanded="true"] > svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.workspace-menu-header .workspace-menu-trigger {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
padding: 0.35rem 0.45rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
.workspace-menu-header .workspace-menu-popover {
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
width: min(20rem, 72vw);
|
||||
}
|
||||
.workspace-menu-trigger:hover,
|
||||
.workspace-menu-trigger:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
@@ -165,12 +229,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workspace-menu-item:hover,
|
||||
.workspace-menu-item:focus-visible,
|
||||
.workspace-menu-item.current {
|
||||
background: var(--interactive-hover);
|
||||
.workspace-menu-item:focus-visible {
|
||||
background: var(--sidebar-item-hover);
|
||||
}
|
||||
.workspace-menu-item.current {
|
||||
color: var(--accent);
|
||||
background: var(--sidebar-item-active);
|
||||
}
|
||||
.workspace-menu-separator {
|
||||
height: 1px;
|
||||
@@ -268,22 +331,22 @@
|
||||
}
|
||||
.sidebar-sections {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
.nav-section {
|
||||
.sidebar-nav-section {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.section-heading-row,
|
||||
.section-header {
|
||||
.sidebar-nav-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.section-heading-row h2,
|
||||
.section-header,
|
||||
.sidebar-nav-section__header,
|
||||
.sidebar-section-label {
|
||||
color: var(--text-faint);
|
||||
font-size: 0.72rem;
|
||||
@@ -292,17 +355,21 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-heading-row h2,
|
||||
.sidebar-nav-section__header,
|
||||
.sidebar-section-label {
|
||||
margin: 0;
|
||||
}
|
||||
.section-heading-link {
|
||||
border-radius: var(--radius-soft);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.section-heading-link:hover,
|
||||
.section-heading-link:focus-visible,
|
||||
.section-heading-link:focus-visible {
|
||||
background: var(--sidebar-item-hover);
|
||||
}
|
||||
.section-heading-link.active {
|
||||
color: var(--accent);
|
||||
background: var(--sidebar-item-active);
|
||||
}
|
||||
.section-count {
|
||||
color: var(--text-muted);
|
||||
@@ -317,61 +384,31 @@
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.primary-nav-link,
|
||||
.nav-item,
|
||||
.objective-link,
|
||||
.sidebar-link {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
margin-inline: calc(-1 * var(--space-2));
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-soft);
|
||||
color: inherit;
|
||||
color: var(--text-strong);
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
.primary-nav-link {
|
||||
color: var(--text-strong);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
.sidebar-nav-section--category {
|
||||
padding-block: var(--space-1);
|
||||
}
|
||||
.sidebar-nav-section--category > .sidebar-link {
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
a.primary-nav-link:hover,
|
||||
a.primary-nav-link:focus-visible,
|
||||
a.nav-item:hover,
|
||||
a.nav-item:focus-visible,
|
||||
a.objective-link:hover,
|
||||
a.objective-link:focus-visible,
|
||||
a.sidebar-link:hover,
|
||||
a.sidebar-link:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
background: var(--sidebar-item-hover);
|
||||
}
|
||||
a.primary-nav-link.active,
|
||||
a.nav-item.active,
|
||||
a.objective-link.active,
|
||||
a.sidebar-link.active {
|
||||
background: var(--interactive-selected);
|
||||
}
|
||||
a.primary-nav-link.active,
|
||||
a.nav-item.active .item-title,
|
||||
a.objective-link.active .item-title,
|
||||
a.sidebar-link.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.item-title,
|
||||
.item-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.item-title {
|
||||
color: var(--text-strong);
|
||||
font-weight: 650;
|
||||
}
|
||||
.item-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
background: var(--sidebar-item-active);
|
||||
}
|
||||
.worker-nav-link {
|
||||
display: grid;
|
||||
@@ -386,12 +423,10 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
.worker-nav-link:hover {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--text-strong);
|
||||
background: var(--sidebar-item-hover);
|
||||
}
|
||||
.worker-nav-link.active {
|
||||
background: var(--interactive-selected);
|
||||
color: var(--accent);
|
||||
background: var(--sidebar-item-active);
|
||||
}
|
||||
.worker-status-indicator {
|
||||
grid-column: 1;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
export const WORKSPACE_SIDEBAR_CONTENT_CONTEXT = Symbol(
|
||||
"workspace-sidebar-content",
|
||||
);
|
||||
|
||||
export type WorkspaceSidebarContentController = {
|
||||
registerContent(content: Snippet): () => void;
|
||||
};
|
||||
@@ -13,6 +13,15 @@ const switcherSource = await Deno.readTextFile(
|
||||
const sidebarSource = await Deno.readTextFile(
|
||||
new URL("./WorkspaceSidebar.svelte", import.meta.url),
|
||||
);
|
||||
const sidebarStyles = await Deno.readTextFile(
|
||||
new URL("./sidebar.css", import.meta.url),
|
||||
);
|
||||
const headerSource = await Deno.readTextFile(
|
||||
new URL("../header/WorkspaceBreadcrumbs.svelte", import.meta.url),
|
||||
);
|
||||
const workspaceLayoutSource = await Deno.readTextFile(
|
||||
new URL("../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
Deno.test("workspace name opens the settings and workspace menu", () => {
|
||||
assert(
|
||||
@@ -39,6 +48,10 @@ Deno.test("workspace name opens the settings and workspace menu", () => {
|
||||
switcherSource.includes("currentWorkspaceName"),
|
||||
"trigger does not use the name",
|
||||
);
|
||||
assert(
|
||||
switcherSource.includes("workspace-menu-popover-${variant}"),
|
||||
"sidebar and header instances should use distinct menu ids",
|
||||
);
|
||||
assert(!switcherSource.includes("<select"), "legacy select switcher remains");
|
||||
});
|
||||
|
||||
@@ -61,14 +74,51 @@ Deno.test("workspace menu lists catalog entries and marks the current workspace"
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("workspace sidebar uses the workspace name menu as its header", () => {
|
||||
Deno.test("workspace header prefixes breadcrumbs with the same Workspace menu", () => {
|
||||
assert(
|
||||
sidebarSource.includes("<WorkspaceSwitcher"),
|
||||
"sidebar omits the menu",
|
||||
headerSource.includes("<WorkspaceSwitcher") &&
|
||||
headerSource.includes('variant="header"') &&
|
||||
headerSource.includes("currentWorkspaceName"),
|
||||
"header should render the shared Workspace selector before breadcrumbs",
|
||||
);
|
||||
assert(
|
||||
sidebarSource.includes("currentWorkspaceName={workspace.display_name}"),
|
||||
"sidebar does not pass the current Workspace name",
|
||||
headerSource.indexOf("<WorkspaceSwitcher") <
|
||||
headerSource.indexOf('<nav class="workspace-breadcrumbs"'),
|
||||
"Workspace selector should precede the breadcrumb trail",
|
||||
);
|
||||
assert(
|
||||
workspaceLayoutSource.includes("workspace={data.workspace ?? null}") &&
|
||||
workspaceLayoutSource.includes(
|
||||
"workspaceError={data.workspaceError ?? null}",
|
||||
),
|
||||
"Workspace layout should pass the authoritative Workspace name to the header selector",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("WorkspaceSidebar exposes icon-only home and settings shortcuts", () => {
|
||||
assert(
|
||||
sidebarSource.includes('aria-label="Workspace shortcuts"') &&
|
||||
sidebarSource.includes('aria-label="Workspace home"') &&
|
||||
sidebarSource.includes('aria-label="Workspace settings"') &&
|
||||
sidebarSource.includes("workspaceRoute(workspaceId)") &&
|
||||
sidebarSource.includes("workspaceRoute(workspaceId, '/settings')") &&
|
||||
sidebarStyles.includes(".workspace-sidebar-shortcut.active"),
|
||||
"Workspace Sidebar should expose accessible Home and Settings icon links",
|
||||
);
|
||||
assert(
|
||||
!headerSource.includes("workspace-sidebar-shortcut"),
|
||||
"Workspace shortcuts should remain specific to the Sidebar",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Workspace selector remains in the Header only", () => {
|
||||
assert(
|
||||
!sidebarSource.includes("<WorkspaceSwitcher"),
|
||||
"Workspace Sidebar should not render the Workspace selector",
|
||||
);
|
||||
assert(
|
||||
headerSource.includes("<WorkspaceSwitcher"),
|
||||
"Header should retain the shared Workspace selector",
|
||||
);
|
||||
assert(
|
||||
!sidebarSource.includes('class="sidebar-actions-row"'),
|
||||
|
||||
@@ -25,34 +25,30 @@
|
||||
|
||||
<WorkspaceAlerts />
|
||||
|
||||
<div class="workspace-layout">
|
||||
<div class="app-shell">
|
||||
<SidebarFrame>
|
||||
{#if sidebar}
|
||||
{@render sidebar()}
|
||||
{:else}
|
||||
<GlobalSidebar currentPath={page.url.pathname} />
|
||||
{/if}
|
||||
<GlobalSidebar currentPath={page.url.pathname} content={sidebar} />
|
||||
</SidebarFrame>
|
||||
<header class="workspace-topbar">
|
||||
<div class="workspace-topbar-location">
|
||||
<header class="app-shell__topbar">
|
||||
<div class="app-shell__topbar-location">
|
||||
{#if headerController.content}{@render headerController.content()}{/if}
|
||||
</div>
|
||||
<nav class="workspace-topbar-actions" aria-label="Global navigation">
|
||||
<a class="topbar-icon-button" href="/account" aria-label="Open Account" title="Account">
|
||||
<svg class="topbar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<main class="shell">
|
||||
<main class="app-shell__main">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workspace-layout {
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -64,7 +60,9 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-topbar {
|
||||
.app-shell__topbar {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
@@ -79,18 +77,19 @@
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.workspace-topbar-location {
|
||||
.app-shell__topbar-location {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.workspace-topbar-actions {
|
||||
.app-shell__topbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.topbar-icon-button {
|
||||
.app-shell__icon-button {
|
||||
display: inline-flex;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
@@ -101,13 +100,13 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topbar-icon-button:hover,
|
||||
.topbar-icon-button:focus-visible {
|
||||
.app-shell__icon-button:hover,
|
||||
.app-shell__icon-button:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.topbar-icon {
|
||||
.app-shell__icon {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
fill: none;
|
||||
@@ -117,7 +116,7 @@
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.shell {
|
||||
.app-shell__main {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
@@ -133,7 +132,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.workspace-layout {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
width: 100vw;
|
||||
@@ -142,13 +141,13 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.workspace-topbar {
|
||||
.app-shell__topbar {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.shell {
|
||||
.app-shell__main {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
overflow: visible;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { setContext } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
||||
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
||||
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
||||
import {
|
||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
||||
type WorkspaceSidebarContentController,
|
||||
} from '$lib/workspace/sidebar/workspace-content-context';
|
||||
getSidebarController,
|
||||
SIDEBAR_CONTEXT,
|
||||
type SidebarController,
|
||||
type SidebarSnippet,
|
||||
} from '$lib/workspace/sidebar/context';
|
||||
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
||||
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||
import '$lib/workspace/styles/workspace-pages.css';
|
||||
@@ -17,13 +19,14 @@
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
let sidebarContent = $state<Snippet | null>(null);
|
||||
const sidebarContentOverrides = createOverrideStack<Snippet>((activeContent) => {
|
||||
const parentSidebarController = getSidebarController();
|
||||
let sidebarContent = $state<SidebarSnippet | null>(null);
|
||||
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
|
||||
sidebarContent = activeContent;
|
||||
});
|
||||
|
||||
setContext<WorkspaceSidebarContentController>(WORKSPACE_SIDEBAR_CONTENT_CONTEXT, {
|
||||
registerContent: sidebarContentOverrides.register,
|
||||
setContext<SidebarController>(SIDEBAR_CONTEXT, {
|
||||
registerSidebar: sidebarContentOverrides.register,
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -34,7 +37,11 @@
|
||||
</script>
|
||||
|
||||
{#snippet workspaceHeader()}
|
||||
<WorkspaceBreadcrumbs workspaceId={page.params.workspaceId ?? data.workspace?.workspace_id ?? ''} />
|
||||
<WorkspaceBreadcrumbs
|
||||
workspaceId={page.params.workspaceId ?? data.workspace?.workspace_id ?? ''}
|
||||
workspace={data.workspace ?? null}
|
||||
workspaceError={data.workspaceError ?? null}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet workspaceSidebar()}
|
||||
@@ -47,6 +54,6 @@
|
||||
{/snippet}
|
||||
|
||||
<HeaderOverride content={workspaceHeader} />
|
||||
<SidebarOverride sidebar={workspaceSidebar} />
|
||||
<SidebarOverride controller={parentSidebarController} sidebar={workspaceSidebar} />
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
|
||||
let settingsHref = $derived(
|
||||
page.params.workspaceId
|
||||
? workspaceRoute(page.params.workspaceId, '/settings')
|
||||
: '/settings',
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings unavailable · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="settings-panel" aria-labelledby="settings-error-title">
|
||||
<p class="eyebrow">Settings</p>
|
||||
<h1 id="settings-error-title">This settings page could not be loaded</h1>
|
||||
<p class="section-state error">
|
||||
{page.error?.message ?? 'The Backend rejected or could not complete the settings request.'}
|
||||
</p>
|
||||
<div class="settings-action-row">
|
||||
<a class="button-link" href={settingsHref}>Back to Settings</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,21 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import SettingsSidebarContent from '$lib/workspace/sidebar/SettingsSidebarContent.svelte';
|
||||
import WorkspaceSidebarContentOverride from '$lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte';
|
||||
import { setContext } from 'svelte';
|
||||
import SettingsSidebar from '$lib/workspace/sidebar/SettingsSidebar.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 '$lib/workspace/styles/settings.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,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet settingsSidebarContent()}
|
||||
<SettingsSidebarContent
|
||||
{#snippet settingsSidebar()}
|
||||
<SettingsSidebar
|
||||
workspaceId={page.params.workspaceId ?? ''}
|
||||
currentPath={page.url.pathname}
|
||||
content={sidebarContent}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<WorkspaceSidebarContentOverride content={settingsSidebarContent} />
|
||||
<SidebarOverride controller={parentSidebarController} sidebar={settingsSidebar} />
|
||||
|
||||
<section class="settings-page">
|
||||
{@render children()}
|
||||
|
||||
Reference in New Issue
Block a user