Skip to content

feat(core): add stories viewer for TUI component development#106

Merged
zrosenbauer merged 27 commits intomainfrom
feat/storybook
Mar 25, 2026
Merged

feat(core): add stories viewer for TUI component development#106
zrosenbauer merged 27 commits intomainfrom
feat/storybook

Conversation

@zrosenbauer
Copy link
Member

Summary

  • Adds a Storybook-like TUI component browser for developing and previewing kidd CLI screens in isolation
  • story() and stories() factories define components with Zod schemas for automatic props editor generation
  • Fullscreen viewer TUI with sidebar navigation, live preview, interactive props editor, and keyboard shortcuts
  • Hot reload via node:fs.watch + jiti (cache disabled) re-imports story files on save
  • Built-in decorators: withContext() (mock useCommandContext()), withFullScreen(), withLayout()
  • New kidd stories CLI command to launch the viewer
  • Supports .tsx, .ts, .jsx, and .js story files

New public API (@kidd-cli/core/stories)

Export Purpose
story() Define a single component story
stories() Define a story group with multiple variants
withContext() Decorator for components using useCommandContext()
withFullScreen() Decorator wrapping in <FullScreen>
withLayout() Decorator with fixed <Box> dimensions
discoverStories() Glob-based story file scanner
createStoryRegistry() Subscribe/notify registry for React integration
createStoryWatcher() File watcher with debounced hot reload

Example stories

  • examples/tui/src/components/StatusBadge.stories.tsx — story group with 3 variants
  • examples/tui/src/components/LogLevel.stories.tsx — story group with enum props
  • examples/tui/src/components/Greeting.stories.jsx — single story in JSX

Test plan

  • pnpm check passes (typecheck + lint + format)
  • pnpm test passes (810/810 tests, includes 67 new story tests)
  • Manual: run kidd stories in examples/tui/ to verify TUI renders
  • Manual: edit a .stories.tsx file while viewer is running to verify hot reload

Add a Storybook-like component browser for developing and previewing
kidd CLI screens in isolation. Includes story/stories factories with
Zod schema introspection, a fullscreen TUI viewer with sidebar
navigation, live preview, interactive props editor, and hot reload
via file watcher.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
oss-kidd Ignored Ignored Preview Mar 25, 2026 6:38am

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Mar 24, 2026

🦋 Changeset detected

Latest commit: 22bd90b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@kidd-cli/core Minor
@kidd-cli/cli Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed-hq
Copy link

codspeed-hq bot commented Mar 24, 2026

Merging this PR will not alter performance

✅ 2 untouched benchmarks


Comparing feat/storybook (22bd90b) with main (a008ba7)

Open in CodSpeed

