Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 44 additions & 24 deletions clients/web/src/lib/components/AltarShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,21 @@
}

const instruments = [
{ slug: "seances", label: "Séances" }, { slug: "atlas", label: "Atlas" },
{ slug: "canvas", label: "Canvas" }, { slug: "orb", label: "Orb" },
{ slug: "loom", label: "Loom" }, { slug: "nexus", label: "Nexus" }
{ slug: "bridge", label: "Bridge", related: "/seances" },
{ slug: "atlas", label: "Atlas", related: "/canvas" },
{ slug: "loom", label: "Loom" },
{ slug: "nexus", label: "Nexus" },
{ slug: "orb", label: "Orb" }
] as const;

function instrumentCurrent(instrument: typeof instruments[number], href: string) {
const pathname = page.url.pathname;
if (pathname === href || pathname === `${href}/`) return "page";
const roots = [resolve(`/${instrument.slug}`)];
if ("related" in instrument) roots.push(resolve(instrument.related));
return roots.some(root => pathname === root || pathname.startsWith(`${root}/`)) ? "location" : undefined;
}

function attention(_event: Event) {
// Instrument events are invalidation hints; only the shell status endpoint
// owns the cross-session count.
Expand Down Expand Up @@ -326,22 +336,19 @@

<nav class="instruments" aria-label="Instruments">
{#each instruments as instrument (instrument.slug)}
<a
href={resolve(`/${instrument.slug}`)}
aria-current={page.url.pathname.startsWith(`/${instrument.slug}`) ? "page" : undefined}
>
{@const href = resolve(instrument.slug === "bridge" ? navigation.bridgePath : `/${instrument.slug}`)}
<a {href} aria-current={instrumentCurrent(instrument, href)}>
<AltarIcon name={instrument.slug} /><span>{instrument.label}</span>
</a>
{/each}
<details class="application-menu" {@attach manageAboutDisclosure}>
<summary><AltarIcon name="applications" /><span>Applications</span><span aria-hidden="true">⌄</span></summary>
<nav aria-label="Applications">
<a href={resolve("/scavenger")} aria-current={page.url.pathname === resolve("/scavenger") ? "page" : undefined}><AltarIcon name="scavenger" />Scavenger market</a>
</nav>
</details>
</nav>

<details class="application-menu" {@attach manageAboutDisclosure}>
<summary><AltarIcon name="applications" /><span>Applications</span><span aria-hidden="true">⌄</span></summary>
<nav aria-label="Applications">
<a href={resolve("/scavenger")} aria-current={page.url.pathname === "/scavenger" ? "page" : undefined}><AltarIcon name="scavenger" />Scavenger market</a>
</nav>
</details>

<div class="spacer"></div>
{#if available}
<a
Expand All @@ -361,7 +368,7 @@
<main id={bridgeFocused ? undefined : "altar-main"} tabindex="-1" hidden={bridgeFocused} inert={bridgeModal}>
{@render children()}
</main>
<BridgeDock bind:open={dockOpen} bind:narrow focused={bridgeFocused} focusHref={resolve(resolvedBridgeSession ? seanceHref(resolvedBridgeSession) : "/seances")} focusLabel={resolvedBridgeSession ? "Open Séance" : "Browse séances"} {workHref} {available} {viewing} onconnect={connect} onexpand={toggleBridge} onsuspend={suspendBridgeVoice}>
<BridgeDock bind:open={dockOpen} bind:narrow focused={bridgeFocused} focusHref={resolve(resolvedBridgeSession ? seanceHref(resolvedBridgeSession) : "/seances")} focusLabel={resolvedBridgeSession ? "Open Séance" : "Browse séances"} browseHref={resolve("/seances")} {workHref} {available} {viewing} onconnect={connect} onexpand={toggleBridge} onsuspend={suspendBridgeVoice}>
{#if available}<BridgeView bind:this={bridgeView} visible={dockOpen || bridgeFocused} isVisible={readBridgeVisibility} embedded={!bridgeFocused} sessionId={bridgeSession} search={bridgeSearch} onNavigate={selectConversation} onSessionResolved={(id) => { resolvedBridgeSession = id; }} />{/if}
</BridgeDock>
</div>
Expand All @@ -380,11 +387,11 @@
.brand { justify-content: center; width: 3rem; min-height: 3rem; padding: .125rem; }
.brand:hover { background: var(--color-tertiary); }
.altar-product { margin-top: .35rem; color: var(--color-parchment); font-size: var(--text-sm); }
nav.instruments { height: auto; align-self: center; gap: .25rem; margin-left: .5rem; }
nav.instruments a { gap: .6rem; padding: .65rem .85rem; letter-spacing: 0; text-transform: none; color: var(--color-ash); min-height: 2.75rem; border: 1px solid transparent; border-radius: var(--radius-rune); }
nav.instruments a :global(svg), .application-menu :global(svg) { color: currentColor; flex: 0 0 auto; }
nav.instruments a:hover { color: var(--color-bone); background: var(--color-obsidian-2); }
nav.instruments a[aria-current="page"] { color: var(--color-accent); text-shadow: none; border-color: color-mix(in srgb,var(--color-accent) 25%,var(--color-engraving)); background: color-mix(in srgb,var(--color-accent) 10%,var(--color-obsidian)); }
nav.instruments { height: auto; align-self: center; flex-wrap: wrap; gap: .25rem; margin-left: .5rem; overflow: visible; }
nav.instruments > a { gap: .6rem; padding: .65rem .85rem; letter-spacing: 0; text-transform: none; color: var(--color-ash); min-height: 2.75rem; border: 1px solid transparent; border-radius: var(--radius-rune); }
nav.instruments > a :global(svg), .application-menu :global(svg) { color: currentColor; flex: 0 0 auto; }
nav.instruments > a:hover { color: var(--color-bone); background: var(--color-obsidian-2); }
nav.instruments > a[aria-current] { color: var(--color-accent); text-shadow: none; border-color: color-mix(in srgb,var(--color-accent) 25%,var(--color-engraving)); background: color-mix(in srgb,var(--color-accent) 10%,var(--color-obsidian)); }
.topbar .spacer { flex: 1 1 0; }
.sigil { border-radius: var(--radius-rune); padding: .55rem .7rem; box-shadow: none; animation: none; border-color: transparent; }
.sigil-identity { display: none; }
Expand All @@ -394,14 +401,27 @@
.application-menu > summary:hover { color:var(--color-bone); background:var(--color-obsidian-2); }
.application-menu > summary::-webkit-details-marker { display: none; }
.application-menu nav { position: absolute; inset-block-start: 100%; inset-inline-start: -.5rem; z-index: 50; min-width: 14rem; padding: .35rem; background: var(--color-tertiary); border: 1px solid var(--color-tertiary-edge); border-radius: var(--radius-glyph); }
.application-menu a { display: flex; align-items: center; gap: .75rem; min-height: 2.75rem; padding: .65rem; font-size: var(--text-sm); text-decoration: none; color: var(--color-bone); }
.application-menu a { display: flex; align-items: center; gap: .75rem; min-height: 2.75rem; padding: .65rem; font-size: var(--text-sm); letter-spacing: 0; text-transform: none; text-decoration: none; color: var(--color-bone); }
.application-menu a:hover, .application-menu a[aria-current="page"] { background: color-mix(in srgb, var(--color-rune) 10%, var(--color-tertiary)); }
.altar-workbench { position: relative; display: flex; flex: 0 0 auto; min-width: 0; min-height: 0; overflow: hidden; background: var(--color-void); }
#altar-main { flex: 1 1 0; min-width: 0; min-height: 0; overflow: auto; background: var(--color-void); container-type: inline-size; container-name: work-area; }
#altar-main::before, #altar-main::after { display: none; }
main[hidden] { display: none; }
:focus-visible { outline: 2px solid var(--color-rune); outline-offset: 3px; }
@media (max-width: 75rem) { .topbar { gap: .65rem; padding-inline: .75rem; } nav.instruments a { padding-inline: .5rem; gap: .4rem; } .offline-status { display: none; } }
@media (max-width: 56rem) { .topbar { display: grid; grid-template-columns: 3rem minmax(0, 1fr) auto; gap: .25rem .5rem; padding: .25rem .5rem .5rem; } .altar-about { grid-column: 1; grid-row: 1; } nav.instruments { grid-column: 1 / -1; grid-row: 2; width: 100%; margin: 0; justify-content: center; } nav.instruments a { min-height: 2.75rem; flex: 1; justify-content: center; } .topbar .spacer { display: none; } .application-menu { grid-column: 2; grid-row: 1; margin: 0; } .application-menu > summary { min-height: 2.75rem; gap: .4rem; font-size: var(--text-xs); } .application-menu nav { inset-inline-start: 0; inset-inline-end: auto; } .sigil { grid-column: 3; grid-row: 1; margin: 0; max-width: 100%; font-size: var(--text-xs); line-height: 1.3; white-space: normal; } }
@media (max-width: 36rem) { nav.instruments { gap: 0; } nav.instruments a { flex-direction: column; gap: .2rem; padding: .4rem .2rem; font-size: var(--text-xs); } }
@media (max-width: 75rem) { .topbar { gap: .65rem; padding-inline: .75rem; } nav.instruments > a { padding-inline: .5rem; gap: .4rem; } .offline-status { display: none; } }
@media (max-width: 56rem) {
.topbar { display: flex; flex-wrap: wrap; gap: .25rem .5rem; padding: .25rem .5rem .5rem; }
.altar-about { order: 0; }
.sigil { margin: 0 0 0 auto; max-width: 9rem; font-size: var(--text-xs); line-height: 1.3; white-space: normal; }
.topbar .spacer { display: none; }
nav.instruments { order: 4; flex: 1 0 100%; margin: 0; justify-content: center; }
nav.instruments > a, .application-menu { flex: 1; }
nav.instruments > a, .application-menu > summary { justify-content: center; }
.application-menu nav { inset-inline-start: auto; inset-inline-end: 0; }
}
@media (max-width: 36rem) {
nav.instruments { gap: 0; }
nav.instruments > a, .application-menu > summary { flex-direction: column; gap: .2rem; padding: .4rem .2rem; font-size: var(--text-xs); }
.application-menu > summary > span[aria-hidden] { display: none; }
}
</style>
23 changes: 13 additions & 10 deletions clients/web/src/lib/components/AltarShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,20 +215,23 @@ describe("Altar consent status", () => {


describe("shell contextual navigation", () => {
it.each(["/canvas", "/canvas/project-a", "/atlas", "/atlas/project-a"])("selects Canvas and Atlas independently at %s", async (path) => {
it.each([
["/bridge", "Bridge", "page"],
["/seances", "Bridge", "location"],
["/seances/session-a", "Bridge", "location"],
["/canvas", "Atlas", "location"],
["/canvas/project-a", "Atlas", "location"],
["/atlas", "Atlas", "page"],
["/atlas/project-a", "Atlas", "location"]
])("selects the owning instrument at %s", (path, label, current) => {
vi.mocked(getAltarStatus).mockResolvedValue(status(0));
page.url.href = `http://localhost${path}`;
const view = render(AltarShell, { children });
const instruments = screen.getByRole("navigation", { name: "Instruments" });
const canvas = within(instruments).getByRole("link", { name: "Canvas" });
expect(canvas.getAttribute("href")).toBe("/canvas");
const atlas = within(instruments).getByRole("link", { name: "Atlas" });
expect(atlas.getAttribute("href")).toBe("/atlas");
expect(canvas.getAttribute("aria-current")).toBe(path.startsWith("/canvas") ? "page" : null);
expect(atlas.getAttribute("aria-current")).toBe(path.startsWith("/atlas") ? "page" : null);
expect(canvas.querySelector("svg rect")).toBeTruthy();
expect(atlas.querySelector("svg path")).toBeTruthy();
expect(screen.getAllByRole("link", { name: "Canvas" })).toHaveLength(1);
expect(Array.from(instruments.children, entry => (entry.querySelector("summary") ?? entry).textContent?.replace(/⌄\s*$/, "").trim()))
.toEqual(["Bridge", "Atlas", "Loom", "Nexus", "Orb", "Applications"]);
expect(within(instruments).getByRole("link", { name: label }).getAttribute("aria-current")).toBe(current);
expect(instruments.querySelectorAll("[aria-current]")).toHaveLength(1);
view.unmount();
});

Expand Down
11 changes: 7 additions & 4 deletions clients/web/src/lib/components/BridgeDock.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
import { onMount, tick, type Snippet } from "svelte";
import { boundedDockWidth, bridgeDockStorageKey, defaultDockLayout, dockMaximumWidth, dockMinimumWidth, parseDockLayout } from "$lib/navigation/dock";

let { open = $bindable(true), narrow = $bindable(false), forceOpen = false, focused = false, focusHref = "/seances", focusLabel = "Open Séance", workHref = "/orb", available = true, viewing = "Altar", onconnect, onexpand, onsuspend, children }: {
let { open = $bindable(true), narrow = $bindable(false), forceOpen = false, focused = false, focusHref = "/seances", focusLabel = "Open Séance", browseHref = "/seances", workHref = "/orb", available = true, viewing = "Altar", onconnect, onexpand, onsuspend, children }: {
open?: boolean; narrow?: boolean; forceOpen?: boolean; available?: boolean;
focused?: boolean; focusHref?: string; focusLabel?: string; workHref?: string;
focused?: boolean; focusHref?: string; focusLabel?: string; browseHref?: string; workHref?: string;
viewing?: string; onconnect?: () => void; onexpand?: () => void; onsuspend?: () => void; children: Snippet;
} = $props();
let width = $state(defaultDockLayout.width);
Expand Down Expand Up @@ -140,7 +140,10 @@
{#if modal}<button class="dock-backdrop" type="button" tabindex="-1" aria-label="Close Bridge overlay" onclick={collapse}></button>{/if}
<section id="bridge-dock" class="bridge-dock" role={narrow || focused ? undefined : "complementary"} aria-labelledby={!focused ? "bridge-dock-title" : undefined}>
{#if focused}
<nav class="focus-heading" aria-label="Bridge workspace"><a href={workHref}>← Back to workspace</a></nav>
<nav class="focus-heading" aria-label="Bridge workspace">
<a href={workHref}>← Back to workspace</a>
<a href={browseHref}>Browse séances</a>
</nav>
{:else}
<header class="dock-heading">
<div class="dock-location"><h2 id="bridge-dock-title">Bridge</h2><span class="dock-view"><span class="visually-hidden">Viewing: </span>{viewing}</span></div>
Expand Down Expand Up @@ -174,7 +177,7 @@
.dock-shell.dock-collapsed { flex-basis: 0; width: 0; }
.dock-shell.dock-focused { flex: 1 1 0; width: 100%; order: 0; overflow: hidden; }
.dock-focused .dock-frame { border: 0; }
.focus-heading { padding: .35rem 1rem; border-bottom: 1px solid var(--color-engraving); }
.focus-heading { display: flex; flex-wrap: wrap; gap: .25rem 1rem; padding: .35rem 1rem; border-bottom: 1px solid var(--color-engraving); }
.focus-heading a, .dock-actions a { display: inline-flex; align-items: center; min-height: 2rem; font-size: var(--text-xs); color: var(--color-parchment); }
a:focus-visible { outline: 2px solid var(--color-rune); outline-offset: 2px; }
.dock-frame { position: relative; display: flex; width: 100%; min-width: 0; min-height: 0; background: var(--color-obsidian); border-left: 1px solid var(--color-engraving); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ describe("shell-lifetime Bridge conversation", () => {
expect(screen.getByRole("link", { name: "Return to Orb event →" }).getAttribute("href"))
.toBe("/orb/missing-run?event=event-b");
await visit("/orb/run-b?event=event-b");
expect(screen.getByRole("link", { name: "Bridge" }).getAttribute("href")).toBe("/bridge/session-b");
await fireEvent.click(screen.getByRole("button", { name: "Collapse Bridge" }));
await fireEvent.click(screen.getByRole("button", { name: "Expand Bridge" }));
expect(screen.queryByText(/requested consent is unavailable/)).toBeNull();
Expand Down Expand Up @@ -333,13 +334,15 @@ it("opens the actual default séance and preserves active A while inspecting B",
const composer = await screen.findByLabelText("Message");
await fireEvent.input(composer, { target: { value: "Keep A draft" } });
expect(screen.getByRole("link", { name: "Open Séance" }).getAttribute("href")).toBe("/seances/session-a");
expect(screen.getByRole("link", { name: "Séances" }).getAttribute("href")).toBe("/seances");
expect(screen.getByRole("link", { name: "Bridge" }).getAttribute("href")).toBe("/bridge");
await visit("/seances/session-b");
expect(screen.getByLabelText("Message")).toBe(composer);
expect((composer as HTMLTextAreaElement).value).toBe("Keep A draft");
expect(screen.getByRole("link", { name: "Open Séance" }).getAttribute("href")).toBe("/seances/session-a");
expect(getBridgeSnapshot).toHaveBeenCalledExactlyOnceWith(undefined);
expect(sendBridgeMessage).not.toHaveBeenCalled();
await visit("/bridge");
expect(screen.getByRole("link", { name: "Browse séances" }).getAttribute("href")).toBe("/seances");
shell.unmount();
});

Expand Down
Loading
Loading