Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2bcbbf0
feat(shell): URL-held right sheet, foldable sidebar groups, on-call pill
Makisuo Nov 9, 2026
5535bc2
fix(shell): sheet history, on-call freshness, foldable active group
Makisuo Nov 9, 2026
0c0b786
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 9, 2026
5888395
fix(shell): read window and document on subscribe, not on import
Makisuo Nov 9, 2026
ef30170
test(shell): lab on-call fixture no longer expires on 16 Nov 2026
Makisuo Nov 9, 2026
f9d010f
fix(views): side panel dims the page lightly, and comes in a compact …
Makisuo Nov 10, 2026
f1179ea
fix(shell): one-row on-call pill, calmer sidebar states
Makisuo Nov 10, 2026
21974a5
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
d17a22e
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
43c444a
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
2b33c5e
fix(shell): re-check a stale 'no schedules' answer so a teammate's fi…
Makisuo Nov 10, 2026
82145ab
fix(shell): only the current page's link carries aria-current in the …
Makisuo Nov 10, 2026
d599b1d
fix(ui): make the sidebar row hover visible in the light theme
Makisuo Nov 10, 2026
5f2c136
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
c5adf7d
fix(shell): an org without schedules is asked again hourly, so an ope…
Makisuo Nov 10, 2026
655534e
docs(design-rules): list the form sheet size
Makisuo Nov 10, 2026
479e46c
fix(shell): a folded group keeps the current sub-item; on-call is re-…
Makisuo Nov 10, 2026
7360717
docs(shell): navRow's comment follows the folded group's current sub-…
Makisuo Nov 10, 2026
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
52 changes: 4 additions & 48 deletions apps/web/src/lab/app-shell.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Effect, Option, Schema, Stream, String as Str } from "effect"
import { Subscription, Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { Subscription, type Update } from "foldkit"
import { readSessionToken } from "../platform/auth-api"
import type { Resources } from "../platform/resources"
import * as Shell from "../shell"

export { foldLabShell, labShellView } from "./shell-frame"

// Shell lab pages render inside the real shell: the same Shell Submodel the
// app root keeps, booted for the session's org, with the frame around the page and its overlays.

Expand All @@ -14,6 +15,7 @@ export const LabShellFlags = Schema.Struct({
theme: Shell.Theme,
pinnedNamespaces: Schema.Record(Schema.String, Schema.String),
dashboardFavorites: Schema.Array(Schema.String),
collapsedNavGroups: Schema.optionalKey(Schema.Array(Schema.String)),
}),
maybeOrgId: Schema.Option(Schema.String),
})
Expand Down Expand Up @@ -55,52 +57,6 @@ export const initLabShell = (
})
}

/** Folds Shell Messages; a lab page has no queries to rebuild on a namespace pin. */
export const foldLabShell = <Model extends { readonly shell: Shell.Model }, Message>(
toParentMessage: (message: Shell.Message) => Message,
) =>
Update.foldChild({
update: Shell.update,
read: (model: Model) => Option.some(model.shell),
write: (model: Model, shell: Shell.Model): Model => ({ ...model, shell }),
toParentMessage,
foldOutMessage: () => (model: Model) => ({ model }),
})

/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */
export const labShellView = <Message>(
h: HtmlBuilder<Message>,
shell: Shell.Model,
toParentMessage: (message: Shell.Message) => Message,
frame: Readonly<{
currentPath: string
breadcrumbs: ReadonlyArray<Shell.BreadcrumbEntry>
page: () => Html
}>,
): ReadonlyArray<Html> => [
h.submodel({
slotId: "shell-frame",
model: shell,
view: Shell.frameView,
toParentMessage,
viewInputs: { ...frame, topbarActions: () => null },
}),
h.submodel({
slotId: "shell-sheet",
model: shell,
view: Shell.sheetLayerView,
toParentMessage,
viewInputs: { currentPath: frame.currentPath },
}),
h.submodel({
slotId: "shell-menu",
model: shell,
view: Shell.menuLayerView,
toParentMessage,
viewInputs: { currentPath: frame.currentPath },
}),
]

/** The root's shell Subscriptions: `useIsMobile`, the sidebar shortcut and the Pinned group's shape. */
export const labShellSubscriptions = <Model extends { readonly shell: Shell.Model }, Message>(
toParentMessage: (message: Shell.Message) => Message,
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/lab/entries/shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Effect, Schema, Stream } from "effect"
import { Dom, Subscription } from "foldkit"
import * as TooltipHost from "@maple/views/ui/tooltip-host"
import type { Resources } from "../../platform/resources"
import * as Shell from "../../shell"
import { labShellSubscriptions } from "../app-shell"
import { defineLabWithFlags } from "../define"
import { Flags, init, Message, Model, toShell, toTooltip, update, view } from "../shell-states-lab"

// `/lab/shell`: sidebar group folding, the on-call pill's states and the URL-held side panel.

const urlTarget: Dom.TypedEventTarget<{ popstate: PopStateEvent; "foldkit:urlchange": Event }> = window

const currentHref = () => Message.ChangedUrl({ href: `${window.location.pathname}${window.location.search}` })

/** Back, Forward and the lab's own pushes and replaces. */
const urlChanges: Stream.Stream<Message> = Stream.merge(
Dom.streamFromEvent({ target: urlTarget, type: "popstate", mapEvent: currentHref }),
Dom.streamFromEvent({ target: urlTarget, type: "foldkit:urlchange", mapEvent: currentHref }),
)

export const lab = defineLabWithFlags("Shell states", {
Model,
Flags,
flags: Effect.all({
shell: Shell.readShellFlags,
href: Effect.sync(() => `${window.location.pathname}${window.location.search}`),
}),
init,
update,
view,
subscriptions: Subscription.aggregate<Model, Message, Resources>()(
labShellSubscriptions<Model, Message>(toShell),
Subscription.make<Model, Message, Resources>()((entry) => ({
url: entry({}, { modelToDependencies: () => ({}), dependenciesToStream: () => urlChanges }),
// The app's tooltip, which the open sidebar's on-call row uses.
tooltipTriggers: entry(
{},
{
modelToDependencies: () => ({}),
dependenciesToStream: () => Stream.map(TooltipHost.triggerEvents, toTooltip),
},
),
tooltipWhileEngaged: entry(
{ isEngaged: Schema.Boolean },
{
modelToDependencies: (model) => ({ isEngaged: TooltipHost.isEngaged(model.tooltip) }),
dependenciesToStream: ({ isEngaged }) =>
Stream.when(
Stream.map(TooltipHost.whileEngaged, toTooltip),
Effect.sync(() => isEngaged),
),
},
),
})),
),
})
8 changes: 8 additions & 0 deletions apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,14 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
kind: "lab",
session: "none",
},
{
path: "/lab/shell",
title: "Shell states",
description:
"The real sidebar with foldable groups and the on-call pill's three states, and a right sheet held in ?sheet= with a demo form.",
kind: "lab",
session: "none",
},
{
path: "/lab/query-builder",
title: "Query builder",
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/lab/shell-frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Option } from "effect"
import { Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import type { Message as ShellMessage } from "../shell/message"
import type { Model as ShellModel } from "../shell/model"
import { update as shellUpdate } from "../shell/update"
import { type BreadcrumbEntry, frameView, menuLayerView, sheetLayerView } from "../shell/view"

// The shell frame a lab page renders in, kept off the shell barrel (its streams read `window` on
// import) so a lab's own logic can run in a scene test.

/** Folds Shell Messages; a lab page has no queries to rebuild on a namespace pin. */
export const foldLabShell = <Model extends { readonly shell: ShellModel }, Message>(
toParentMessage: (message: ShellMessage) => Message,
) =>
Update.foldChild({
update: shellUpdate,
read: (model: Model) => Option.some(model.shell),
write: (model: Model, shell: ShellModel): Model => ({ ...model, shell }),
toParentMessage,
foldOutMessage: () => (model: Model) => ({ model }),
})

/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */
export const labShellView = <Message>(
h: HtmlBuilder<Message>,
shell: ShellModel,
toParentMessage: (message: ShellMessage) => Message,
frame: Readonly<{
currentPath: string
breadcrumbs: ReadonlyArray<BreadcrumbEntry>
page: () => Html
}>,
): ReadonlyArray<Html> => [
h.submodel({
slotId: "shell-frame",
model: shell,
view: frameView,
toParentMessage,
viewInputs: { ...frame, topbarActions: () => null },
}),
h.submodel({
slotId: "shell-sheet",
model: shell,
view: sheetLayerView,
toParentMessage,
viewInputs: { currentPath: frame.currentPath },
}),
h.submodel({
slotId: "shell-menu",
model: shell,
view: menuLayerView,
toParentMessage,
viewInputs: { currentPath: frame.currentPath },
}),
]
141 changes: 141 additions & 0 deletions apps/web/src/lab/shell-states-lab.scene.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import * as Animation from "@foldkit/ui/animation"
import {
Command,
click,
expect,
given,
keydown,
Mount,
role,
scene,
selector,
Subscription,
} from "foldkit/scene"
import { describe, expect as expectVitest, test } from "vitest"
import * as Dialog from "@maple/views/ui/dialog"
import { Array as Arr } from "effect"
import { MeasureSidebarOverflow } from "../shell/mounts"
import { Message as ShellMessage } from "../shell/message"
import {
init,
Message,
type Model,
COMPACT_SHEET_ID,
DismissSheetUrl,
PushSheetUrl,
SHEET_ID,
update,
view,
} from "./shell-states-lab"

// The right sheet held in `?sheet=`: the URL opens it, Escape and the backdrop clear it, Back closes it.

const shellFlags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const

const at = (href: string): Model => init({ shell: shellFlags, href }).model

const dialog = role("dialog", { name: "New alert rule" })
const portal = selector("[data-base-ui-portal]")

const measured = Mount.resolve(MeasureSidebarOverflow, ShellMessage.MeasuredSidebarOverflow())

/** The portal Mount and the enter transition (or none, for a sheet that boots open). */
const mounted = Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog())

