Skip to content

Core: Split bundled-extension web views into presentational components + interactive Storybook stories#2321

Open
Sebastian-ubs wants to merge 23 commits into
mainfrom
split-webviews-storybook
Open

Core: Split bundled-extension web views into presentational components + interactive Storybook stories#2321
Sebastian-ubs wants to merge 23 commits into
mainfrom
split-webviews-storybook

Conversation

@Sebastian-ubs
Copy link
Copy Markdown
Contributor

@Sebastian-ubs Sebastian-ubs commented May 22, 2026

Summary

Splits bundled-extension web views into a thin data-loading *.web-view.tsx (the PAPI container) plus a presentational *.component.tsx, each covered by a Storybook story that behaves like the real app — filter, select, edit, install, save, and fail — driven through the same interface the web view feeds the component, but with a thin in-memory Storybook service standing in for PAPI.

This continues the home/get-resources split already on the branch and extends the pattern to every remaining PAPI-coupled web view that can be made Storybook-compatible.

Architecture (per feature)

  • <name>.component.tsx — presentational, owns its orchestration, no @papi. Props = raw data + localizedStrings + operation callbacks (on*/handle*, get* for dependent reads, show* for in-app sub-UIs).
  • <name>.web-view.tsx — thin container: PAPI hooks / commands / project data providers → the component's props. Keeps web-view-only primitives (useWebViewState, scroll-group scrRef, controllers, updateWebViewDefinition).
  • <name>.stories.tsx — container backed by a thin in-memory CRUD store (seed useState + mutating callbacks), real localized strings via getLocalizedStrings, and real in-app sub-components wired inline (e.g. the resource picker dialog).

Story conventions: save settings → console.log; opens a completely different UI → alertCommand; calls another in-app UI → wire the real component; business-level failure → rejectingMock (only failures the web view already produces). Default story leads with the populated/data-present state.

To make components Storybook-compatible, internal @papi usage (useLocalizedStrings, logger, data hooks) was lifted up to the web view so the component is pure.

Features split

  • model-text-panel — reworked so orchestration (resolve configured model text → match a DBL resource → auto-install → load USJ) lives in the component; story wires the real ResourcePickerDialog and a thin install/settings store.
  • dictionary — lifted entry-data + localization hooks to the web view; story drives entries by scope/scrRef.
  • inventory (character / marker / punctuation / repeated-words) — lifted the four components' localization hooks to props; story does CRUD over approved/unapproved items.
  • checks-side-panel — lifted check-card localization; made checks-side-panel.utils.ts @papi-free; allow/deny mutate the store.
  • find — lifted SearchResult logger, made SearchResultsInBook pure (USJ via callback); story drives a seed result set.
  • comment-list — upgraded the existing story's write callbacks to mutate the in-memory threads so add/edit/delete/read-status reflect in the UI.

Fixes

  • Scoped usj-nodes.css bare table/td/rt selectors to .usfm to stop a global table-border style leak that appeared after viewing an editor story.
  • Fixed a find-story runtime error by importing Canon (a runtime value) from @sillsdev/scripture rather than platform-bible-utils (which only re-exports it as a type).

Verification

  • Repo typecheck (tsc) and ESLint clean on changed files.
  • npm run storybook:build succeeds — the real gate confirming components compile without @papi.
  • Playwright runtime smoke-test loaded all split-feature story iframes: 0 page errors / console errors.

Deferred

The main platform-scripture-editor.web-view.tsx (~1900 lines) is a follow-up.

🤖 Generated with Claude Code

image

This change is Reviewable

@Sebastian-ubs Sebastian-ubs changed the title Split bundled-extension web views into presentational components + interactive Storybook stories Core: Split bundled-extension web views into presentational components + interactive Storybook stories May 22, 2026
@Sebastian-ubs Sebastian-ubs enabled auto-merge (squash) May 22, 2026 17:44
@Sebastian-ubs Sebastian-ubs force-pushed the split-webviews-storybook branch 2 times, most recently from efc9c24 to eb3b76e Compare June 2, 2026 17:16
Sebastian-ubs and others added 20 commits June 3, 2026 18:45
…ories

