@avophile/clarrel-shared-react (0.4.2)

Published 2026-09-26 17:30:21 +00:00 by alexander

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 real markPhaseReady(phase) signal before committing to it, exposing isWaitingOnReadiness while the real backend stage is still running past its normal hold and isStalled if it never reports in within maxWaitMs; built for a genuinely incremental backend like Commons's /chat SSE stream that still wants search's own smooth pacing). As of avophile/clarrel-commons#262, generating -> done is ALSO gated under this mode (call markPhaseReady('done') once your own stream's real completion event arrives, instead of hand-rolling a ref/closure to decide when it's safe to call skip() — 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 trip isStalled just because its TOTAL duration exceeds maxWaitMs. Unaffected in plain timer/external mode: generating -> done there is still driven purely by skip() (TypewriterText's completion callback), exactly as before.
  • SearchStageSpinner / RerankStageSpinner / WinnowStageSpinner / GenerationStageSpinner (+ bare StageSpinnerIcon) — 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 off useSearchRevealPipeline's own phase. The real, shared equivalent of clarrel-commons's chat.html fallback's own hand-written #stage-tracker — built once here instead of redone per consumer. Pass as many or as few steps as your pipeline actually walks through (a plain-search consumer that never reaches generating omits that step entirely); layers isWaitingOnReadiness/isStalled from readinessGate mode onto the active step when provided. Generic steps mode (avophile/clarrel#1205): pass activeKey (and optionally complete) instead of phase, with steps keyed by any string, for a process that is not the search pipeline — e.g. an upload's Uploading → Reading pages → Indexing → Done. Steps before activeKey are 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 optional detail renders inside it, for a measured count ("64 of 130 chunks"). Optional statusLabels ({ 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 are aria-hidden and only the active step carries aria-current. Omitted, the DOM is unchanged.
  • SynthesisReveal (avophile/clarrel-commons#262) — pairs a synthesis pipeline's narrative response with its tiles grid, structurally guaranteeing narrative renders 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-gap spacing token between them so the gap always matches ResultTileGrid's own row-to-row gap. Presentation-only, no RevealPhase dependency: pass null/undefined for 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 — shared prefers-reduced-motion reader.
  • 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 each StageStepper step, 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-info to 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 at winnowed (clarrel#984).
  • COMMONS_PALETTE / commonsTheme / COMMONS_THEME_CLASS / COMMONS_SHAPE / COMMONS_TYPE — the Commons design tokens for code. commonsTheme values are var(--token) references for inline styles; COMMONS_PALETTE holds 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
Details
npm
2026-09-26 17:30:21 +00:00
2
MIT
latest
89 KiB
Assets (1)
Versions (1) View all
0.4.2 2026-09-26