Lucentive Labs
Lucentive Labs Docs
Packages / API

@lucentive-labs/loupe-core

The zero-dependency headless core — the store, the derivations, the crop math, the theme tokens, and the a11y wiring.

The brain. Every interesting behavior in Loupe is a pure function here, and every renderer is a thin view that calls these functions. Core has no DOM and no framework dependency, so the same logic powers the vanilla renderer, the React adapter, and the static artifact identically.

import {
  createLoupeStore,
  recommendedSelections,
  selectedOption,
  selectProgress,
  selectComposedPreview,
  selectExportBrief,
  cropToCss,
  connect,
  resolveKeydown,
  tokensToCssVars,
  tokensToCssText,
  DEFAULT_TOKENS,
  escapeHtml,
  safeUrl,
} from "@lucentive-labs/loupe-core";

The store

State is single-select per group, held in a tiny observable store with an SSR-safe snapshot pair.

createLoupeStore(config, opts?): LoupeStore

interface CreateStoreOptions {
  storage?: StoragePort;   // persist selections (e.g. localStorageAdapter)
  initial?: Selections;    // initial picks (overrides storage + recommended)
}

interface LoupeStore {
  readonly config: Config;
  getSnapshot(): Selections;        // stable reference; changes only on change
  getServerSnapshot(): Selections;  // deterministic (recommended) for SSR
  subscribe(listener: () => void): () => void;
  lock(groupId: string, optionId: string): void;
  clear(groupId: string): void;     // group → open
  reset(): void;                    // → recommended selections
  clearAll(): void;                 // → blank
}

Selections is Record<string, string | undefined> — group id → locked option id (or undefined for open). getSnapshot returns a frozen, stable reference that only changes when state changes, which is exactly what useSyncExternalStore wants.

const store = createLoupeStore(config);
const off = store.subscribe(() => render(store.getSnapshot()));
store.lock("color", "ink");   // pick
store.clear("color");          // back to open
off();                          // unsubscribe

localStorageAdapter(key): StoragePort

An SSR-safe StoragePort over localStorage that no-ops when storage is unavailable (private mode, server). Pass it as storage to persist picks.

const store = createLoupeStore(config, { storage: localStorageAdapter("my-lock") });

Derivations

Pure, deterministic functions over (config, selections). These are the single source for what the UI shows and what the brief contains.

recommendedSelections(config): Selections

The recommended pick per group (the recommended option, else undefined). Used as the SSR/server snapshot and by Reset → Recommended.

selectedOption(group, selections): TOption | null

The locked option for a group, or null if open.

selectProgress(config, selections): { locked, total }

How many groups are locked, out of how many — what the progress pill shows.

selectComposedPreview(config, selections): PreviewModel

Builds the composed-preview model: a headline (string or null) plus a bands[] array (each { slot, as, group, option }). Respects config.preview if present, otherwise falls back to one band per group in config order. This is the composed preview.

selectExportBrief(config, selections): ExportBrief

The deterministic handoff, in both forms:

interface ExportBrief {
  markdown: string;                  // title · locked decisions · banned · workflow
  json: Record<string, unknown>;     // { version, title, decisions[], banned[] }
}

Config order is preserved; there are no timestamps and no absolute paths. The same inputs always produce byte-identical output.

const sel = recommendedSelections(config);
const { markdown, json } = selectExportBrief(config, sel);

Crop math

cropToCss(rect, intrinsic, tileAspect): CropCss

Maps a normalized crop rect onto a fixed-aspect tile, returning the exact { widthPct, leftPct, topPct } for a covering <img>. This is the math documented in full on the crop model.

const css = cropToCss({ x: 0, y: 0, w: 0.34, h: 1 }, { width: 1600, height: 1067 }, 4 / 3);
// → { widthPct, leftPct, topPct } — inline styles for the <img>

Theming

tokensToCssVars(tokens?): Record<string, string>

Merges your tokens over DEFAULT_TOKENS, prefixes each key with --loupe-, and sorts them. Returns a { "--loupe-color-primary": "…" } object — exactly what the React adapter spreads as inline style vars.

tokensToCssText(tokens?, selector = ":root"): string

The same, rendered as an inline-ready CSS string (:root { --loupe-…: …; }). The generator uses it to inline the theme into the artifact.

DEFAULT_TOKENS: TThemeTokens

The full default token map (calm light theme). See the table in Theming.

Accessibility wiring

Core also owns the a11y semantics, so every renderer is keyboard-correct for free.

connect(): Connect

Returns prop-getters that emit the right ARIA + data-* attributes:

interface Connect {
  getRootProps(): Props;
  getGroupProps(group): Props;                          // role="radiogroup"
  getTileProps(group, option, selections): Props;       // role="radio" + roving tabindex
}

resolveKeydown(group, focusedId, key): { lock, focus } | null

Resolves an APG radio keystroke into an action — arrows move and select, Space/Enter select the focused tile, Home/End jump. Returns null for keys it does not handle. Renderers apply focus themselves.

rovingId(group, selections): string

The option that owns tabindex=0 in a group (the checked one, else the first).

Security

All renderers route author/agent text and URLs through these, so a config can never inject markup or scripts.

escapeHtml(s): string

Escapes &, <, >, ", ' before any text is injected into HTML.

safeUrl(src): string

Allows only http(s) and relative/asset URLs; returns "" for javascript: and data: URLs.

motion presets and layoutMock plans are fixed enums, so a config selects a feel/structure but never supplies raw CSS. layoutMock wireframe markup is author-trusted; everything author-supplied (labels, captions, samples, image src) is escaped or allowlisted.