@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Storybook-like terminal UI and example Ink components. New core runtime and type modules implement story factories (story, stories), story types, Zod schema → field descriptors, prop validation, story discovery/importer/registry/watcher, and an Ink-based viewer with subcomponents (sidebar, preview, props editor, status bar, help overlay, error boundary, field controls) and hooks. A CLI stories command launches the TUI. Package manifests and build config were updated to export the new stories entry and add Ink/React deps. Multiple Vitest suites and example components/stories were added.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(core): add stories viewer for TUI component development' accurately and specifically summarizes the main change—introducing a Storybook-like TUI viewer for component development.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, covering the feature overview, new public API, example stories, and test plan.
Docstring Coverage ✅ Passed Docstring coverage is 91.30% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/storybook

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli/package.json`:
- Around line 50-52: The CLI package currently lists "ink" and "react" as
devDependencies but they are dynamically imported at runtime by the kidd stories
command (see dynamic imports in packages/cli/src/commands/stories.ts), so move
"ink" and "react" from devDependencies into dependencies in
packages/cli/package.json (keep "@types/react" in devDependencies); remove their
entries under devDependencies and add the same version strings under
dependencies so the packages are present in production installs.

In `@packages/core/src/stories/schema.ts`:
- Line 25: resolveControlKind currently accepts two positional parameters;
change its exported signature to accept a single readonly destructured object
like ({ typeName, def }: { readonly typeName: string; readonly def: SomeDefType
}) and update its internal references from positional params to those
destructured names. Update every call site (including the call at the shown
line: replace resolveControlKind(typeName, innerDef) with resolveControlKind({
typeName, def: innerDef }) and similarly fix all calls referenced around lines
47-57) so callers pass an object with properties typeName and def. Ensure
exported typing is updated and any imports/uses across the module reflect the
new single-object parameter.
- Around line 103-104: The code stores Zod's raw `_def.defaultValue` (which can
be a function) into the descriptor, leaking callables instead of their runtime
values; update resolveDefaultValue (and the places that call it in the
.with('default' ...) block and the similar block around lines 136-140) to detect
when def.defaultValue is a function and invoke it (no args) to materialize its
result before assigning to FieldDescriptor.defaultValue, returning the computed
value otherwise.
- Around line 151-165: extractOptions currently unsafely casts
Object.values(def.entries) to readonly string[] causing numeric enum values to
become strings; update extractOptions to return options preserving original
types (readonly (string | number)[]) and adapt the code that consumes it: change
FieldDescriptor.options to readonly (string | number)[] and modify
buildSelectOptions to handle both string and number option values (e.g., keep
numbers as numbers when emitting values and only stringify for UI keys if
necessary). Ensure extractOptions for both 'enum' and 'nativeEnum' returns the
typed array (no cast to string[]) and update any downstream code expecting
strings to accept the widened union.

In `@packages/core/src/stories/validate.ts`:
- Around line 18-21: The exported function validateProps currently takes two
positional parameters (schema, props); refactor it to accept a single object
parameter using destructuring (e.g., ({ schema, props }: { schema:
z.ZodObject<z.ZodRawShape>, props: Record<string, unknown> })) and update its
signature and any internal references accordingly; also update all call sites to
pass an object with keys schema and props so external usage and types (including
the readonly FieldError[] return) remain correct.

In `@packages/core/src/stories/viewer/_components/preview.tsx`:
- Around line 66-75: The StoryDescription component performs an unreachable null
check for the description prop typed as string | undefined; update the
conditional to only check for undefined (or simply use if (!description) if
empty string should also hide it) so the null branch is removed; locate the
StoryDescription function and adjust the guard that currently reads "if
(description === null || description === undefined)" to a single undefined check
(or falsy check) and return null when appropriate.

In `@packages/core/src/stories/viewer/_components/props-editor.tsx`:
- Around line 44-66: The focusedFieldIndex can become out-of-bounds when the
schema/fields change; add an effect that watches fields (or fields.length) and
resets or clamps focusedFieldIndex using setFocusedFieldIndex so it never
exceeds fields.length - 1 (e.g., set to Math.min(currentIndex, Math.max(0,
fields.length - 1)) or 0 when fields is empty); update the component that
declares focusedFieldIndex/useState and useInput to include this useEffect so
focus is valid after a story/schema swap.

In `@packages/core/src/stories/viewer/_components/sidebar.tsx`:
- Around line 48-50: The highlightIndex state can become out-of-range when
selectableItems changes; add an effect that watches selectableItems (or
selectableItems.length) and clamps highlightIndex using setHighlightIndex to
Math.max(0, Math.min(prevIndex, selectableItems.length - 1)) (or reset to 0 when
length is 0) so keyboard navigation never references an undefined item; update
the component where buildSidebarItems, selectableItems, highlightIndex, and
setHighlightIndex are defined to apply this clamp whenever the items change.

In `@packages/core/src/stories/watcher.ts`:
- Around line 107-122: In debouncedReload, the setTimeout callback calls the
async function reloadStoryFile(filePath, options) without handling its Promise;
change the call inside debouncedReload so the returned Promise is handled (e.g.,
append .catch(...) to reloadStoryFile(...) to log/handle unexpected rejections)
and ensure state.timers.delete(filePath) still runs; update the code at
debouncedReload (reference: debouncedReload, reloadStoryFile, and state.timers)
to surface errors instead of silently swallowing them.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c6e5e56d-4ba6-4522-a236-aaae0ac1bd56

📥 Commits

Reviewing files that changed from the base of the PR and between 6998ac8 and eeb95f6.

⛔ Files ignored due to path filters (2)
  • .changeset/stories-viewer.md is excluded by !.changeset/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml, !pnpm-lock.yaml
📒 Files selected for processing (38)
  • examples/tui/src/components/Greeting.stories.jsx
  • examples/tui/src/components/Greeting.tsx
  • examples/tui/src/components/LogLevel.stories.tsx
  • examples/tui/src/components/LogLevel.tsx
  • examples/tui/src/components/StatusBadge.stories.tsx
  • examples/tui/src/components/StatusBadge.tsx
  • packages/cli/package.json
  • packages/cli/src/commands/stories.ts
  • packages/core/package.json
  • packages/core/src/stories/decorators.test.tsx
  • packages/core/src/stories/decorators.tsx
  • packages/core/src/stories/discover.test.ts
  • packages/core/src/stories/discover.ts
  • packages/core/src/stories/importer.ts
  • packages/core/src/stories/index.ts
  • packages/core/src/stories/registry.test.ts
  • packages/core/src/stories/registry.ts
  • packages/core/src/stories/schema.test.ts
  • packages/core/src/stories/schema.ts
  • packages/core/src/stories/story.test.ts
  • packages/core/src/stories/story.ts
  • packages/core/src/stories/types.ts
  • packages/core/src/stories/validate.test.ts
  • packages/core/src/stories/validate.ts
  • packages/core/src/stories/viewer/_components/empty-state.tsx
  • packages/core/src/stories/viewer/_components/error-boundary.tsx
  • packages/core/src/stories/viewer/_components/field-control.tsx
  • packages/core/src/stories/viewer/_components/help-overlay.tsx
  • packages/core/src/stories/viewer/_components/preview.tsx
  • packages/core/src/stories/viewer/_components/props-editor.tsx
  • packages/core/src/stories/viewer/_components/sidebar.tsx
  • packages/core/src/stories/viewer/_components/status-bar.tsx
  • packages/core/src/stories/viewer/hooks/use-panel-focus.ts
  • packages/core/src/stories/viewer/hooks/use-stories.ts
  • packages/core/src/stories/viewer/stories-app.tsx
  • packages/core/src/stories/watcher.test.ts
  • packages/core/src/stories/watcher.ts
  • packages/core/tsdown.config.ts

zrosenbauer and others added 2 commits March 24, 2026 18:35
- Move ink/react to runtime dependencies in CLI package
- Use object destructuring for resolveControlKind() and validateProps()
- Materialize callable Zod defaults in resolveDefaultValue()
- Explicitly convert enum values to strings in extractOptions()
- Remove redundant null check in StoryDescription
- Clamp stale focusedFieldIndex/highlightIndex on schema/entry changes
- Add .catch() to unhandled promise in watcher debounced reload

Co-Authored-By: Claude <noreply@anthropic.com>
Align with main branch rename of Context → CommandContext and
useCommandContext → useScreenContext.

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/stories/schema.ts`:
- Around line 28-35: extractOptions currently only inspects the array def itself
and misses enum options nested on def.element, which causes array(enum) to be
routed to a broken 'multiselect' without choices and collapse to a singleton on
edit; update extractOptions to detect when typeName === 'array' (or when
innerDef.kind === 'array') and, if innerDef.element exists and
innerDef.element.kind === 'enum', return the enum options from innerDef.element
(e.g., map its values into the same options shape you use elsewhere); also audit
the control mapping that sets control to 'multiselect' (the Object.freeze block
returning zodTypeName and control) and ensure
packages/core/src/stories/viewer/_components/field-control.tsx consumes those
options and emits an array (i.e., the MultiSelect component should accept/return
arrays) so array(enum) gets proper options and round-trips correctly.