Establish the webview-split pattern: each production webview becomes a thin
data-loader plus a same-named presentational component covered by an interactive
Storybook story.

- Add shared .storybook/story.utils.ts:
  - alertCommand(command, args): happy-path callbacks announce the real command
    name + arguments via alert()
  - rejectingMock(businessError): error-handling stories simulate a business
    failure (not "backend unavailable")
- home: surface action errors in a destructive Alert (await onSendReceiveProject);
  upgrade story alerts to state real commands + args; add SendReceiveError story
- get-resources: extract GetResources presentational component (filters stay
  controlled by the webview to preserve useWebViewState persistence; text/sort
  internal); webview becomes a data-loader. Stories: Default, Loading, Empty,
  ResourcesError, InstallError, RemoveError
- new-tab: add story rendering Home in new-tab config (no redundant component)

Verified: npm run lint:scripts, typecheck, and storybook:build all clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Apply the webview-split pattern to platform-scripture-editor's model text panel:
the webview becomes a thin data-loader that resolves PAPI data and computes a
`status`, and a new same-named presentational ModelTextPanel component renders
each state from props.

- Extract ModelTextPanel (pure, props-driven): renders the noProject /
  loadingModelTexts / noModelText / unknownResource / installing / loadingText /
  active states. Owns the editor ref, options, and read-only setUsj effect.
- model-text-panel.web-view.tsx: keep all PAPI data resolution; collapse the
  render branches into a single `status` value passed to the component.
- Add model-text-panel.stories.tsx: one story per state, with the active state
  rendering the read-only Scripture editor on a sample USJ.

Verified: typecheck, extensions lint, and storybook:build all clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Apply the webview-split pattern to legacy-comment-manager's comment list: the
web view stays a thin data-loader (PAPI threads, registration, PDP callbacks)
and a new CommentListPanel presentational component renders the toolbar + list
from props.

- Extract CommentListPanel (pure, props-driven): filter toolbar + loading
  skeletons + empty states + the platform-bible-react CommentList. Forwarded
  prop types are derived from CommentList so they stay in sync. The comment/
  scope filter constants, types, and guards move here and are re-exported.
- comment-list.web-view.tsx: keep all PAPI wiring; render <CommentListPanel>
  with the resolved threads, controlled filters, and callbacks.
- Add comment-list.stories.tsx: Loading, Populated, Empty, and EmptyFiltered
  states with sample threads.

Verified: typecheck, extensions lint, and storybook:build all clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Apply the webview-split pattern to paratext-registration's Internet settings:
the web view stays the data-loader (load/save settings via PAPI commands, the
save-and-restart flow, useWebViewState persistence) and a new
InternetSettingsForm presentational component renders the controlled form.

- Extract InternetSettingsForm (pure, props-driven): internet-use selector,
  optional proxy settings card, server selector, success/error alerts, and the
  save-and-restart button. The server/internet-use/proxy option lists, localize
  key helpers, and the string-keys list move here and are re-exported.
- internet-settings.web-view.tsx: keep all PAPI wiring and derived disabled/
  unsaved-changes logic; render <InternetSettingsForm> with the values.
- Add internet-settings.stories.tsx: Default, ProxyOnly, Restarting, SaveError.

Verified: typecheck, extensions lint, and storybook:build all clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Separate paratext-registration's RegistrationForm into a data/logic container
and a pure presentational view, so the form can be exercised in Storybook.

The form's heavy logic (debounced backend validation, save + restart, and the
webview-only updateWebViewDefinition title side-effect) is unsafe to move into
the web view wholesale, so the container keeps it and a new RegistrationFormView
holds only the JSX, fully controlled via props.

- Extract RegistrationFormView (pure, props-driven): editable/read-only name +
  code fields, validation hint, success/error alerts, and the change /
  save-and-restart buttons. The code-format regex + length constants used by the
  inputs move here and are re-exported for the container's validation.
