fix: pin holds the first slot, and the nav table says what it shares

Pinning brings the sidebar's handle back, and from behind it the unpin button sat
one slot over from where the pin had just been clicked — so a second click in the
same place opened the sidebar instead. An operator is detached by default, which
makes that the default state of the feature rather than an edge case.

`NAV_PAGES` claimed one table stops a page being called one thing in the nav and
another in the header, which holds for the seven entries the sidebar's main group
is built from and not for the four grouped separately — `/routes` was "Triggers"
in the header and "HTTP" in the nav. The comment now says which is which, and
that entry is named for the page rather than its group.

`sidebarPageAllowed` gets a table test: it decides what an operator sees, and its
`=== true` is what a future `Option`-style default would silently change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-01 00:27:21 -07:00
co-authored by Claude Opus 5
parent f2768de00c
commit 379858a97d
3 changed files with 65 additions and 30 deletions
@@ -92,6 +92,36 @@ The row's height matches the sidebar's own header row, so the two read as one ba
'shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_0_-1px_0_0_#374151] [html.github-dark_&]:shadow-[inset_0_-1px_0_0_rgb(var(--color-border-light))]'
)}
>
<!-- Pin before the sidebar's handle, so it holds the first slot whether or not the handle is
there. Pinning brings the handle back — the band is no longer hidden — and from behind the
handle the unpin button would sit one slot over from where the pin was just clicked, which
for an operator (detached by default) means the second click opens the sidebar instead. -->
{#if onPin}
<!-- Same box and offsets as the corner handle it comes to rest over, so the pointer that
called the band down is already on this button. -->
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Pin the header on deployed apps"
title="Pin the header on deployed apps"
onclick={() => onPin?.()}
>
<PanelTop size={16} class="flex-shrink-0 text-hint" />
</button>
{/if}
{#if onUnpin}
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Unpin the header from deployed apps"
title="Unpin the header from deployed apps"
onclick={() => onUnpin?.()}
>
<!-- Dashed while the band is shown, solid while it is away: the pair the sidebar's own
toggle uses, so the two controls read as one idea. -->
<PanelTopDashed size={16} class="flex-shrink-0 text-hint" />
</button>
{/if}
{#if (navDetached.val || panelled) && !navHidden && !hideNavHandle}
<!-- Reveals the hidden sidebar, and only that: hovering slides the card in, clicking holds
it there. Attaching it for good belongs to the toggle in the sidebar's own footer, where
@@ -115,33 +145,6 @@ The row's height matches the sidebar's own header row, so the two read as one ba
</div>
{/if}
{#if onPin}
<!-- Same box and offsets as the corner handle it comes to rest over, so the pointer that
called the band down is already on this button. -->
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Pin the header on deployed apps"
title="Pin the header on deployed apps"
onclick={() => onPin?.()}
>
<PanelTop size={16} class="flex-shrink-0 text-hint" />
</button>
{/if}
{#if onUnpin}
<!-- After the sidebar's handle: the sidebar is the outer thing, the band sits inside it. -->
<button
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
aria-label="Unpin the header from deployed apps"
title="Unpin the header from deployed apps"
onclick={() => onUnpin?.()}
>
<!-- Dashed while the band is shown, solid while it is away: the pair the sidebar's own
toggle uses, so the two controls read as one idea. -->
<PanelTopDashed size={16} class="flex-shrink-0 text-hint" />
</button>
{/if}
<!-- The breadcrumb yields width grudgingly (shrink-[0.1]): when a page fills the bar with
controls, they are what should narrow, not the name of where the user is. It still gives
way rather than pushing them off the bar once there is nothing left to take.
@@ -14,8 +14,13 @@ import {
import type { OperatorPageKey } from './operatorRoutes'
/**
* The workspace pages the sidebar links to, and the names the page header's breadcrumb gives them.
* One table so a page cannot be called one thing in the nav and another in the header.
* The names the page header's breadcrumb gives the workspace's own pages, and the rows the
* sidebar's main group is built from.
*
* Every entry names a page for the breadcrumb. Only the entries `SidebarContent`'s
* `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules, HTTP routes, Workers
* and Audit logs are grouped differently there and carry their own row, so for those four the
* `icon` and `operatorKey` below go unread. Keep the labels in step with those rows by hand.
*/
export type NavPage = {
label: string
@@ -35,7 +40,8 @@ export const NAV_PAGES: NavPage[] = [
{ label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' },
{ label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' },
{ label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' },
{ label: 'Triggers', path: '/routes', icon: Unplug, operatorKey: 'triggers' },
// "Triggers" is the group the sidebar files this under, not the page: the row there reads HTTP.
{ label: 'HTTP routes', path: '/routes', icon: Unplug, operatorKey: 'triggers' },
{ label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' },
{ label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' }
]
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { sidebarPageAllowed } from './operatorRoutes'
import type { UserWorkspace } from '$lib/stores'
const ws = (operator_settings: any): UserWorkspace =>
({ id: 'w', name: 'w', username: 'u', operator_settings }) as UserWorkspace
describe('sidebarPageAllowed', () => {
it('shows every page to anyone who is not an operator', () => {
expect(sidebarPageAllowed(false, ws(null), 'runs')).toBe(true)
expect(sidebarPageAllowed(undefined, undefined, 'runs')).toBe(true)
})
it('admits an operator only where the workspace turned the page on', () => {
expect(sidebarPageAllowed(true, ws({ runs: true }), 'runs')).toBe(true)
expect(sidebarPageAllowed(true, ws({ runs: false }), 'runs')).toBe(false)
})
it('withholds a page the settings do not mention', () => {
// `=== true`, not truthiness: a right absent from the jsonb, or settings that were never
// written, must not read as granted.
expect(sidebarPageAllowed(true, ws({ variables: true }), 'runs')).toBe(false)
expect(sidebarPageAllowed(true, ws(null), 'runs')).toBe(false)
expect(sidebarPageAllowed(true, undefined, 'runs')).toBe(false)
})
})