In `@packages/core/src/stories/viewer/_components/preview.tsx`:
- Around line 48-50: The ErrorBoundary around DecoratedComponent retains errors
across prop/story changes because getDerivedStateFromError stores state but
there's no reset; fix by adding a key prop to the ErrorBoundary so it
unmounts/remounts when story or props change (e.g., derive key from story.id
and/or currentProps), update the JSX that renders <ErrorBoundary> to include key
based on story and currentProps (use a stable representation like story?.id ||
'empty' or JSON.stringify([story?.id, currentProps]) depending on how frequently
you want resets) so the ErrorBoundary's internal error state is cleared on
relevant changes.
- Around line 37-40: The DecoratedComponent identity is recreated every render
because applyDecorators(...) is called directly in Preview; wrap the call to
applyDecorators in a useMemo so the decorated component is memoized and does not
change unless the inputs change—compute DecoratedComponent = useMemo(() =>
applyDecorators(story.component as ComponentType<Record<string, unknown>>,
story.decorators), [story.component, story.decorators]) inside the Preview
component so updates to currentProps don't force an unmount of the
DecoratedComponent subtree.

In `@packages/core/src/stories/viewer/_components/props-editor.tsx`:
- Around line 180-185: The current findFieldError only matches exact field names
so nested validation errors like "meta.nested" or "tags.0" are ignored; update
findFieldError to return the first error whose field equals the requested
fieldName OR whose field starts with the requested fieldName followed by a dot
or an opening bracket (e.g. error.field === fieldName ||
error.field.startsWith(fieldName + ".") || error.field.startsWith(fieldName +
"[")), preserving the existing return-null behavior when none match; this change
ensures nested errors emitted by validateProps() surface on the parent
json/object/array control.

