+
+/** 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 f85cf4772..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.
From 7ab80f3c197a99ae75f1db6658f26ad8f5e8dfd9 Mon Sep 17 00:00:00 2001
From: "codepress-dev[bot]"
<202219725+codepress-dev[bot]@users.noreply.github.com>
Date: Fri, 4 Sep 2026 17:10:08 +0000
Subject: [PATCH 3/3] chore(provenance): record the Storybook devDependencies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`npm run verify:provenance` pins package.json and package-lock.json by
checksum and requires the dependency inventory's direct dependencies to equal
package.json's, so adding storybook and @storybook/react-vite invalidated the
baseline. Refresh both checksums and add the two devDependencies to the
inventory.
Verified with the exact CI sequence from .github/workflows/reproducible-package.yml:
npm ci --ignore-scripts, npm run verify:provenance, then npm run
package:reproducible twice — both runs produced
sha256 b827de7fb540d01e4ac0ed5391acf406779df3182186d39449cdda17054013e5 and
compared byte-identical.
Co-authored-by: stevekriz <38476831+stevekriz@users.noreply.github.com>
---
provenance/dependency-inventory.json | 6 ++++--
provenance/freecut-baseline.json | 4 ++--
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json
index c08f6d559..9846c5c7a 100644
--- a/provenance/dependency-inventory.json
+++ b/provenance/dependency-inventory.json
@@ -3,11 +3,11 @@
"generatedFrom": "package.json",
"packageName": "freecut",
"packageVersion": "0.0.0",
- "packageJsonSha256": "7786ffe5cde5b09b6f88fc4fd13af399365ac0d6dc32f320d6f719c6f62edc89",
+ "packageJsonSha256": "c40bf894a34dc7677f6c45979775ee3987c8dcd57fd8498126bd0b4f7e7c4bbe",
"lockfile": {
"path": "package-lock.json",
"lockfileVersion": 3,
- "sha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e"
+ "sha256": "5dcdd7fe99c39fd6d7e4eb90d87dc1817b631392d66d163cf1c9695cc6c7b363"
},
"directDependencies": {
"dependencies": {
@@ -64,6 +64,7 @@
"zustand": "5.0.12"
},
"devDependencies": {
+ "@storybook/react-vite": "10.6.0",
"@tailwindcss/vite": "4.2.2",
"@tanstack/router-cli": "1.166.33",
"@testing-library/dom": "10.4.1",
@@ -77,6 +78,7 @@
"@webgpu/types": "0.1.69",
"jsdom": "27.4.0",
"playwright": "1.60.0",
+ "storybook": "10.6.0",
"tailwindcss": "4.2.2",
"typescript": "7.0.2",
"vite-plus": "0.2.4"
diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json
index 3ee69326d..7ca99210e 100644
--- a/provenance/freecut-baseline.json
+++ b/provenance/freecut-baseline.json
@@ -34,10 +34,10 @@
],
"dependencies": {
"packageJson": "package.json",
- "packageJsonSha256": "7786ffe5cde5b09b6f88fc4fd13af399365ac0d6dc32f320d6f719c6f62edc89",
+ "packageJsonSha256": "c40bf894a34dc7677f6c45979775ee3987c8dcd57fd8498126bd0b4f7e7c4bbe",
"lockfile": "package-lock.json",
"lockfileVersion": 3,
- "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e",
+ "lockfileSha256": "5dcdd7fe99c39fd6d7e4eb90d87dc1817b631392d66d163cf1c9695cc6c7b363",
"inventory": "provenance/dependency-inventory.json"
},
"assets": {