@avophile/clarrel-shared-react (0.4.2)
Installation
@avophile:registry=npm install @avophile/clarrel-shared-react@0.4.2"@avophile/clarrel-shared-react": "0.4.2"About this package
clarrel-shared-react
The staged search/rerank/winnow/[generate] reveal pipeline as a shared React
component library — extracted from clarrel's own frontend
(avophile/clarrel#784/#785) so both clarrel (the personal instance) and
clarrel-commons (the standalone Commons service) can render the same
client-paced reveal from one source, instead of drifting apart in two copies.
Same reasoning as this repo's Python side (anchors/chunking/signals/
interchange) — see the repo root README.
This is a client-paced UX layer over an already-complete response, not a reflection of real incremental server work. The backend does search + rerank (and, in synthesize mode, answer generation) in one call; these components pace when the UI is allowed to reveal what it already has.
What's here
useSearchRevealPipeline— the orchestrating state machine (idle -> searching -> results -> reranking -> reranked -> winnowing -> winnowed -> [generating -> done]). Manual controls:beginSearch,reveal,skip,goToPhase,replay,pause,resume,reset. Three timing modes: fixed-duration timer mode (the default, what search uses — paces reveal of an already-complete response), fully caller-driven external mode (external: true— every transition is an explicit call, for a backend with no fixed schedule at all), and readiness-gated mode (readinessGate: { maxWaitMs? }— same timer-mode pacing, but each transition also waits for a realmarkPhaseReady(phase)signal before committing to it, exposingisWaitingOnReadinesswhile the real backend stage is still running past its normal hold andisStalledif it never reports in withinmaxWaitMs; built for a genuinely incremental backend like Commons's/chatSSE stream that still wants search's own smooth pacing). As of avophile/clarrel-commons#262,generating -> doneis ALSO gated under this mode (callmarkPhaseReady('done')once your own stream's real completion event arrives, instead of hand-rolling a ref/closure to decide when it's safe to callskip()— the exact trap #262's own original bug turned out to be) —heartbeat()re-arms the stall ceiling on each incremental sign of life (e.g. once per streamed chunk) so a slow-but-healthy long generation doesn't falsely tripisStalledjust because its TOTAL duration exceedsmaxWaitMs. Unaffected in plain timer/external mode:generating -> donethere is still driven purely byskip()(TypewriterText's completion callback), exactly as before.SearchStageSpinner/RerankStageSpinner/WinnowStageSpinner/GenerationStageSpinner(+ bareStageSpinnerIcon) — four hand-built, purpose-specific loading indicators. None reuse a generic spinning-arc pattern.ResultTileGrid— responsive tile grid with a hand-rolled FLIP reorder animation, winnow shrink/fade, and a collapsed-by-default, keyboard-operable expand toggle per tile. Generic over the result type — the consumer supplies field accessors (getTitle,getScore,renderActions, ...) rather than this package knowing any one app's result shape.StageStepper(avophile/clarrel-commons#258) — a persistent, all-steps-at-once progress tracker (pending/active/done per step), driven directly offuseSearchRevealPipeline's ownphase. The real, shared equivalent ofclarrel-commons'schat.htmlfallback's own hand-written#stage-tracker— built once here instead of redone per consumer. Pass as many or as fewstepsas your pipeline actually walks through (a plain-search consumer that never reachesgeneratingomits that step entirely); layersisWaitingOnReadiness/isStalledfromreadinessGatemode onto the active step when provided. Generic steps mode (avophile/clarrel#1205): passactiveKey(and optionallycomplete) instead ofphase, withstepskeyed by any string, for a process that is not the search pipeline — e.g. an upload's Uploading → Reading pages → Indexing → Done. Steps beforeactiveKeyare done, it is active, the rest pending; everything else (glyphs,aria-current,statusLabels, waiting/stalled, reduced motion) is shared with the phase mode. A step's optionaldetailrenders inside it, for a measured count ("64 of 130 chunks"). OptionalstatusLabels({ done, active, pending, stalled? }, caller- supplied like every other string here) renders each step's state as visually-hidden text, so a screen reader can tell a finished step from one not yet started — the glyphs arearia-hiddenand only the active step carriesaria-current. Omitted, the DOM is unchanged.SynthesisReveal(avophile/clarrel-commons#262) — pairs a synthesis pipeline'snarrativeresponse with itstilesgrid, structurally guaranteeingnarrativerenders first (a consumer cannot get this order wrong by passing the two props differently — see the component's own module docstring) and applying the shared--csr-block-gapspacing token between them so the gap always matchesResultTileGrid's own row-to-row gap. Presentation-only, noRevealPhasedependency: passnull/undefinedfor whichever slot has nothing to show yet.TypewriterText— reveals a complete string at a configurable characters-per-second rate, skippable by click or a real keyboard-operable button.usePrefersReducedMotion— sharedprefers-reduced-motionreader.defaultTileSnippet— small text-truncation helper matching the existing card truncation convention both consumers already used.StageInfoOverlay(avophile/clarrel-commons#312, extracted in avophile/clarrel#984) — the little "i" beside eachStageStepperstep, explaining in plain language what that stage does. Meets WCAG 2.1 AA 1.4.13 (hoverable, dismissible with Escape, persistent), opens on tap, and only one tooltip is open at a time. The caller supplies every label and description, so each consumer describes its own pipeline. Wrap it and the stepper in.stage-stepper-with-infoto overlay each "i" on its step in pure CSS.stepperDisplayPhase/resolveStepperPhase— which phase a stage tracker should display. The first closes the dead gap between one step's check mark and the next step's spinner during the reveal pipeline's cosmetic "done" holds (clarrel-commons#312). The second follows an answer that streams on its own clock instead of the pipeline, so "Generating" doesn't spin forever once the pipeline has stopped atwinnowed(clarrel#984).COMMONS_PALETTE/commonsTheme/COMMONS_THEME_CLASS/COMMONS_SHAPE/COMMONS_TYPE— the Commons design tokens for code.commonsThemevalues arevar(--token)references for inline styles;COMMONS_PALETTEholds the literals, for comparing against a stylesheet.
What's deliberately NOT here
Anything tied to one consumer's own result shape or business logic:
SearchResult/UnifiedSearchResult types, feedback/consent-toggle
components, and any private/shared consent-partition wiring. clarrel-commons
serves one public corpus with no private/shared split — that machinery
doesn't belong at this layer. Each consumer supplies its own accessor
callbacks into ResultTileGrid instead.
Theming
ResultTileGrid/StageSpinners read their colors from a small set of
--csr-*-prefixed CSS custom properties (theme.css, imported once
from index.ts) rather than hardcoded hex values — namespaced to avoid
colliding with a consumer's own theme variables. The :root defaults
are clarrel's own current dark palette verbatim, so clarrel needs zero
CSS of its own to keep looking exactly as it did before this file
existed. A consumer with a different visual identity overrides just
these variables, scoped however suits its own page (its own :root, a
wrapping class, a media query):
:root {
--csr-accent: #2b6cb0;
--csr-accent-bg: #ebf4ff;
--csr-accent-hover: #2b6cb0;
--csr-bg-hover: #f7fafc;
--csr-text: #1a202c;
--csr-text-muted: #718096;
--csr-border: #e2e8f0;
--csr-generate-cursor: #38a169;
--csr-error: #dc2626;
--csr-surface: #ffffff;
--csr-tile-radius: 18px;
--csr-tile-shadow: 0 1px 3px rgba(20, 43, 66, 0.06);
--csr-block-gap: 16px;
}
See theme.css for what each variable actually controls.
The Commons design system (opt-in)
@avophile/clarrel-shared-react/commons-theme.css is the clarrel-commons
"cosy reading room" design system as a stylesheet: the approved palette
(each colour has exactly one job), the EB Garamond serif stack, the
.card / .btn-primary / .btn-secondary / .commons-chip /
.commons-input / .commons-notice--* primitives, the answer surface
(.narrative-panel, .cite-link, .cite-button, .citation-list), and
the --csr-* mapping that makes every component in this package render in
that palette.
import '@avophile/clarrel-shared-react/style.css'; // components
import '@avophile/clarrel-shared-react/commons-theme.css'; // the palette
// then: <body class="clarrel-commons-theme"> (or any mount point)
Two deliberate choices:
- Opt-in by class, never
:root. Importing the stylesheet changes nothing until an element carries.clarrel-commons-theme, so a consumer can convert one subtree at a time. - A separate file, not folded into
style.css. A consumer that never opts in ships no palette it does not use.
The stylesheet only declares the font stack; it never fetches a font. A private, self-hosted instance should not contact a third party on every page view, so loading a webfont is each consumer's own decision.
commonsTheme.test.ts pins every palette value and --csr-* mapping
against the clarrel-commons original, in one place. clarrel-commons's
server-rendered pages must work with no JS bundle at all, so they inline
their own copy of the palette and pin it against COMMONS_PALETTE
instead of importing this file.
Install
Published to GitHub Packages as @avophile/clarrel-shared-react (a normal
semver range, not a git-commit pin) — the publish-js job in this repo's own
ci.yml publishes a new version on every push to main whose
package.json version isn't already published.
Not installed via npm's git-subdirectory syntax, despite that being this
repo's own Python-side convention (see the root README) — npm ci hits an
unresolved, confirmed-not-a-credential-problem "Repository not found" error
on the exact ssh-insteadOf-redirected-to-https pattern that syntax needs
(reproduces identically even against a freshly rotated PAT, while the same
token's equivalent pip fetch of this same repo succeeds every time — see
avophile/clarrel#786 and avophile/clarrel-commons#228's own ci.yml
comments for the full diagnosis). GitHub Packages' npm registry is npm's own
native mechanism for a private package and sidesteps that code path
entirely.
A consumer's package.json:
"dependencies": {
"@avophile/clarrel-shared-react": "^0.1.0"
}
Both the consumer's own .npmrc (for local development) and CI job need to
know @avophile-scoped packages resolve against GitHub Packages, and need a
token with read access to this repo to authenticate the pull (GitHub
Packages permissions are inherited from the repository's own access, not
scoped separately — a token that can already read this repo's contents can
already install anything published from it):
@avophile:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
In CI, actions/setup-node's registry-url/scope inputs write this
automatically — see either consumer's own ci.yml for the working example.
For local development, each developer needs a personal fine-grained PAT
with read access to this repo, exported as NODE_AUTH_TOKEN (or written
directly into ~/.npmrc, never committed — see this repo's own
Credentials section in the root README).
react/react-dom (^19.0.0) are peerDependencies — never bundled, so a
consumer app never ends up running two React instances side by side.
Component CSS rides along with each component's own JS (each does
import './Foo.css'), except in a library build that CSS is extracted
into a single stylesheet rather than auto-injected — import it once at your
app's entry point:
import '@avophile/clarrel-shared-react/style.css';
Development
build/prepare force NODE_ENV=production explicitly rather than
relying on vite build's own default: Vite only auto-sets
NODE_ENV=production for the build command when the variable isn't
already set in the environment it runs in — an ambient
NODE_ENV=development (common in a dev shell, and easy to pick up
transitively from a consumer's own npm install environment, since
this package's prepare script runs inside whatever process spawned
it) silently makes @vitejs/plugin-react's automatic JSX transform
select react/jsx-dev-runtime instead of react/jsx-runtime. Since
react/jsx-dev-runtime is CJS-only, Rolldown then has no ESM-safe way
to leave it external and inlines its literal source instead — which
still calls Node's require("react") internally, and crashes at
runtime in any environment without a global require (Vitest's jsdom
environment, a browser, ESM-only bundler output). Confirmed directly:
an ambient NODE_ENV=development reproduces this exact break
(dist/index.js balloons from ~28KB to ~45KB and gains six
require(...)/__require call sites); vite build --mode production
alone does not fix it (--mode doesn't override an already-set
process.env.NODE_ENV) -- only forcing the env var itself does.
npm install
npm run build # vite build -> dist/
npm test # vitest (watch mode)
npm run test:run # vitest run (CI)
Dependencies
Development dependencies
| ID | Version |
|---|---|
| @testing-library/dom | ^10.0.0 |
| @testing-library/jest-dom | ^7.0.1 |
| @testing-library/react | ^16.0.0 |
| @testing-library/user-event | ^14.6.6 |
| @types/node | ^26.2.0 |
| @types/react | ^19.2.18 |
| @types/react-dom | ^19.2.5 |
| @vitejs/plugin-react | ^5.2.0 |
| jsdom | ^24.0.0 |
| react | ^19.2.8 |
| react-dom | ^19.2.8 |
| typescript | ~5.9.3 |
| vite | ^8.2.2 |
| vite-plugin-dts | ^5.1.0 |
| vitest | ^4.1.11 |
Peer dependencies
| ID | Version |
|---|---|
| react | ^19.0.0 |
| react-dom | ^19.0.0 |