In `@packages/core/src/stories/watcher.ts`:
- Around line 33-53: createStoryWatcher currently calls watch(dir, ...)
synchronously and can throw, leaking any previously created watchers and not
returning the required Result tuple; wrap the loop that calls watch() in a
try/catch so you catch synchronous exceptions from watch(), and on error iterate
state.watchers to close each watcher (call the watcher close method used in your
code) to release file handles before returning an error Result. Change the
function to return the Result type per contrib rules: on success return [null,
StoryWatcher] (where StoryWatcher exposes the existing close() that closes all
state.watchers and clears timers) and on failure return [StoryWatcherError,
null] with a clear error describing which directory failed; ensure you still use
debounceMs and debouncedReload and push each created watcher into state.watchers
as you create them so cleanup can run if a later watch() throws.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9f6bf43b-b56b-4574-8215-6aa5a7b6a553

📥 Commits

Reviewing files that changed from the base of the PR and between eeb95f6 and f4ebde7.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml, !pnpm-lock.yaml
📒 Files selected for processing (11)
  • packages/cli/package.json
  • packages/core/src/stories/index.ts
  • packages/core/src/stories/schema.test.ts
  • packages/core/src/stories/schema.ts
  • packages/core/src/stories/validate.test.ts
  • packages/core/src/stories/validate.ts
  • packages/core/src/stories/viewer/_components/preview.tsx
  • packages/core/src/stories/viewer/_components/props-editor.tsx
  • packages/core/src/stories/viewer/_components/sidebar.tsx
  • packages/core/src/stories/viewer/stories-app.tsx
  • packages/core/src/stories/watcher.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli/src/commands/stories.ts`:
- Around line 76-81: The finally block currently unconditionally writes the
alternate-screen exit sequence via process.stdout.write('\u001B[?1049l') after
await instance.waitUntilExit(); remove this line from the finally in
packages/cli/src/commands/stories.ts because StoriesApp is always wrapped in
<FullScreen> (stories-app.tsx) which already handles enter/exit lifecycle; if
you prefer to keep a defensive guard instead, mirror the guard pattern used in
packages/core/src/ui/screen.tsx to only write the exit sequence when the process
actually owns the alternate screen.

In `@packages/core/src/stories/decorators.tsx`:
- Around line 80-90: withLayout currently passes options.width and
options.padding directly into Box (inside LayoutWrapper) without validation; add
a Zod schema (e.g., LayoutOptionsSchema) that validates allowed types/ranges for
width and padding (and any other LayoutOptions fields), run options through
schema.parse or safeParse inside withLayout before returning the decorator, and
if validation fails use a safe fallback (default width/padding) or throw a clear
error so LayoutWrapper always passes validated values to Box; reference the
symbols withLayout, LayoutWrapper, options.width, options.padding, and Box when
implementing the validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1aead540-9c13-4d79-bd99-02d7270b2a86

📥 Commits

Reviewing files that changed from the base of the PR and between f4ebde7 and ac8ce85.

📒 Files selected for processing (4)
  • packages/cli/package.json
  • packages/cli/src/commands/stories.ts
  • packages/core/package.json
  • packages/core/src/stories/decorators.tsx

Enable `jsx: true` in jiti config so `.jsx` story files are supported.
Change example story imports from `.js` to extensionless so jiti's CJS
require can resolve them to `.tsx`/`.jsx` source files.

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/stories/importer.ts`:
- Around line 55-57: Change isStoryEntry so it is a TypeScript user-defined type
guard returning "value is StoryEntry" instead of boolean; update the function
signature to use the StoryEntry predicate (function isStoryEntry(value:
unknown): value is StoryEntry) so callers can rely on narrowing (remove the cast
"entry as StoryEntry" where used). Keep the existing implementation (hasTag
checks for 'Story' or 'StoryGroup') and ensure the StoryEntry type is in scope
or imported so the compiler recognizes the predicate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9220a5e0-1575-492a-ba0f-4a8e6c045b73

