Merge branch 'work/companion' into hare/develop

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