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(
|
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"'),
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
Reference in New Issue
Block a user