📥 Commits

Reviewing files that changed from the base of the PR and between ac8ce85 and a876016.

📒 Files selected for processing (4)
  • examples/tui/src/components/Greeting.stories.jsx
  • examples/tui/src/components/LogLevel.stories.tsx
  • examples/tui/src/components/StatusBadge.stories.tsx
  • packages/core/src/stories/importer.ts

zrosenbauer and others added 3 commits March 24, 2026 19:12
- Replace .map() side effects with .reduce() accumulators and point-free helpers
- Add change detection guard in registry set() to skip no-op updates
- Extract shared createMockStory test helper to __test__/mock-story.ts
- Merge duplicate enum/nativeEnum match branches in schema.ts
- Add useMemo for sidebar item computation
- Use toError() from utils/fp instead of manual error normalization
- Replace O(n²) spread pattern in collectAsyncIterable with push-based drain
- Export STORY_FILE_SUFFIXES constant from types and reuse in discover/watcher

Co-Authored-By: Claude <noreply@anthropic.com>
- Fix array(enum) extractOptions to pull options from def.element
- Memoize DecoratedComponent with useMemo in Preview to prevent remounts
- Add key prop to ErrorBoundary to reset error state on story change
- Replace single-value Select with real MultiSelect for multiselect control
- Add test assertion for array(enum) options extraction

Co-Authored-By: Claude <noreply@anthropic.com>
…andling

The stories command was using command() with a manual inkRender() call,
which bypassed the framework's stdin/raw mode setup. Key inputs were not
being picked up because @clack/prompts spinner left stdout in a state
that interfered with Ink's rendering lifecycle.

- Create StoriesScreen wrapper component that handles discovery via useEffect
- Convert CLI command from command() to screen() for proper Ink lifecycle
- Move story discovery, registry setup, and watcher into the component
- Export StoriesScreen from @kidd-cli/core/stories barrel

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (3)
packages/core/src/stories/viewer/_components/preview.tsx (1)

50-52: ⚠️ Potential issue | 🟠 Major

Reset the boundary for same-story prop edits and hot reloads.

