// ── Global client store — a single Alpine store named "flow" ────────────────────
//
// App-wide, client-only UI state the SERVER never needs to see: theme, sidebar open/closed,
// a command-palette's visibility, an unsaved-changes flag. It IS an Alpine store — Alpine
// already gives us a global, reactive, dependency-tracked store, so we don't reinvent it.
// Everything lives under one store, `flow`, and `$flow.$store` returns it:
//
//
//
…
// {$flow.store.cart.count} items
//
// Reads compile to Alpine bindings (x-text / :class / x-show) and re-render with no round-trip;
// writes mutate the reactive store directly. Declare the shape with defineStore().
//
// Division of labour:
// • $flow.store — live cross-component UI state the server never sees.
// • @session — a preference that must survive a refresh (persisted server-side).
// • @expose — state one component owns and the server may reconcile.
//
// Dependency-free (no @zerotal/core import) so it's safe in the browser runtime bundle.
/**
* The shape of the global client store (`$flow.store`). Declare your namespaces by augmenting
* this interface, which makes every `$flow.store.*` access fully typed:
*
* ```ts
* // resources/js/env.d.ts — augment the /store subpath, where the interface is declared.
* declare module "@zerotal/flow/store" {
* interface FlowStore {
* ui: { dark: boolean; sidebar: boolean };
* cart: { count: number };
* }
* }
* ```
*/
export interface FlowStore {}
/** The one Alpine store name that backs `$flow.store`. */
const STORE_NAME = "flow";
/** The slice of Alpine we use (register/read a named store). */
interface AlpineStores {
store(name: string): Record | undefined;
store(name: string, value: Record): void;
}
type GlobalWithAlpine = typeof globalThis & {
Alpine?: AlpineStores;
__flowStoreInit?: Array>;
};
function _g(): GlobalWithAlpine {
return globalThis as GlobalWithAlpine;
}
/** Recursively fill defaults into `target` WITHOUT overwriting values already present. */
function _mergeDefaults(target: Record, src: Record): void {
for (const [k, v] of Object.entries(src)) {
const cur = target[k];
if (
v &&
typeof v === "object" &&
!Array.isArray(v) &&
cur &&
typeof cur === "object" &&
!Array.isArray(cur)
) {
_mergeDefaults(cur as Record, v as Record);
} else if (!(k in target)) {
target[k] = v;
}
}
}
/** Merge `initial` (defaults only) into the reactive "flow" Alpine store, registering it if new. */
function _seed(A: AlpineStores, initial: Record): void {
if (!A.store(STORE_NAME)) A.store(STORE_NAME, {});
_mergeDefaults(A.store(STORE_NAME)!, initial); // mutating the reactive store keeps bindings live
}
/**
* Declare (or extend) the client store's initial shape — call once at app start:
*
* ```ts
* // resources/js/app.ts — import from the browser-safe /store subpath, not the package barrel.
* import { defineStore } from "@zerotal/flow/store";
* defineStore({ ui: { dark: false, sidebar: true }, cart: { count: 0 } });
* ```
*
* Existing values are preserved (defaults only). Safe to call before OR after the Flow runtime
* boots — a call made before Alpine exists is queued and applied when the store is registered.
*/
export function defineStore(initial: Record): void {
const g = _g();
if (g.Alpine?.store) _seed(g.Alpine, initial);
else (g.__flowStoreInit ??= []).push(initial);
}
/**
* @internal Register the "flow" Alpine store and apply any defineStore() calls queued before
* Alpine existed. Called once by the client entry, before `Alpine.start()`. Idempotent.
*/
export function initClientStore(alpine: AlpineStores): Record {
if (!alpine.store(STORE_NAME)) alpine.store(STORE_NAME, {});
const g = _g();
for (const partial of g.__flowStoreInit ?? []) _seed(alpine, partial);
g.__flowStoreInit = [];
return alpine.store(STORE_NAME)!;
}
/** @internal The reactive "flow" store object (Alpine-owned), or `{}` before Alpine is ready. */
export function clientStore(): Record {
const A = _g().Alpine;
return (A?.store && A.store(STORE_NAME)) || {};
}