@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(); // unsubscribelocalStorageAdapter(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.