key={story.name} only remounts the boundary when the display name changes. If the same story throws after a prop edit or reloads with the same name, the boundary stays latched and the preview can remain stuck on the fallback until the user selects a different story. Use a reset key that also changes with the selected story identity and/or current props.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/stories/viewer/_components/preview.tsx` around lines 50 -
52, The ErrorBoundary is keyed only by story.name which doesn't change on prop
edits or hot reloads, causing the boundary to stay latched; update the key on
the <ErrorBoundary> (in preview.tsx around ErrorBoundary, DecoratedComponent,
currentProps, story.name) to include a stable story identity and something that
reflects current props or selected story state (for example combine story.id or
story.name with a lightweight serialized form or version of
currentProps/selectedStoryId) so the boundary remounts when props or the
selected story identity change.
packages/cli/src/commands/stories.ts (1)

77-80: ⚠️ Potential issue | 🟡 Minor

Redundant alternate-screen cleanup still present.

Line 79 writes \u001B[?1049l unconditionally. StoriesApp is always wrapped in <FullScreen> which handles alternate-screen lifecycle on unmount. This sequence fires twice on normal exit.

Either remove the line or add a TTY guard:

Option 1: Remove (preferred)
     } finally {
       watcher.close()
-      process.stdout.write('\u001B[?1049l')
     }
Option 2: Guard
     } finally {
       watcher.close()
-      process.stdout.write('\u001B[?1049l')
+      if (process.stdout.isTTY) {
+        process.stdout.write('\u001B[?1049l')
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/stories.ts` around lines 77 - 80, The finally block
redundantly writes the alternate-screen exit sequence unconditionally, causing
duplicate unmount cleanup since StoriesApp is always wrapped by <FullScreen>;
remove the explicit process.stdout.write('\u001B[?1049l') call from the finally
block (preferred) so FullScreen handles the lifecycle, or if you insist on
keeping it, wrap that write in a TTY guard (e.g., check process.stdout.isTTY)
and only execute when true; update references in this file where watcher.close()
and the process.stdout.write call appear to reflect the removal/guarding.
packages/core/src/stories/watcher.ts (1)

34-62: ⚠️ Potential issue | 🟠 Major

watch() can throw, leaking earlier watchers.

If watch(dir, ...) throws (e.g., ENOENT, EACCES) after successfully creating watchers for earlier directories, those watchers remain open and unreachable since the function never returns the close() handler.

Per contributing/standards/typescript/errors.md, exported functions with expected failures should return Result tuples.

