+
+/** The J/K/L shuttle readout: direction, key hint and rate, in mono. */
+export const Forward: Story = {}
+
+export const Reverse: Story = { args: { playbackRate: -4 } }
+
+/** At 1× it stays neutral graphite; above 1× it takes the signal orange. */
+export const Rates: Story = {
+ render: () => (
+
+ {[-8, -4, -2, -1, 1, 2, 4, 8].map((rate) => (
+
+ ))}
+
+ ),
+}
+
+/** Renders nothing when playback is stopped or the rate is zero. */
+export const Hidden: Story = {
+ args: { active: false },
+ render: (args) => (
+
+
+ (nothing renders while stopped)
+
+ ),
+}
diff --git a/src/shared/ui/timeline-preview-scrubber-visual.stories.tsx b/src/shared/ui/timeline-preview-scrubber-visual.stories.tsx
new file mode 100644
index 000000000..e12cb144f
--- /dev/null
+++ b/src/shared/ui/timeline-preview-scrubber-visual.stories.tsx
@@ -0,0 +1,68 @@
+import { useEffect, type ReactNode } from 'react'
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { usePlaybackStore } from '@/shared/state/playback'
+import { TimelinePreviewScrubberVisual } from '@/shared/ui/timeline-preview-scrubber-visual'
+
+const meta = {
+ title: 'Editor Surfaces/TimelinePreviewScrubberVisual',
+ component: TimelinePreviewScrubberVisual,
+ args: { fps: 24, frameToPixels: (frame: number) => frame * 4 },
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+
+/**
+ * The ghost playhead that follows the pointer across every Edit timeline. It
+ * reads the preview frame straight from the playback store and positions itself
+ * imperatively, so these stories seed that store rather than pass a position.
+ */
+function ScrubberStage({
+ previewFrame,
+ children,
+}: {
+ previewFrame: number | null
+ children: ReactNode
+}) {
+ useEffect(() => {
+ usePlaybackStore.getState().setPreviewFrame(previewFrame)
+ return () => usePlaybackStore.getState().setPreviewFrame(null)
+ }, [previewFrame])
+
+ return (
+
+ )
+}
+
+export const OverTracks: Story = {
+ render: (args) => (
+
+
+
+ ),
+}
+
+/** In the ruler it gains the timecode tooltip. */
+export const InRuler: Story = {
+ args: { inRuler: true },
+ render: (args) => (
+
+
+
+ ),
+}
+
+/** Suppressed (a drag is in progress) or with no preview frame: nothing renders. */
+export const Suppressed: Story = {
+ args: { suppressed: true },
+ render: (args) => (
+
+
+
+ ),
+}
diff --git a/src/shared/ui/vertical-scrollbar-overlay.stories.tsx b/src/shared/ui/vertical-scrollbar-overlay.stories.tsx
new file mode 100644
index 000000000..652246316
--- /dev/null
+++ b/src/shared/ui/vertical-scrollbar-overlay.stories.tsx
@@ -0,0 +1,71 @@
+import { useRef } from 'react'
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { VerticalScrollbarOverlay } from '@/shared/ui/vertical-scrollbar-overlay'
+
+const meta = {
+ title: 'Editor Surfaces/VerticalScrollbarOverlay',
+ component: VerticalScrollbarOverlay,
+ args: { ariaLabel: 'Scroll tracks' },
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+
+/**
+ * Timeline surfaces hide their native rail so the overlay can sit inside the
+ * track area. Scroll the well, or drag the rail on the right.
+ */
+export const OverTracks: Story = {
+ render: function OverTracksStory(args) {
+ const scrollRef = useRef(null)
+
+ return (
+
+
+ {Array.from({ length: 12 }, (_, index) => (
+
+ V{12 - index}
+
+ ))}
+
+
+
+ )
+ },
+}
+
+/** With nothing to scroll the thumb collapses and the rail stays empty. */
+export const NoOverflow: Story = {
+ render: function NoOverflowStory(args) {
+ const scrollRef = useRef(null)
+
+ return (
+
+ )
+ },
+}
diff --git a/vite.config.ts b/vite.config.ts
index b12c6a422..24044c3fd 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig, lazyPlugins } from 'vite-plus'
+import { coverageConfigDefaults, defineConfig, lazyPlugins } from 'vite-plus'
import type { Plugin } from 'vite-plus'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
@@ -83,6 +83,9 @@ export default defineConfig({
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
+ // Storybook stories are a catalog, not product code — counting them as
+ // uncovered would drag the ratchet down without saying anything true.
+ exclude: [...coverageConfigDefaults.exclude, '**/*.stories.tsx'],
// Ratchet floor, not a target: set just below measured coverage
// (2026-06-10: 50.2% stmts / 43.7% branch / 54.9% funcs / 51.5% lines)
// so CI fails on regressions. Raise these as coverage grows.
@@ -106,6 +109,17 @@ export default defineConfig({
server: {
port: 5173,
strictPort: true,
+ // CodePress Live Dev Server (managed): the preview is served from a public proxied
+ // origin on 443, so Vite must accept the proxied Host header and the HMR client
+ // must dial the proxy instead of the container's own dev port. Both values are
+ // injected by the runtime; unset locally, where Vite keeps its same-origin defaults.
+ allowedHosts: true,
+ hmr: {
+ protocol: process.env.CODEPRESS_HMR_PROTOCOL,
+ clientPort: process.env.CODEPRESS_HMR_CLIENT_PORT
+ ? Number.parseInt(process.env.CODEPRESS_HMR_CLIENT_PORT, 10)
+ : undefined,
+ },
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
diff --git a/vite.storybook.config.ts b/vite.storybook.config.ts
new file mode 100644
index 000000000..8c13114ec
--- /dev/null
+++ b/vite.storybook.config.ts
@@ -0,0 +1,29 @@
+import type { ViteUserConfig } from 'vite-plus'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import { fileURLToPath } from 'node:url'
+
+/**
+ * Vite config used only by Storybook — `.storybook/main.ts` points the builder here.
+ *
+ * The app's `vite.config.ts` carries lint/fmt/staged/test sections, a two-entry build
+ * (index.html + headless.html) and a build-only service-worker plugin that rewrites
+ * `dist/sw.js` after the bundle closes. None of that applies to a component catalog,
+ * and the SW plugin would run against a Storybook bundle that has no `sw.js`.
+ *
+ * Storybook therefore gets its own slim config with only what the components need:
+ * React Fast Refresh, Tailwind v4, and the `@` alias.
+ */
+const config: ViteUserConfig = {
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ // Same reason as the app config: keep every UI dependency on one React copy so
+ // Radix never lands on a stale dispatcher across an HMR refresh.
+ dedupe: ['react', 'react-dom'],
+ },
+}
+
+export default config