const enter = (generation: number) => [
mounted,
Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })),
Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation })),
]

const leave = (generation: number) => [
Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })),
Command.resolve(
Dialog.WaitForDialogTransition({ id: SHEET_ID, generation }),
Dialog.Message.GotAnimationMessage({ message: Animation.Message.EndedAnimation({ generation }) }),
),
Mount.expectEnded(Dialog.MountDialog),
expect(dialog).toBeAbsent(),
]

describe("URL-held right sheet", () => {
test("a link with ?sheet= opens it, and Escape dismisses it through the URL", () => {
scene(
{ update, view },
given(at(`/lab/shell?tab=x&sheet=${SHEET_ID}`)),
measured,
mounted,
expect(dialog).toExist(),
expect(dialog).toContainText("Error rate above (%)"),
expect(role("button", { name: "Create rule" })).toExist(),
keydown(portal, "Escape"),
Command.expectHas(DismissSheetUrl({ href: `/lab/shell?tab=x&sheet=${SHEET_ID}`, id: SHEET_ID })),
Command.resolve(DismissSheetUrl, Message.CompletedNavigate()),
// Booting open counted the show as generation 1.
...leave(2),
)
})

test("the page's button pushes ?sheet=, the URL change opens it, and Back closes it", () => {
scene(
{ update, view },
given(at("/lab/shell")),
measured,
expect(dialog).toBeAbsent(),
click(role("button", { name: "New alert rule" })),
Command.expectExact(PushSheetUrl({ href: "/lab/shell", id: SHEET_ID })),
Command.resolve(PushSheetUrl, Message.CompletedNavigate()),
expect(dialog).toBeAbsent(),
Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${SHEET_ID}` })),
...enter(1),
expect(dialog).toHaveAttr("data-open", ""),
Subscription.emit(Message.ChangedUrl({ href: "/lab/shell" })),
...leave(2),
Command.expectNone(),
)
})

test("the compact panel has its own ?sheet= id and content", () => {
const snooze = role("dialog", { name: "Snooze rule" })
scene(
{ update, view },
given(at("/lab/shell")),
measured,
click(role("button", { name: "Snooze rule" })),
Command.expectExact(PushSheetUrl({ href: "/lab/shell", id: COMPACT_SHEET_ID })),
Command.resolve(PushSheetUrl, Message.CompletedNavigate()),
Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${COMPACT_SHEET_ID}` })),
...enter(1),
expect(snooze).toContainText("Hours"),
keydown(portal, "Escape"),
Command.expectHas(
DismissSheetUrl({ href: `/lab/shell?sheet=${COMPACT_SHEET_ID}`, id: COMPACT_SHEET_ID }),
),
Command.resolve(DismissSheetUrl, Message.CompletedNavigate()),
...leave(2),
)
})

test("Back (the URL losing ?sheet=) closes it without touching the URL again", () => {
const open = at(`/lab/shell?sheet=${SHEET_ID}`)
const back = update(open, Message.ChangedUrl({ href: "/lab/shell" }))
expectVitest(back.model.sheet.isOpen).toBe(false)
expectVitest(Arr.map(back.commands ?? [], (command) => command.name)).not.toContain(
DismissSheetUrl.name,
)
})

test("Cancel in the footer dismisses it like Escape", () => {
scene(
{ update, view },
given(at(`/lab/shell?sheet=${SHEET_ID}`)),
measured,
mounted,
expect(dialog).toExist(),
click(role("button", { name: "Cancel" })),
Command.expectHas(DismissSheetUrl({ href: `/lab/shell?sheet=${SHEET_ID}`, id: SHEET_ID })),
Command.resolve(DismissSheetUrl, Message.CompletedNavigate()),
...leave(2),
)
})
})
Loading
Loading