Proposed fix
+/**
+ * Error returned when watcher creation fails.
+ */
+export interface StoryWatcherError {
+  readonly message: string
+  readonly directory: string
+}
+
+type StoryWatcherResult = [StoryWatcherError, null] | [null, StoryWatcher]
+
-export function createStoryWatcher(options: WatcherOptions): StoryWatcher {
+export function createStoryWatcher(options: WatcherOptions): StoryWatcherResult {
   const debounceMs = options.debounceMs ?? 150
+  const createdWatchers: ReturnType<typeof watch>[] = []
+
+  const createError = (dir: string, err: unknown): StoryWatcherResult => {
+    createdWatchers.map((w) => w.close())
+    const message = err instanceof Error ? err.message : String(err)
+    return [{ message, directory: dir }, null]
+  }
+
-  const watchers = options.directories.map((dir) => {
+  for (const dir of options.directories) {
+    try {
       const watcher = watch(dir, { recursive: true }, (_event, filename) => {
         // ... callback unchanged
       })
-    return watcher
-  })
+      createdWatchers.push(watcher)
+    } catch (err) {
+      return createError(dir, err)
+    }
+  }

   const state: WatcherState = {
     timers: new Map<string, ReturnType<typeof setTimeout>>(),
-    watchers,
+    watchers: createdWatchers,
   }

-  return Object.freeze({
+  return [null, Object.freeze({
     close: (): void => {
       // ... unchanged
     },
-  })
+  })]
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/stories/watcher.ts` around lines 34 - 62,
createStoryWatcher currently calls watch(dir, ...) directly which can throw and
leak already-created watchers; modify createStoryWatcher to catch errors during
watcher creation by wrapping each watch(dir, ...) in a try/catch, and on any
catch: immediately close all watchers created so far (use closeWatcher on each
watcher in the local watchers array), clear any timers in WatcherState, and
return a failure Result tuple instead of throwing (follow the exported-function
Result tuple convention from contributing/standards/typescript/errors.md).
Update the function signature/return type to a Result<[StoryWatcher, null] |
[null, Error]> (or your project’s Result type), and ensure successful path still
builds state (WatcherState) and returns the frozen StoryWatcher with close()
that closes watchers and clears timers; reference createStoryWatcher, watch,
WatcherState, closeWatcher, state.timers, and the close() method when making the
changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/stories/registry.ts`:
- Around line 32-38: The public getters currently return the live mutable Map
(state.snapshot) via a type-only ReadonlyMap cast, so callers can mutate
internal state; change getAll and getSnapshot (and the similar getters at the
other occurrences) to return a shallow copy instead (e.g., return new
Map(state.snapshot) cast to ReadonlyMap) so callers receive an immutable view
that cannot mutate the internal Map directly, preserving synchronization between
state.entries and state.snapshot and keeping subscribers intact; update getAll,
getSnapshot and any analogous return sites (lines referenced by the reviewer) to
construct and return new Map(state.snapshot) rather than returning
state.snapshot itself.

In `@packages/core/src/stories/schema.ts`:
- Around line 65-79: The ZodDef interface is declared under a "Private" comment
but is re-exported publicly from index.ts; either move the ZodDef declaration
out of the private block (so it sits with other public types) or change the
section comment to indicate it's public; update the comment around the ZodDef
declaration in packages/core/src/stories/schema.ts (and ensure index.ts still
re-exports ZodDef) so the declaration location/comment accurately reflects its
public API status.

---

Duplicate comments:
In `@packages/cli/src/commands/stories.ts`:
- Around line 77-80: The finally block redundantly writes the alternate-screen
exit sequence unconditionally, causing duplicate unmount cleanup since
StoriesApp is always wrapped by <FullScreen>; remove the explicit
process.stdout.write('\u001B[?1049l') call from the finally block (preferred) so
FullScreen handles the lifecycle, or if you insist on keeping it, wrap that
write in a TTY guard (e.g., check process.stdout.isTTY) and only execute when
true; update references in this file where watcher.close() and the
process.stdout.write call appear to reflect the removal/guarding.

In `@packages/core/src/stories/viewer/_components/preview.tsx`:
- Around line 50-52: The ErrorBoundary is keyed only by story.name which doesn't
change on prop edits or hot reloads, causing the boundary to stay latched;
update the key on the <ErrorBoundary> (in preview.tsx around ErrorBoundary,
DecoratedComponent, currentProps, story.name) to include a stable story identity
and something that reflects current props or selected story state (for example
combine story.id or story.name with a lightweight serialized form or version of
currentProps/selectedStoryId) so the boundary remounts when props or the
selected story identity change.

In `@packages/core/src/stories/watcher.ts`:
- Around line 34-62: createStoryWatcher currently calls watch(dir, ...) directly
which can throw and leak already-created watchers; modify createStoryWatcher to
catch errors during watcher creation by wrapping each watch(dir, ...) in a
try/catch, and on any catch: immediately close all watchers created so far (use
closeWatcher on each watcher in the local watchers array), clear any timers in
WatcherState, and return a failure Result tuple instead of throwing (follow the
exported-function Result tuple convention from
contributing/standards/typescript/errors.md). Update the function
signature/return type to a Result<[StoryWatcher, null] | [null, Error]> (or your
project’s Result type), and ensure successful path still builds state
(WatcherState) and returns the frozen StoryWatcher with close() that closes
watchers and clears timers; reference createStoryWatcher, watch, WatcherState,
closeWatcher, state.timers, and the close() method when making the changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 625e847f-2162-4c7f-9bc1-4d5ed1311b96

📥 Commits

Reviewing files that changed from the base of the PR and between a876016 and d9eca09.

📒 Files selected for processing (16)
  • packages/cli/src/commands/stories.ts
  • packages/core/src/stories/__test__/mock-story.ts
  • packages/core/src/stories/discover.test.ts
  • packages/core/src/stories/discover.ts
  • packages/core/src/stories/importer.ts
  • packages/core/src/stories/index.ts
  • packages/core/src/stories/registry.test.ts
  • packages/core/src/stories/registry.ts
  • packages/core/src/stories/schema.test.ts
  • packages/core/src/stories/schema.ts
  • packages/core/src/stories/types.ts
  • packages/core/src/stories/viewer/_components/field-control.tsx
  • packages/core/src/stories/viewer/_components/preview.tsx
  • packages/core/src/stories/viewer/_components/sidebar.tsx
  • packages/core/src/stories/viewer/stories-app.tsx
  • packages/core/src/stories/watcher.ts

zrosenbauer and others added 12 commits March 24, 2026 20:10
Registry keys are absolute file paths containing /, so using / as the
separator between group key and variant name caused resolveStory to
split on the first / in the path. Use :: instead.

Co-Authored-By: Claude <noreply@anthropic.com>
jiti with jsx: true uses Babel's classic JSX transform which requires
React to be in scope. Story components use the automatic runtime
(react/jsx-runtime) so they don't import React. Configure jiti with
jsx: { runtime: 'automatic' } to match.

Co-Authored-By: Claude <noreply@anthropic.com>
Add a ScrollArea UI component for vertically scrollable containers
in terminal UIs. Features:
- Auto-scroll to keep active item visible
- Controlled and uncontrolled scroll offset modes
- Optional scroll position indicator (┃/│ track)
- Configurable viewport height

Integrate into the stories sidebar to handle long story lists
that exceed terminal height. Export from @kidd-cli/core/ui for
use in downstream CLIs.

Co-Authored-By: Claude <noreply@anthropic.com>
Auto-select first story on mount and on navigate (no Enter required),
add bold border + cyan title to focused panel, simplify StatusBadge
example to use story() API.

Co-Authored-By: Claude <noreply@anthropic.com>
…icator

- Remove group headers and indentation from sidebar — all stories
  shown at same level with "GroupTitle / VariantName" labels
- Up/down arrows only move highlight cursor, Enter commits selection
- Remove auto-select on mount
- Replace dim panel label in status bar with tab indicator showing
  active panel (▸ Stories / ▸ Props) and keyboard hints

Co-Authored-By: Claude <noreply@anthropic.com>
Replace panel cycling with two distinct interaction modes:

- Browse mode: navigate collapsible directory tree in sidebar,
  groups expand/collapse with Enter, stories select with Enter
- Edit mode: activated after selecting a story, props editor
  receives focus, Esc returns to browse mode

Sidebar now renders a directory tree with ▸/▾ chevrons on groups
and indented variant leaves. Status bar shows mode-aware hints
(● Browse / ● Edit) with context-sensitive keyboard shortcuts.

Co-Authored-By: Claude <noreply@anthropic.com>
…escription

Preview panel now shows the relative file path, qualified story name
(e.g. "LogLevel > Info" for group variants), and description.

Co-Authored-By: Claude <noreply@anthropic.com>
…ed header

- Name displayed first (bold), file path second (italic + dim)
- Description visually separated with margin
- Preview wrapped in bordered box
- Content area uses ScrollArea for overflow

Co-Authored-By: Claude <noreply@anthropic.com>
…verflow clip

ScrollArea with a computed height inside a flexGrow container was
double-counting space, pushing the layout past terminal bounds.
Use overflow="hidden" on a flex-growing inner box instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds a useSize(ref?) hook that measures the Yoga-computed dimensions
of a <Box> element via Ink's measureElement. When no ref is provided,
falls back to terminal dimensions. Preserves referential identity
when dimensions are unchanged to avoid unnecessary re-renders.

Replaces manual chrome-row calculations in both the sidebar and
preview components with ref-based measurement.

Co-Authored-By: Claude <noreply@anthropic.com>
Move useMemo before the early return so all hooks run on every render,
fixing "Rendered more hooks than during the previous render" error.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds the react plugin with rules-of-hooks (error), exhaustive-deps
(warn), jsx-no-duplicate-props, jsx-key, no-children-prop, and
no-direct-mutation-state. Disables react-in-jsx-scope (automatic
JSX runtime) and other inapplicable rules.

Co-Authored-By: Claude <noreply@anthropic.com>
zrosenbauer and others added 2 commits March 25, 2026 02:19
Split preview content area between component and props editor with
independent scrolling. Add overflow hidden to prevent layout blowout.
Add pipe divider to tab hint text.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@zrosenbauer zrosenbauer merged commit d270f4b into main Mar 25, 2026
7 checks passed
@zrosenbauer zrosenbauer deleted the feat/storybook branch March 25, 2026 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants