diff --git a/DESIGN.md b/DESIGN.md index bda54f4da2b..af538661b68 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -867,6 +867,8 @@ tokenize motion. Principles — reasoning only; values live in `ui/src/index.css Agent Chat and regular task chats keep pending questions and requests as compact, reopenable entries at their original position in history. Dismissing any composer request—including completion reviews, confirmations, choices, suggested tasks, item verdicts, and runtime input—removes its form and pending reminder. Saved interactions record human dismissal on the server; raw runtime requests retain a browser-local preference. Dismissal is not an answer, approval, or rejection. Sending a newer user message also clears an ordinary question from the composer without resolving it. Questions never contribute to composer pending counts or navigation. Opening the history entry restores the original form and its draft; submitting later uses the same durable question response path. Actual permission reviews retain their permission checks. +Desktop properties panels keep a zero-width, noninteractive host while their content is unknown. Late content expands the panel and moves the thread in the same width transition. Removing content on navigation clears the pane immediately, including maximized geometry. The motion uses `--motion-pane-reveal` and `--motion-ease-pane-reveal`; reduced motion disables it. A saved hidden preference stays hidden, and pointer resizing stays immediate. + ## Canonical proposal toolbars `Design proposals / Components / Toolbars` is the canonical toolbar review and diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index dbbef012e9f..ba1df3c8b86 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -1,7 +1,7 @@ import express from "express"; import request from "supertest"; import { getTableName } from "drizzle-orm"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { HttpError } from "../errors.js"; vi.mock("../services/fast-responses.js", () => ({ @@ -376,6 +376,15 @@ function createScheduledRetry() { } describe("issue comment reopen routes", () => { + beforeAll(async () => { + // Load the route graph within the setup budget. A first-test import can + // time out and continue into the next test after its mocks are reset. + await Promise.all([ + import("../routes/issues.js"), + import("../middleware/index.js"), + ]); + }); + beforeEach(() => { vi.clearAllMocks(); mockIssueService.getById.mockReset(); diff --git a/tests/storybook-visual/task-sidebar-loading.spec.ts b/tests/storybook-visual/task-sidebar-loading.spec.ts new file mode 100644 index 00000000000..77bced22d48 --- /dev/null +++ b/tests/storybook-visual/task-sidebar-loading.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from "@playwright/test"; + +test.use({ viewport: { width: 1440, height: 960 }, reducedMotion: "no-preference" }); + +async function sampleLayout(page: import("@playwright/test").Page, duration: number) { + return page.evaluate(async (duration) => { + const samples: Array<{ time: number; pane: number; main: number; skeleton: boolean; overflow: boolean }> = []; + const start = performance.now(); + while (performance.now() - start < duration) { + samples.push({ + time: performance.now() - start, + pane: document.querySelector("aside")!.getBoundingClientRect().width, + main: document.querySelector("main")!.getBoundingClientRect().width, + skeleton: Boolean(document.querySelector('[data-testid="issue-chat-skeleton"]')), + overflow: document.documentElement.scrollWidth > innerWidth, + }); + await new Promise(requestAnimationFrame); + } + return samples; + }, duration); +} + +for (const scenario of ["sidebar-during-skeleton", "sidebar-after-content"]) { + test(`${scenario} moves the thread with the arriving sidebar`, async ({ page }) => { + await page.goto(`/iframe.html?id=tasks-sidebar-loading--${scenario}&viewMode=story`); + const pane = page.locator("aside"); + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(434); + await page.getByRole("button", { name: "Replay cold load" }).click(); + await expect(pane).toHaveAttribute("inert", ""); + const samples = await sampleLayout(page, 3000); + // Route cleanup removes the old pane immediately, before the delayed arrival. + expect(samples.filter((sample) => sample.time < 700).every((sample) => sample.pane === 0)).toBe(true); + const reveal = samples.filter((sample) => sample.time > 700 && sample.pane > 2 && sample.pane < 432); + expect(reveal.length).toBeGreaterThan(0); + expect(reveal.some((sample) => sample.skeleton)).toBe(scenario === "sidebar-during-skeleton"); + for (const sample of samples) { + expect(Math.abs(sample.main + sample.pane - 1440)).toBeLessThan(2); + expect(sample.overflow).toBe(false); + } + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(434); + await expect(pane).not.toHaveAttribute("inert"); + }); +} + +for (const scenario of ["without-sidebar", "hidden-sidebar"]) { + test(`${scenario} leaves the full thread width available`, async ({ page }) => { + await page.goto(`/iframe.html?id=tasks-sidebar-loading--${scenario}&viewMode=story`); + await expect(page.getByRole("button", { name: "Replay cold load" })).toBeVisible(); + const samples = await sampleLayout(page, 2700); + expect(samples.every((sample) => sample.pane === 0 && sample.main === 1440)).toBe(true); + await expect(page.locator("aside")).toHaveAttribute("inert", ""); + if (scenario === "hidden-sidebar") { + await page.getByRole("button", { name: "Show sidebar" }).click(); + await expect.poll(() => page.locator("aside").evaluate((el) => el.getBoundingClientRect().width)).toBe(434); + } + }); +} + +test("reduced motion reveals the sidebar without a transition", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/iframe.html?id=tasks-sidebar-loading--sidebar-during-skeleton&viewMode=story"); + await expect(page.getByRole("button", { name: "Replay cold load" })).toBeVisible(); + const samples = await sampleLayout(page, 2700); + expect(samples.every((sample) => sample.pane === 0 || sample.pane === 434)).toBe(true); + await expect.poll(() => page.locator("aside").evaluate((el) => getComputedStyle(el).transitionDuration)).toBe("0s"); +}); + +test("resizing stays immediate and maximize restores the docked width", async ({ page }) => { + await page.goto("/iframe.html?id=tasks-sidebar-loading--sidebar-during-skeleton&viewMode=story"); + const pane = page.locator("aside"); + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(434); + const grip = page.getByRole("separator", { name: "Resize panel" }); + const bounds = (await grip.boundingBox())!; + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width / 2 - 90, bounds.y + bounds.height / 2); + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(524); + expect(await pane.evaluate((el) => getComputedStyle(el).transitionDuration)).toBe("0s"); + await page.mouse.up(); + await page.getByRole("button", { name: "Maximize side panel" }).click(); + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(1440); + await page.getByRole("button", { name: "Restore side panel" }).click(); + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(524); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1440); +}); + +test("a viewport narrowed during loading clamps the sidebar before reveal", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("taskChatRedesign.taskDetailPropertiesPaneWidth", "700")); + await page.goto("/iframe.html?id=tasks-sidebar-loading--sidebar-during-skeleton&viewMode=story&args=sidebarDelay:3500;contentDelay:4500"); + await page.getByRole("button", { name: "Replay cold load" }).click(); + const pane = page.locator("aside"); + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(0); + await page.setViewportSize({ width: 1000, height: 960 }); + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width), { timeout: 8000 }).toBe(344); + expect(await page.evaluate(() => localStorage.getItem("taskChatRedesign.taskDetailPropertiesPaneWidth"))).toBe("700"); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000); +}); + +test("removing maximized content releases the empty pane immediately", async ({ page }) => { + await page.goto("/iframe.html?id=tasks-sidebar-loading--sidebar-during-skeleton&viewMode=story"); + const pane = page.locator("aside"); + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(434); + await page.getByRole("button", { name: "Maximize side panel" }).click(); + await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(1440); + await page.getByRole("button", { name: "Replay cold load" }).click(); + await expect(pane).toHaveAttribute("inert", ""); + const samples = await sampleLayout(page, 500); + expect(samples.filter((sample) => sample.pane !== 0 || sample.main !== 1440 || sample.overflow)).toEqual([]); + await expect(pane).toHaveAttribute("inert", ""); + expect(await pane.evaluate((el) => getComputedStyle(el).transitionDuration)).toBe("0s"); +}); diff --git a/ui/src/components/PropertiesPanel.test.tsx b/ui/src/components/PropertiesPanel.test.tsx index c5d1ebc0794..f4562d26d49 100644 --- a/ui/src/components/PropertiesPanel.test.tsx +++ b/ui/src/components/PropertiesPanel.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { flushSync } from "react-dom"; +import type { ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -54,15 +55,16 @@ describe("PropertiesPanel", () => { let container: HTMLDivElement; let root: Root | null = null; let originalInnerWidth: number; + let queryClient: QueryClient; async function renderPanel({ panelVisible = true, taskDetailLayout = false, - }: { panelVisible?: boolean; taskDetailLayout?: boolean } = {}) { - mockPanelState.panelContent =
content
; + content =
content
, + }: { panelVisible?: boolean; taskDetailLayout?: boolean; content?: ReactNode } = {}) { + mockPanelState.panelContent = content; mockPanelState.panelVisible = panelVisible; - root = createRoot(container); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + root ??= createRoot(container); flushSync(() => { root!.render( @@ -85,6 +87,47 @@ describe("PropertiesPanel", () => { mockClearPanelMaximizeRequest.mockClear(); mockPanelState.panelMaximizeRequested = false; mockSandbox.enabled = false; + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + describe.each([false, true])("late content with classic interface %s", (classic) => { + beforeEach(() => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableStreamlinedUi: true, + enableClassicTaskInterface: classic, + }); + }); + + it("keeps the same zero-width host when content arrives and leaves", async () => { + await renderPanel({ content: null }); + const aside = container.querySelector("aside")!; + expect(aside).not.toBeNull(); + expect(aside.style.width).toBe("0px"); + expect(aside.hasAttribute("inert")).toBe(true); + expect(aside.getAttribute("aria-hidden")).toBe("true"); + expect(aside.classList.contains("properties-panel-docked")).toBe(false); + + await renderPanel(); + expect(container.querySelector("aside")).toBe(aside); + expect(aside.style.width).toBe(classic ? "320px" : "322px"); + expect(aside.hasAttribute("inert")).toBe(false); + expect(aside.classList.contains("properties-panel-docked")).toBe(true); + + await renderPanel({ content: null }); + expect(container.querySelector("aside")).toBe(aside); + expect(aside.style.width).toBe("0px"); + expect(aside.classList.contains("properties-panel-docked")).toBe(false); + expect(mockSetPanelVisible).not.toHaveBeenCalled(); + }); + + it("honors a hidden-panel preference when late content arrives", async () => { + await renderPanel({ panelVisible: false, content: null }); + await renderPanel({ panelVisible: false }); + const aside = container.querySelector("aside")!; + expect(aside.style.width).toBe("0px"); + expect(aside.hasAttribute("inert")).toBe(true); + expect(mockSetPanelVisible).not.toHaveBeenCalled(); + }); }); afterEach(() => { @@ -133,6 +176,22 @@ describe("PropertiesPanel", () => { }); }); + it.each([false, true])("clamps late content to a narrowed viewport (task detail: %s)", async (taskDetailLayout) => { + const storageKey = taskDetailLayout + ? "taskChatRedesign.taskDetailPropertiesPaneWidth" + : "taskChatRedesign.propertiesPaneWidth"; + window.localStorage.setItem(storageKey, "700"); + await renderPanel({ taskDetailLayout, content: null }); + const aside = container.querySelector("aside")!; + + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 }); + await renderPanel({ taskDetailLayout }); + + expect(container.querySelector("aside")).toBe(aside); + expect(aside.style.width).toBe("344px"); + expect(window.localStorage.getItem(storageKey)).toBe("700"); + }); + it("renders the Paper task-detail rail width with a drag grip and a maximize button", async () => { await renderPanel({ taskDetailLayout: true }); const aside = container.querySelector("aside"); @@ -140,7 +199,7 @@ describe("PropertiesPanel", () => { expect(aside!.style.width).toBe("434px"); expect(aside!.querySelector('[role="separator"][aria-label="Resize panel"]')).not.toBeNull(); expect(container.querySelector('[aria-label="Maximize side panel"]')).not.toBeNull(); - const inner = aside!.querySelector(":scope > div:not([role])"); + const inner = aside!.querySelector(":scope > div:not([role]) > div"); expect(inner!.style.width).toBe("434px"); expect(inner!.style.minWidth).toBe("434px"); expect(aside!.querySelector("header")?.className).toContain( @@ -177,11 +236,11 @@ describe("PropertiesPanel", () => { '[aria-label="Maximize side panel"]', )!; - expect(aside.className).toContain("border-l"); + expect(aside.querySelector(":scope > div:not([role]) > div")!.className).toContain("border-l"); maximize.click(); await flushReact(); - expect(aside.className).not.toContain("border-l"); + expect(aside.querySelector(":scope > div:not([role]) > div")!.className).not.toContain("border-l"); expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true"); }); diff --git a/ui/src/components/PropertiesPanel.tsx b/ui/src/components/PropertiesPanel.tsx index babd7bed3c0..e7dcf746ccd 100644 --- a/ui/src/components/PropertiesPanel.tsx +++ b/ui/src/components/PropertiesPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { X } from "lucide-react"; import { usePanel } from "../context/PanelContext"; import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled"; @@ -22,14 +22,17 @@ export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const designSandbox = useDesignSandbox(); const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout; - - if (!panelContent) return null; + // Keep the zero-width host mounted while route data loads. Inserting an + // already-open pane skips its width transition and snaps the thread sideways. + const visible = panelVisible && panelContent != null; if (classicTaskInterfaceEnabled) { return ( diff --git a/ui/src/components/task-chat/motion-tokens.ts b/ui/src/components/task-chat/motion-tokens.ts index 08086aca402..068cf982b98 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -31,6 +31,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" }, { name: "--motion-ease-in", group: "Easing", kind: "easing" }, { name: "--motion-ease-pane-width-reset", group: "Easing", kind: "easing" }, + { name: "--motion-ease-pane-reveal", group: "Easing", kind: "easing" }, // Duration primitives { name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 }, @@ -76,6 +77,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { name: "--motion-mobile-nav-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-scrollbar-idle-delay", group: "States", kind: "time", min: 0, max: 2000, step: 10 }, { name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-pane-reveal", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-pane-width-reset", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-side-panel-tab", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, ]; diff --git a/ui/src/index.css b/ui/src/index.css index 27e9dc1edc7..195d5cee7b2 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -461,6 +461,8 @@ --motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1); --motion-ease-scroll-pill-out: cubic-bezier(0.7, 0, 0.84, 0); --motion-pane-glide: 320ms; /* properties-pane maximize/restore glide */ + --motion-pane-reveal: var(--motion-duration-slow); + --motion-ease-pane-reveal: var(--motion-ease-out-expo); --motion-pane-width-reset: 200ms; --motion-ease-pane-width-reset: ease-in-out; --motion-side-panel-tab: var(--motion-duration-fast); @@ -1384,6 +1386,10 @@ html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) #root /* Properties-pane maximize/restore glide (left animates between docked and sidebar-flush while the pane is position:fixed). */ .tc-pane-glide { transition: left var(--motion-pane-glide) var(--motion-ease-standard); } +.properties-panel-docked { + transition: width var(--motion-pane-reveal) var(--motion-ease-pane-reveal), + opacity var(--motion-pane-reveal) var(--motion-ease-pane-reveal); +} .sidebar-shell[data-width-resetting="true"], .sidebar-shell[data-width-resetting="true"] > .sidebar-shell-panel { transition: width var(--motion-pane-width-reset) var(--motion-ease-pane-width-reset); } .design-page-aside[data-width-resetting="true"] { transition: flex-basis var(--motion-pane-width-reset) var(--motion-ease-pane-width-reset); } @@ -1457,6 +1463,7 @@ html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) #root .tc-turn-metrics, .tc-line-scroll-inner, .tc-pane-glide, + .properties-panel-docked, .side-panel-tab-motion, .side-panel-tab-close-motion { transition: none; } /* The pill keyframes carry the X-centering; with animation:none restore it. */ @@ -3177,7 +3184,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --tp-max-height: max-height; /* Extracted from ui/src/components/FoldCurtain.tsx (transition-[max-height]). */ --tp-border-color-background-color-box-shadow: border-color,background-color,box-shadow; /* Extracted from ui/src/components/IssueChatThread.tsx (transition-[border-color,background-color,box-shadow]). */ --tp-width-min-width: width,min-width; /* Extracted from ui/src/components/KanbanBoard.tsx (transition-[width,min-width]). */ - --tp-width-opacity: width,opacity; /* Extracted from ui/src/components/OnboardingWizard.tsx (transition-[width,opacity]). */ --tp-background-color-right: background-color,right; /* Extracted from ui/src/components/ScrollToBottom.tsx (transition-[background-color,right]). */ --tp-background-color-border-color-color: background-color,border-color,color; /* Extracted from ui/src/components/SystemNotice.tsx (transition-[background-color,border-color,color]). */ --tp-transform-opacity: transform,opacity; /* Extracted from ui/src/components/ToastViewport.tsx (transition-[transform,opacity]). */ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6e79c863101..8d37738c3ed 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -981,7 +981,7 @@ function useTaskDetailInterfaceMode(conversationMode = false) { }; } -function IssueDetailLoadingState({ +export function IssueDetailLoadingState({ headerSeed, }: { headerSeed: ReturnType; diff --git a/ui/storybook/stories/tasks/task-sidebar-loading.stories.tsx b/ui/storybook/stories/tasks/task-sidebar-loading.stories.tsx new file mode 100644 index 00000000000..b06d0d96896 --- /dev/null +++ b/ui/storybook/stories/tasks/task-sidebar-loading.stories.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PropertiesPanel } from "@/components/PropertiesPanel"; +import { TaskSidePanel } from "@/components/task-side-panel"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { Button } from "@/components/ui/button"; +import { usePanel } from "@/context/PanelContext"; +import { IssueDetailLoadingState } from "@/pages/IssueDetail"; +import { storybookIssues } from "../../fixtures/paperclipData"; + +const issue = storybookIssues[0]!; +const noUpdate = () => {}; + +function LoadingTransition({ sidebar, initiallyHidden, sidebarDelay, contentDelay }: { + sidebar: boolean; + initiallyHidden: boolean; + sidebarDelay: number; + contentDelay: number; +}) { + const { openPanel, closePanel, setPanelVisible } = usePanel(); + const [replay, setReplay] = useState(0); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + closePanel(); + setLoaded(false); + setPanelVisible(!initiallyHidden); + const panelTimer = sidebar ? window.setTimeout(() => { + openPanel( + , + { contentMode: "full-bleed" }, + ); + }, sidebarDelay) : undefined; + const contentTimer = window.setTimeout(() => setLoaded(true), contentDelay); + return () => { + window.clearTimeout(panelTimer); + window.clearTimeout(contentTimer); + closePanel(); + }; + }, [replay, sidebar, initiallyHidden, sidebarDelay, contentDelay, openPanel, closePanel, setPanelVisible]); + + return ( +
+
+ + + Delayed fixture data · uses the production skeleton and sidebar host +
+
+
+ {loaded ? ( +
+ {issue.identifier} +

{issue.title}

+ {issue.description ?? "The task is ready. The sidebar and thread share the same layout transition."} +
+ ) : } +
+ +
+
+ ); +} + +const meta = { + id: "tasks-sidebar-loading", + title: "Tasks and conversations/Task creation and lifecycle/Sidebar loading", + component: LoadingTransition, + parameters: { + layout: "fullscreen", + paperclip: { + capabilities: ["tasks/properties", "tasks/discussion"], + status: "current", + implementation: "production", + boundary: "fixtures", + notes: "Production PropertiesPanel, TaskSidePanel, and IssueDetailLoadingState in a local flex shell. Replay late sidebar discovery, content-first loading, absent sidebars, and a saved hidden preference. Timers simulate data arrival; resolved thread content is a fixture, not the full task page. Browser reduced-motion preferences apply.", + }, + }, + args: { sidebar: true, initiallyHidden: false, sidebarDelay: 1200, contentDelay: 2400 }, + argTypes: { + sidebarDelay: { control: { type: "range", min: 0, max: 5000, step: 100 } }, + contentDelay: { control: { type: "range", min: 0, max: 5000, step: 100 } }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SidebarDuringSkeleton: Story = {}; +export const SidebarAfterContent: Story = { args: { sidebarDelay: 2400, contentDelay: 800 } }; +export const WithoutSidebar: Story = { args: { sidebar: false } }; +export const HiddenSidebar: Story = { args: { initiallyHidden: true } };