- registration-form.component.tsx: keep all state, validation, and PAPI calls;
  render <RegistrationFormView> with the values and handlers.
- Add registration-form-view.stories.tsx: InitialRegistration,
  ExistingRegistration, ValidRegistration, SaveError, Restarting.

Verified: typecheck, extensions lint, and storybook:build all clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The demo scripture-editor stylesheet (loaded in Storybook via ten-layout-shared
and the footnote/scripture-editor stories) declared bare `table`, `td`,
`td.markercell`, and `rt` element selectors. Once the stylesheet loaded, those
rules styled every table on the page — notably giving the Get Resources / Home
tables a solid black border after an editor-based story had been viewed.

Scope them to the editor's `.usfm` content root so editor tables keep their
styling but unrelated UI is unaffected. The upstream editor package ships the
same unscoped rules.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Storybook opens the first exported story by default, so lead with the
data-present state instead of loading/empty:

- comment-list: Populated first (was Loading)
- registration-form-view: ExistingRegistration first (was the empty InitialRegistration)

Loading/empty remain as later stories.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ve picker story

Redo the model-text-panel split so the story exercises the real component through
the same interface the webview uses, with a thin in-memory backend (per the agreed
pattern). The component now owns the orchestration instead of receiving a derived
`status`/`usj`.

- ModelTextPanel: owns resolution (configured model text → match DBL resource →
  auto-install → load USJ). Receives raw data as props (effectiveModelTexts,
  dblResources, adminModelTexts, canWriteProjectSettings, scrRef) and operations as
  callbacks (installResource, setAdminModelTexts, setUserModelTexts,
  showResourcePicker, and getResourceChapter — a callback because the resource
  project to read is resolved inside the component). No @papi imports.
- webview: thin data-loader wiring PAPI to those props/callbacks; getResourceChapter
  and showResourcePicker use the imperative papi.projectDataProviders.get /
  papi.dialogs.showDialog APIs.
- story: thin in-memory CRUD service (resources + admin/user model-text lists);
  renders the REAL ResourcePickerDialog inline for showResourcePicker; install flips
  state; settings writes are console-logged. Active story leads; NoModelText is fully
  interactive (pick → install → render).

Verified: typecheck, extensions lint, and storybook:build all clean.

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

Apply the webview-split pattern to platform-lexical-tools' dictionary: a new
Dictionary presentational component owns the orchestration (scope/search/selection
state, text filtering, single-entry-vs-list derivation) and the webview becomes a
thin data-loader.

- Dictionary component (pure, no @papi): renders the scope/search header, loading/
  error/no-results states, and DictionaryEntryDisplay or DictionaryList. Dependent
  reads are callbacks (getEntries, getFullEntry) run in effects.
- Lift the internal useLocalizedStrings out of dictionary-entry-display and
  dictionary-list into a localizedStrings prop; make dictionary-list-item's
  @papi/core import a type-only import so the render tree is @papi-free.
- webview: thin loader; getEntries/getFullEntry via the imperative
  papi.dataProviders.get('platformLexicalTools.lexicalReferenceService') API;
  scrRef from the scroll group.
- story: thin in-memory service with seed entries; getEntries filters by scope;
  occurrence-nav announced via alertCommand. EntryList leads; SingleEntry, Loading,
  NoResults, DataError follow.

Verified: typecheck, extensions lint, and storybook:build all clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Make the four inventory components (character, marker, punctuation, repeated-words)
pure so they render in Storybook, and add interactive stories driven by a thin
in-memory service.

- Lift each component's internal useLocalizedStrings into a localized-strings prop.
  Marker also used useProjectData + logger for marker names; lift that to a
  markerNames prop so the component is fully @papi-free (the webview now loads
  marker names and passes them down).
- inventory.web-view.tsx: resolve each type's table-header strings (and marker
  names) and pass them into the rendered component; everything else (useInventory,
  settings reads/writes, occurrence loading) unchanged.
- Add inventory.stories.tsx: a thin in-memory CRUD service seeds items +
  approved/unapproved lists; approve/unapprove move items between lists so the UI
  reflects the change; occurrences load on selection; valid/invalid persistence is
  console-logged. Stories for Character (default), RepeatedWords, Markers, plus
  Loading and Empty.

Verified: typecheck, extensions lint, and storybook:build all clean.

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

Extract a presentational ChecksSidePanel component from the checks side panel
webview and add an interactive story. The async check-job lifecycle (begin/poll/
stop/abandon, network invalidation, PDP writes, editor navigation) stays in the
webview; the component owns the presentational logic and is fed via props.

- ChecksSidePanel component (pure, no @papi): config bar (project/scope/check-type
  filters), the results list of CheckCards, progress/status bar, and empty/loading
  states. Receives checkResults, checksInfo, projects, filters, and job status as
  props and allow/deny/navigate/settings as callbacks.
- check-card: lift its internal useLocalizedStrings into a localizedStrings prop.
- checks-side-panel.utils: move getProjectNames (the only @papi user) out to the
  webview so the util is @papi-free and importable by the component + story.
- webview: thin loader — keeps all PAPI/job/event orchestration; maps results +
  progress to props and wires callbacks to PDP writes/navigation.
- story: thin in-memory service seeds check results; allow/deny mutate them so the
  cards update; navigation/settings announced via alertCommand; Running shows
  progress; WriteFailure uses rejectingMock. Populated leads.

Verified: typecheck, extensions lint, and storybook:build all clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Extract a presentational Find component from the find/replace webview and add an
interactive story. The find-job lifecycle, replace/revert, version-history commits,
and external-change detection stay in the webview; the component is fed via props.

- Find component (pure, no @papi): search input + recent searches + scope selector
  + FindFilters + find/replace mode toggle + replace row + grouped results list +
  progress/empty states. Owns presentational derivations + keyboard result
  navigation, calling back for every action.
- Make the result children @papi-free: search-result drops its logger (keeps the
  graceful catch); search-results-in-book takes a getBookUsj callback instead of
  calling useProjectData, building the UsjReaderWriter from the result.
- webview: keeps all orchestration; renders <Find> with the state/callbacks;
  getBookUsj via the imperative papi.projectDataProviders.get(...).getBookUSJ API.
- story: thin in-memory service seeds results across two books with verse context;
  replace/replace-all/cancel/hide mutate the seed so the UI reflects it;
  commit/editor-nav announced via alertCommand. Populated leads; ReplaceMode,
  InProgress, NoResults, Replaced follow.

Verified: typecheck, extensions lint, and storybook:build all clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Upgrade the comment-list story from announce-only callbacks to a thin in-memory
CRUD store: add/edit/delete/read-status changes mutate the threads in state so the
list reflects them, matching the real app (and the other split stories). Editor
navigation stays announced via alertCommand.

Verified: typecheck, extensions lint, and storybook:build all clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The find story imported `Canon` from platform-bible-utils, which only re-exports
it as a type — so `Canon` was undefined at runtime and `Canon.allBookIds` threw
when the story rendered (typecheck/build passed; only runtime caught it). Import
Canon from @sillsdev/scripture, as the webview does.

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

Stories now behave like the running app instead of rendering a fixed snapshot:

- preview.ts: wrap every story in a full-height bg-background/text-foreground
  surface so each renders on the app panel color.
- get-resources: get/remove transition through the spinner state and flip the
  resource installed flag; add a 100-languages story.
- comment-list: derive the visible threads from the comment/scope filters
  (mirroring the web view's query); add a cross-chapter thread for the scope demo.
- checks-side-panel: derive the results from the selected check types + scope.
- find: add a small in-memory search engine over the seed corpus so the term,
  match-case, word restriction, regex, and scope all re-run the search and update
  the highlighting; replace/replace-all announce the command, show the replaced
  state, then commit (the result drops out as a re-find would); Cancel reverts.
- inventory: announce the occurrence-row navigation and the approve/unapprove
  status change via alert.
- model-text-panel: import the editor's usj-nodes/nodes-menu CSS and hide the
  read-only toolbar so the context menu is styled and the stray current-marker
  label no longer shows above the editor.
- registration-form-view story: wire "Change" to enter edit mode, Cancel to
  revert, and Save to persist back to the read-only view.
- Add .storybook/STORYBOOK-INTERACTIVITY.md documenting these conventions.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ntory status alignment

Component (app-facing) fixes flagged while reviewing the stories:

- internet-settings: make the form scroll so the proxy-settings card no longer
  overflows the fixed-height panel with no scrollbar, and keep the "Paratext
  servers" label left-aligned (the shared Grid right-aligns label columns, which
  pushed this standalone label far right on a wide panel).
- registration-form-view: use shadcn's field-validation styling (aria-invalid)
  on the registration name and code inputs instead of the bespoke invalid border.
- inventory status column: center the status buttons in the cell to match the
  centered column header (they were left-aligned).

Co-Authored-By: Claude Code <noreply@anthropic.com>
The registration form's field validation is computed by the RegistrationForm
container (showInvalidCode, registrationIsValid, the save-disabled state) and fed
to the view as props. The story never reproduced that, so unlike the app on main,
typing an invalid code showed no red field or length warning and the save button
never gated.

The decorator now derives those flags from the entered name/code (mirroring the
container): a non-empty code that doesn't match the registration-code format is
flagged with the destructive field styling and the length warning, the registration
is "valid" only with a name and a well-formed code (success alert + enabled save),
and Save is disabled while invalid or unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Sebastian-ubs and others added 3 commits June 3, 2026 18:45
The previous commit reproduced the field validation in the story, which was the
wrong place — UI logic must live in the component, and the story may only stand in
for backend behavior.

RegistrationFormView now derives its own field-level validation: a debounced
malformed-code check (the invalid-code hint) and the save-enabled gating
(unsaved + backend-confirmed + not mid-operation). The RegistrationForm container
no longer computes/passes showInvalidCode or the save-disabled flag (it only does
the backend validation and passes registrationIsValid). The story now mocks just
the backend validate command (the sample registration is recognized) and feeds
raw data + callbacks — no validation logic.

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

Adds a "Where logic lives" section drawing the line the story must not cross
(client-side rules → component; backend results → story mock), clarifies that the
"reproduce the derivation" guidance refers to the web view's PAPI query/data layer
(not UI logic), and adds a verification step to catch UI logic that snuck into a
story. Uses the registration-form validation as the cautionary example.

Co-Authored-By: Claude Code <noreply@anthropic.com>
- Wrap Select onValueChange to narrow string to DictionaryScope
- Guard against undefined lexical reference data provider in getEntries/getFullEntry

Co-Authored-By: Claude Code <noreply@anthropic.com>
@Sebastian-ubs Sebastian-ubs force-pushed the split-webviews-storybook branch from eb3b76e to 4d1d0a5 Compare June 3, 2026 16:47
Copy link
Copy Markdown
Contributor

@irahopkinson irahopkinson left a comment

Choose a reason for hiding this comment

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

@irahopkinson made 1 comment.
Reviewable status: 0 of 47 files reviewed, 1 unresolved discussion (waiting on Sebastian-ubs).


-- commits at r2:
I've been trying to get to review this. Now I finally have and it isn't ready for review. There are failing CI checks that need to be addressed. Also you need to run the Claude /review-paratext command. If you have the github plugin it will automatically update the PR description with its results (if not you will need to do that manually). Note the review command usually catches and fixes CI issues so do that first.

@tombogle
Copy link
Copy Markdown
Contributor

tombogle commented Jun 4, 2026

@irahopkinson Sebastian's posts (multiple) in Discord do not have a :code-review emoji reaction from you. Are you still hoping/planning to review this?

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.

3 participants