@mosetta/react 0.0.0-stage → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ import { SingleDiToken } from '@mosetta/di';
2
+ import { PARENT_DI_TOKEN } from '@mosetta/runtime/client';
3
+ import { useDi } from './di';
4
+
5
+ // A settings form may offer "upload a picture" or "upload a video" — but only when the HOST offers
6
+ // a way to store one. The workbench registers an uploader under this token in its own container;
7
+ // a form reads it through the parent chain (like the settings draft). No host, no uploader → the
8
+ // form shows the URL field alone. The contract is a function: a File in (with the progress of a
9
+ // long upload reported as it goes), its public URL out.
10
+ export interface IMediaUpload {
11
+ upload(file: File, onProgress?: (fraction: number) => void): Promise<string>;
12
+ }
13
+
14
+ export const MEDIA_UPLOAD_TOKEN = new SingleDiToken<IMediaUpload>('MEDIA_UPLOAD_TOKEN');
15
+
16
+ /** the host's uploader, or null when the form is shown by a host that has none */
17
+ export function useMediaUpload(): IMediaUpload | null {
18
+ const parent = useDi(PARENT_DI_TOKEN);
19
+ return parent?.hasByToken(MEDIA_UPLOAD_TOKEN) ? parent.getByToken(MEDIA_UPLOAD_TOKEN) : null;
20
+ }
@@ -0,0 +1,34 @@
1
+ import { useCallback, useSyncExternalStore } from 'react';
2
+ import { useDi } from './di';
3
+ import { PAGE_MANAGER_TOKEN, PageManager } from '@mosetta/runtime/client';
4
+ import type { IBlueprint } from '@mosetta/core';
5
+
6
+ // the manager itself is framework-agnostic and lives in @mosetta/runtime;
7
+ // this entry keeps only the react-facing hooks
8
+ //
9
+ // SUBSCRIBED, not read once: the page is swappable (see PageManager — live composition in the
10
+ // editor's preview), and a reader that only sampled it would keep rendering the tree the
11
+ // application was born with. On the server the same snapshot answers both passes, so a server
12
+ // render is unaffected.
13
+ export function usePageOf(manager: PageManager): IBlueprint {
14
+ const snapshot = useCallback(() => manager.getCurrentPage(), [manager]);
15
+ return useSyncExternalStore(manager.subscribe, snapshot, snapshot);
16
+ }
17
+
18
+ export function usePage(): IBlueprint {
19
+ return usePageOf(useDi(PAGE_MANAGER_TOKEN));
20
+ }
21
+
22
+ /**
23
+ * One value out of the page, for readers that need a flag rather than the tree. Subscribing to the
24
+ * whole page to read one field means re-rendering on every live update — including the ones that
25
+ * changed a node somewhere else entirely.
26
+ *
27
+ * `select` must be a stable function (module scope), and must return something comparable by
28
+ * Object.is: a primitive, or a value the page itself holds.
29
+ */
30
+ export function usePageValue<TValue>(select: (page: IBlueprint) => TValue): TValue {
31
+ const manager = useDi(PAGE_MANAGER_TOKEN);
32
+ const snapshot = useCallback(() => select(manager.getCurrentPage()), [manager, select]);
33
+ return useSyncExternalStore(manager.subscribe, snapshot, snapshot);
34
+ }
@@ -0,0 +1,31 @@
1
+ // Previewness is a CONTAINER fact, not a react context: PREVIEW_TYPE_TOKEN is computed once per
2
+ // application in createAppContainer — from the typed mount option (IMountOptions.preview — the
3
+ // palette's bubble mounts a specimen) or from the realm mark the preview shell writes before
4
+ // bootstrap (the canvas iframe). These are this binding's readers over it.
5
+ //
6
+ // `useIsPreviewMode()` answers "should I behave like a preview". A branch on it is a move of
7
+ // necessity — substituting DATA a live backend would supply — never the default: a preview is
8
+ // the REAL component in an arranged environment (IEditorSide.previewServices), because the
9
+ // same mechanism will carry the storybook.
10
+ //
11
+ // `usePreviewType()` answers "what KIND" — for the rare module whose duty depends on the realm
12
+ // itself: the preview bridge WORKS inside a full-app preview and is only a description card in
13
+ // a palette specimen.
14
+ import { DI_CONTAINER_TOKEN } from '@mosetta/di';
15
+ import { useDi } from './di';
16
+ import { PREVIEW_TYPE_TOKEN } from '@mosetta/runtime/client';
17
+ import type { TPreviewType } from '@mosetta/runtime/client';
18
+
19
+ export { FULL_APP_PREVIEW, PALETTE_PREVIEW } from '@mosetta/runtime/client';
20
+ export type { IMountPreview, TPreviewType } from '@mosetta/runtime/client';
21
+
22
+ export function usePreviewType(): TPreviewType | null {
23
+ // through the container token, not useDi(PREVIEW_TYPE_TOKEN): a container assembled by hand
24
+ // (a harness, a custom root) carries no registration, and that means "live", not a crash
25
+ const di = useDi(DI_CONTAINER_TOKEN);
26
+ return di.hasByToken(PREVIEW_TYPE_TOKEN) ? di.getByToken(PREVIEW_TYPE_TOKEN) : null;
27
+ }
28
+
29
+ export function useIsPreviewMode(): boolean {
30
+ return usePreviewType() !== null;
31
+ }
@@ -0,0 +1,104 @@
1
+ import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
2
+ import { DI_CONTAINER_TOKEN } from '@mosetta/di';
3
+ import {
4
+ findRefProvider,
5
+ isRefValue,
6
+ TILES_HOLDER_TOKEN,
7
+ PAGE_MANAGER_TOKEN,
8
+ parseRef,
9
+ REF_VIEWS_TOKEN,
10
+ resolveRefs,
11
+ } from '@mosetta/runtime/client';
12
+ import type { ITilesHolder, IRefProviderDeclaration, IRefTreeNode, IRefValue } from '@mosetta/runtime/client';
13
+ import type { IRouteMatch } from '@mosetta/core';
14
+ import { useDi } from './di';
15
+
16
+ const NO_VIEWS: Readonly<Record<string, unknown>> = Object.freeze({});
17
+ const NO_SUBSCRIPTION = () => () => {};
18
+
19
+ /** what a loaded component module declares it provides; nothing for a module not loaded (yet) */
20
+ export function declarationOf(holder: ITilesHolder | null, reference: string): IRefProviderDeclaration | null {
21
+ if (!holder) return null;
22
+ try {
23
+ const loaded = holder.getModule(reference) as { module?: { provides?: IRefProviderDeclaration } } | undefined;
24
+ return loaded?.module?.provides ?? null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** one reference read against the tree: the provider found lexically, its adapter asked */
31
+ export function readRef(
32
+ page: IRefTreeNode,
33
+ consumerId: string,
34
+ ref: IRefValue,
35
+ holder: ITilesHolder | null,
36
+ views: Readonly<Record<string, unknown>>,
37
+ route: IRouteMatch | null,
38
+ ): unknown {
39
+ const parsed = parseRef(ref.$ref);
40
+ if (!parsed) return undefined;
41
+ const found = findRefProvider(page, consumerId, parsed.name, (reference) => declarationOf(holder, reference));
42
+ if (!found) return undefined;
43
+ try {
44
+ return found.provides.refs.read(found.node.settings ?? {}, parsed.key, { view: views[parsed.name] ?? null, route });
45
+ } catch {
46
+ return undefined; // a provider's bug must not take the consumer down with it
47
+ }
48
+ }
49
+
50
+ interface IResolved {
51
+ readonly raw: Record<string, unknown>;
52
+ readonly page: unknown;
53
+ readonly views: unknown;
54
+ readonly values: readonly unknown[];
55
+ readonly result: Record<string, unknown>;
56
+ }
57
+
58
+ /**
59
+ * A node's settings with every reference replaced by the value its provider hands out.
60
+ * SUBSCRIBED to the page and to the editor's views: the provider is another node, and editing
61
+ * it (a translation typed in the editor) must reach this consumer, whose own node did not
62
+ * change. Settings without references come back untouched, with no subscription at all.
63
+ */
64
+ export function useResolvedSettings(nodeId: string, raw: Record<string, unknown>): Record<string, unknown> {
65
+ const di = useDi(DI_CONTAINER_TOKEN);
66
+ const hasRefs = useMemo(() => Object.values(raw).some(isRefValue), [raw]);
67
+ const manager = di.hasByToken(PAGE_MANAGER_TOKEN) ? di.getByToken(PAGE_MANAGER_TOKEN) : null;
68
+ const views = di.hasByToken(REF_VIEWS_TOKEN) ? di.getByToken(REF_VIEWS_TOKEN) : null;
69
+ const holder = di.hasByToken(TILES_HOLDER_TOKEN) ? di.getByToken(TILES_HOLDER_TOKEN) : null;
70
+ const cache = useRef<IResolved | null>(null);
71
+
72
+ const subscribe = useCallback((listener: () => void) => {
73
+ const offPage = manager?.subscribe(listener);
74
+ const offViews = views?.subscribe(listener);
75
+ return () => {
76
+ offPage?.();
77
+ offViews?.();
78
+ };
79
+ }, [manager, views]);
80
+
81
+ const snapshot = useCallback((): Record<string, unknown> => {
82
+ if (!hasRefs || !manager) return raw;
83
+ const page = manager.getCurrentPage();
84
+ const viewSnapshot = views?.snapshot() ?? NO_VIEWS;
85
+ const last = cache.current;
86
+ if (last && last.raw === raw && last.page === page && last.views === viewSnapshot) return last.result;
87
+ const route = (page as { route?: IRouteMatch }).route ?? null;
88
+ const values: unknown[] = [];
89
+ const result = resolveRefs(raw, (ref) => {
90
+ const value = readRef(page, nodeId, ref, holder, viewSnapshot, route);
91
+ values.push(value);
92
+ return value;
93
+ });
94
+ // an unrelated edit elsewhere on the page must not hand this consumer a new object
95
+ if (last && last.raw === raw && last.values.length === values.length && last.values.every((v, i) => Object.is(v, values[i]))) {
96
+ cache.current = { ...last, page, views: viewSnapshot };
97
+ return last.result;
98
+ }
99
+ cache.current = { raw, page, views: viewSnapshot, values, result };
100
+ return result;
101
+ }, [hasRefs, manager, views, holder, raw, nodeId]);
102
+
103
+ return useSyncExternalStore(hasRefs ? subscribe : NO_SUBSCRIPTION, snapshot, snapshot);
104
+ }
@@ -0,0 +1,19 @@
1
+ import type { IRouteMatch } from '@mosetta/core';
2
+ import { usePageValue } from './usePage';
3
+
4
+ const selectRoute = (page: { route?: IRouteMatch }): IRouteMatch | null => page.route ?? null;
5
+
6
+ /**
7
+ * What the request URL said to the page's route pattern: `{slug}` segments, a `{...rest}` array,
8
+ * captured query values. Null in a realm the server did not stamp (a bare static build).
9
+ */
10
+ export function useRoute(): IRouteMatch | null {
11
+ return usePageValue(selectRoute);
12
+ }
13
+
14
+ /** one named parameter, or null; a rest parameter joins its segments with `/` */
15
+ export function useRouteParam(name: string): string | null {
16
+ const route = useRoute();
17
+ const value = route?.params[name];
18
+ return value === undefined ? null : Array.isArray(value) ? value.join('/') : value;
19
+ }
@@ -0,0 +1,38 @@
1
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
2
+ import { ROUTER_TOKEN } from '@mosetta/runtime/client';
3
+ import type { INavigateOptions, IRouter, IRouterSnapshot } from '@mosetta/runtime/client';
4
+ import { useDi } from './di';
5
+
6
+ export interface IRouterHandle {
7
+ /** moves happen in place here (false on the server, in a preview, in a hosted mount) */
8
+ readonly active: boolean;
9
+ /** a move is in flight */
10
+ readonly pending: boolean;
11
+ /** the address the router last moved to */
12
+ readonly href: string;
13
+ /** move to an address of the site; false when the router did not take it (the browser should) */
14
+ navigate(href: string, options?: INavigateOptions): Promise<boolean>;
15
+ /** the same, replacing the current history entry */
16
+ replace(href: string, options?: Omit<INavigateOptions, 'replace'>): Promise<boolean>;
17
+ back(): void;
18
+ }
19
+
20
+ /**
21
+ * The client router (ADR-0051), the way Next hands one out: `navigate` moves to another page of
22
+ * the site in place, `pending` says a move is in flight. Where moves do not happen in place —
23
+ * the server, a preview realm, a hosted mount — `active` is false and `navigate` answers false:
24
+ * a block that wants to go somewhere then lets the browser (or `useNavigate`, which loads whole).
25
+ */
26
+ export function useRouter(): IRouterHandle {
27
+ const router: IRouter = useDi(ROUTER_TOKEN);
28
+ const snapshot = useCallback((): IRouterSnapshot => router.snapshot(), [router]);
29
+ const { pending, href } = useSyncExternalStore(router.subscribe, snapshot, snapshot);
30
+ return useMemo(() => ({
31
+ active: router.active,
32
+ pending,
33
+ href,
34
+ navigate: (target: string, options?: INavigateOptions) => router.navigate(target, options),
35
+ replace: (target: string, options?: Omit<INavigateOptions, 'replace'>) => router.navigate(target, { ...options, replace: true }),
36
+ back: () => router.back(),
37
+ }), [router, pending, href]);
38
+ }
@@ -0,0 +1,110 @@
1
+ import { useCallback, useSyncExternalStore } from 'react';
2
+ import { SingleDiToken } from '@mosetta/di';
3
+ import { PARENT_DI_TOKEN } from '@mosetta/runtime/client';
4
+ import { useDi } from './di';
5
+
6
+ // The `.editor.tsx` facet contract: the HOST owns the draft of the edited node's settings, the
7
+ // facet form reads it and mutates through `update(patch)` (shallow merge). Save and validation
8
+ // stay host-side — a facet never talks to the content API itself.
9
+ //
10
+ // The draft crosses the mount boundary through the PARENT container (IMountOptions.parentDi →
11
+ // PARENT_DI_TOKEN): the workbench registers the store under this token in ITS OWN container, and
12
+ // the form reads it through the door. Tokens are string-keyed, so a form built against another
13
+ // copy of the packages — or another binding entirely — resolves the same name. What follows is
14
+ // this binding's reader; another binding writes its own.
15
+ export const SETTINGS_DRAFT_TOKEN = new SingleDiToken<ISettingsDraftContract>('SETTINGS_DRAFT_TOKEN');
16
+
17
+ /** how one setting stands against references (see useSettingsBinding) */
18
+ export interface IFieldBinding {
19
+ /** the provider this field reads from; null while it holds a literal */
20
+ readonly provider: string | null;
21
+ /** the providers this node can reach — what the field could be bound to */
22
+ readonly providers: readonly string[];
23
+ }
24
+
25
+ /**
26
+ * The draft as the HOST provides it: a store, in the shape everything else in this codebase uses.
27
+ * The snapshot carries settings with references RESOLVED, and `update` of a field bound to a
28
+ * provider writes through to the provider — a form edits values, never references. The optional
29
+ * members are the reference-aware part; a host that knows no references leaves them out.
30
+ */
31
+ /** a key a provider's scope references, and what reads it */
32
+ export interface IScopeEntry {
33
+ readonly key: string;
34
+ /** the block and its setting: `heading · text` */
35
+ readonly role: string;
36
+ /** the nodes that read it — where to point the author (a host that does not tell leaves it out) */
37
+ readonly nodes?: readonly string[];
38
+ }
39
+
40
+ /** a page's words about itself, in one language */
41
+ export interface IPageMetaWordsShape {
42
+ readonly title?: string;
43
+ readonly description?: string;
44
+ }
45
+
46
+ /** what a provider's form may read of the page's meta */
47
+ export interface IPageMetaShape extends IPageMetaWordsShape {
48
+ readonly i18n?: Readonly<Record<string, IPageMetaWordsShape>>;
49
+ }
50
+
51
+ export interface ISettingsDraftContract<TSettings = Record<string, unknown>> {
52
+ snapshot(): TSettings;
53
+ subscribe(listener: () => void): () => void;
54
+ update(patch: Partial<TSettings>): void;
55
+ binding?(field: string): IFieldBinding;
56
+ /**
57
+ * The field becomes a reference to `provider`: to `key` when given (a value picked from what the
58
+ * provider offers), otherwise to a fresh key holding the field's current value.
59
+ */
60
+ bind?(field: string, provider: string, key?: string): void;
61
+ /** the field becomes a literal again, holding the value it showed in the original */
62
+ unbind?(field: string): void;
63
+ /**
64
+ * For a PROVIDER's own form: every literal a block in its scope declares `bindable` becomes a
65
+ * reference to it. Resolves with how many fields were bound.
66
+ */
67
+ bindScope?(): Promise<number>;
68
+ /**
69
+ * For a PROVIDER's own form: the keys of its values that some block in its scope references.
70
+ * `providerId` names another provider node than the one the draft is about — a provider's
71
+ * chrome in the canvas, which lives beside whatever is selected: the node of the page in the
72
+ * canvas being worked in, wherever the last pick happened.
73
+ */
74
+ usedKeys?(providerId?: string): readonly string[];
75
+ /**
76
+ * For a PROVIDER's own form: the keys its scope references, in the page's order, each with what
77
+ * reads it (`heading · text`) — what a translator is told about each string
78
+ */
79
+ outline?(providerId?: string): readonly IScopeEntry[];
80
+ /** a node pointed at from a form (a list of what reads a value): the editor lights it up; null — none */
81
+ hover?(nodeId: string | null): void;
82
+ /**
83
+ * The meta of the page being edited — null when the document is no page (a prefab). With
84
+ * `providerId` (a provider's chrome asking): the page in the canvas being worked in.
85
+ */
86
+ pageMeta?(providerId?: string): IPageMetaShape | null;
87
+ /** the page's title and description in other languages, by language code — one undoable step */
88
+ setPageMetaWords?(byLang: Readonly<Record<string, IPageMetaWordsShape>>): void;
89
+ /** the id of the node the draft is about right now — a long operation checks it did not move */
90
+ node?(): string | null;
91
+ }
92
+
93
+ /** the draft as a FORM consumes it — unchanged, so no facet had to be touched */
94
+ export interface ISettingsDraft<TSettings = Record<string, unknown>> {
95
+ readonly draft: TSettings;
96
+ update(patch: Partial<TSettings>): void;
97
+ }
98
+
99
+ export function useSettingsDraft<TSettings>(): ISettingsDraft<TSettings> {
100
+ const parent = useDi(PARENT_DI_TOKEN);
101
+ const contract = parent?.hasByToken(SETTINGS_DRAFT_TOKEN)
102
+ ? (parent.getByToken(SETTINGS_DRAFT_TOKEN) as ISettingsDraftContract<TSettings>)
103
+ : undefined;
104
+ if (!contract) {
105
+ throw new Error('useSettingsDraft: no parent application provides a settings draft (an editor facet is mounted BY a workbench, which registers the draft in its own container)');
106
+ }
107
+ const snapshot = useCallback(() => contract.snapshot(), [contract]);
108
+ const draft = useSyncExternalStore(contract.subscribe, snapshot, snapshot);
109
+ return { draft, update: contract.update };
110
+ }
@@ -0,0 +1,27 @@
1
+ import { DI_CONTAINER_TOKEN } from '@mosetta/di';
2
+ import { SSR_RENDER_CHANNEL_TOKEN } from '@mosetta/runtime/client';
3
+ import { useDi } from './di';
4
+ import { useNode, useRawSettings } from './NodeContext';
5
+
6
+ /**
7
+ * Declares what of THIS node's settings ships to the browser in the page envelope. The server
8
+ * renders with the full settings; the envelope is serialized after the body settled, with
9
+ * `transform(settings)` in this node's place. A block that holds data for every visitor but shows
10
+ * each one a slice of it (translations: every language stored, one language read) ships only
11
+ * the slice.
12
+ *
13
+ * The one rule: the result must keep everything the render READ. The browser hydrates from the
14
+ * envelope — a setting the render used and the transform dropped renders differently there.
15
+ *
16
+ * A declaration, not an effect: a server render runs a component again while actions resolve,
17
+ * and the last declaration wins. In the browser, and on a node the server did not render, it
18
+ * does nothing — the envelope then carries the settings as they are.
19
+ */
20
+ export function useShippedSettings<TSettings>(transform: (settings: TSettings) => TSettings): void {
21
+ const di = useDi(DI_CONTAINER_TOKEN);
22
+ const { id } = useNode();
23
+ const settings = useRawSettings<TSettings>();
24
+ const channel = di.hasByToken(SSR_RENDER_CHANNEL_TOKEN) ? di.getByToken(SSR_RENDER_CHANNEL_TOKEN) : null;
25
+ if (!channel?.shipSettings) return;
26
+ channel.shipSettings(id, transform(settings) as Record<string, unknown>);
27
+ }
@@ -0,0 +1,34 @@
1
+ import { useRef } from 'react';
2
+ import { STYLE_ATTRIBUTE, STYLES_SETTING, stylePartKey, stylerCss } from '@mosetta/core';
3
+ import { useNode, useRawSettings } from './NodeContext';
4
+ import { useAddToHead } from './useAddToHead';
5
+
6
+ // The runtime half of the STYLER (the vocabulary lives in @mosetta/core): a block calls it once
7
+ // and spreads `part('<name>')` on each element it lets users restyle. The node's overrides
8
+ // (`settings.styles`) become one stylesheet in the head — written during render, so the server
9
+ // ships it with the page, and rewritten when the settings change (a live edit in the editor).
10
+ //
11
+ // const styler = useStyler();
12
+ // return <a {...styler.part('root')} className="ui-button">{label}</a>;
13
+
14
+ export interface IStyler {
15
+ /** the attribute that makes an element the named part of this block */
16
+ part(name: string): Record<string, string>;
17
+ }
18
+
19
+ const headKey = (nodeId: string): string => `styler:${nodeId}`;
20
+
21
+ export function useStyler(): IStyler {
22
+ const { id } = useNode();
23
+ const settings = useRawSettings<Record<string, unknown>>();
24
+ const addToHead = useAddToHead();
25
+ const css = stylerCss(id, settings?.[STYLES_SETTING]);
26
+ // once written, a stylesheet is kept current — cleared to empty, not left stale, when the
27
+ // styles go; a node that never had any writes nothing
28
+ const wrote = useRef(false);
29
+ if (css || wrote.current) {
30
+ addToHead(headKey(id), 'style', { 'data-mosetta-styler': id }, css);
31
+ wrote.current = true;
32
+ }
33
+ return { part: (name) => ({ [STYLE_ATTRIBUTE]: stylePartKey(id, name) }) };
34
+ }
@@ -0,0 +1,189 @@
1
+ // THE THEME CONTROL: what a theme block hands to the blocks inside it through DI — the themes
2
+ // there are, the one that is on, and the way to switch. A theme switcher (or any block) picks one;
3
+ // the pick is kept in the reader's browser and put on the document root, where the theme's CSS
4
+ // reads it (see @mosetta/core theme.ts). Blocks outside any theme get null and offer nothing.
5
+ //
6
+ // const theme = useTheme();
7
+ // theme?.select('dark'); // kept for the next visit
8
+ // theme?.select(null); // back to the system's (or the default)
9
+ import { useCallback, useSyncExternalStore } from 'react';
10
+ import { SingleDiToken } from '@mosetta/di';
11
+ import { activeThemeId, themeChoiceAttribute, themeStorageName } from '@mosetta/core';
12
+ import type { IThemeSettings, TColorScheme } from '@mosetta/core';
13
+ import { useDiOptional } from './di';
14
+
15
+ export interface IThemeOption {
16
+ readonly id: string;
17
+ readonly label: string;
18
+ readonly scheme: TColorScheme;
19
+ }
20
+
21
+ export interface IThemeState {
22
+ readonly themes: readonly IThemeOption[];
23
+ /** the theme that is on */
24
+ readonly active: string;
25
+ /** the reader's pick; null — the theme is decided for them (the system's, or the default) */
26
+ readonly chosen: string | null;
27
+ readonly followSystem: boolean;
28
+ }
29
+
30
+ export interface IThemeControl {
31
+ snapshot(): IThemeState;
32
+ /** what the server rendered — what a block hydrates with */
33
+ serverSnapshot(): IThemeState;
34
+ subscribe(listener: () => void): () => void;
35
+ /** pick a theme, kept for the next visit; null — back to the system's, or the default */
36
+ select(id: string | null): void;
37
+ }
38
+
39
+ export const THEME_TOKEN = new SingleDiToken<IThemeControl>('THEME_TOKEN');
40
+
41
+ const DARK_QUERY = '(prefers-color-scheme: dark)';
42
+
43
+ /**
44
+ * The control a theme block owns. Until `start` it knows neither the reader's pick nor the system
45
+ * — exactly what a server render knows — so the first browser render agrees with the server's and
46
+ * hydration is clean; the head script has already put the right theme on screen meanwhile.
47
+ */
48
+ export class ThemeControl implements IThemeControl {
49
+ private chosen: string | null = null;
50
+
51
+ /** the theme the EDITOR shows this page in (its canvas control) — never kept, never the reader's */
52
+ private previewed: string | null = null;
53
+
54
+ private system: TColorScheme | null = null;
55
+
56
+ private view: Window | null = null;
57
+
58
+ private state: IThemeState;
59
+
60
+ private readonly listeners = new Set<() => void>();
61
+
62
+ private stopWatching: (() => void) | null = null;
63
+
64
+ /** what the server rendered: neither the pick nor the system known */
65
+ private serverState: IThemeState;
66
+
67
+ constructor(private settings: IThemeSettings) {
68
+ this.state = this.compute();
69
+ this.serverState = this.state;
70
+ }
71
+
72
+ readonly snapshot = (): IThemeState => this.state;
73
+
74
+ /**
75
+ * The state a block HYDRATES with — the server's, whatever the browser knows by then: a block
76
+ * hydrated late (its module loaded after the theme started) would otherwise render the pick over
77
+ * the server's markup, and react leaves mismatched attributes as the server wrote them.
78
+ */
79
+ readonly serverSnapshot = (): IThemeState => this.serverState;
80
+
81
+ readonly subscribe = (listener: () => void): (() => void) => {
82
+ this.listeners.add(listener);
83
+ return () => {
84
+ this.listeners.delete(listener);
85
+ };
86
+ };
87
+
88
+ /** the browser this block renders in: the reader's kept pick and the system's scheme */
89
+ start(view: Window): void {
90
+ // a second start (an effect run again) replaces the first one's watch
91
+ this.stopWatching?.();
92
+ this.view = view;
93
+ try {
94
+ this.chosen = view.localStorage.getItem(themeStorageName(this.settings.storageKey));
95
+ } catch {
96
+ this.chosen = null; // storage denied (a sandboxed frame, blocked cookies)
97
+ }
98
+ const media = view.matchMedia?.(DARK_QUERY);
99
+ if (media) {
100
+ this.system = media.matches ? 'dark' : 'light';
101
+ const onChange = (e: MediaQueryListEvent) => {
102
+ this.system = e.matches ? 'dark' : 'light';
103
+ this.refresh();
104
+ };
105
+ media.addEventListener('change', onChange);
106
+ this.stopWatching = () => media.removeEventListener('change', onChange);
107
+ }
108
+ this.refresh();
109
+ }
110
+
111
+ /** the theme block's settings changed (an author editing them) */
112
+ update(settings: IThemeSettings): void {
113
+ this.settings = settings;
114
+ this.serverState = { ...this.compute(), active: activeThemeId(settings, null, null), chosen: null };
115
+ this.refresh();
116
+ }
117
+
118
+ select(id: string | null): void {
119
+ const known = id !== null && this.settings.themes.some((t) => t.id === id) ? id : null;
120
+ this.chosen = known;
121
+ const view = this.view;
122
+ if (view) {
123
+ const name = themeStorageName(this.settings.storageKey);
124
+ const attribute = themeChoiceAttribute(this.settings.storageKey);
125
+ try {
126
+ if (known) view.localStorage.setItem(name, known);
127
+ else view.localStorage.removeItem(name);
128
+ } catch {
129
+ // not kept — the pick still holds for this visit
130
+ }
131
+ if (known) view.document.documentElement.setAttribute(attribute, known);
132
+ else view.document.documentElement.removeAttribute(attribute);
133
+ }
134
+ this.refresh();
135
+ }
136
+
137
+ /**
138
+ * Show the page in this theme for the editor's canvas, or stop (null): the document root gets
139
+ * the theme as if the reader had picked it, but nothing is kept — the reader's own pick, if
140
+ * any, comes back when the preview stops.
141
+ */
142
+ preview(id: string | null): void {
143
+ const known = id !== null && this.settings.themes.some((t) => t.id === id) ? id : null;
144
+ if (known === this.previewed) return;
145
+ this.previewed = known;
146
+ const view = this.view;
147
+ if (view) {
148
+ const attribute = themeChoiceAttribute(this.settings.storageKey);
149
+ const shown = known ?? this.chosen;
150
+ if (shown) view.document.documentElement.setAttribute(attribute, shown);
151
+ else view.document.documentElement.removeAttribute(attribute);
152
+ }
153
+ this.refresh();
154
+ }
155
+
156
+ /** stops watching the system; subscribers leave by their own unsubscribe */
157
+ dispose(): void {
158
+ this.stopWatching?.();
159
+ this.stopWatching = null;
160
+ }
161
+
162
+ private compute(): IThemeState {
163
+ const chosen = this.chosen && this.settings.themes.some((t) => t.id === this.chosen) ? this.chosen : null;
164
+ return {
165
+ themes: this.settings.themes.map(({ id, label, scheme }) => ({ id, label, scheme })),
166
+ active: this.previewed ?? activeThemeId(this.settings, chosen, this.system),
167
+ chosen,
168
+ followSystem: this.settings.followSystem,
169
+ };
170
+ }
171
+
172
+ private refresh(): void {
173
+ const next = this.compute();
174
+ if (JSON.stringify(next) === JSON.stringify(this.state)) return;
175
+ this.state = next;
176
+ for (const listener of [...this.listeners]) listener();
177
+ }
178
+ }
179
+
180
+ const NOTHING = () => () => {};
181
+ const NONE = () => null;
182
+
183
+ /** the theme around this block, and the way to switch it — null outside any theme */
184
+ export function useTheme(): (IThemeState & { select(id: string | null): void }) | null {
185
+ const control = useDiOptional(THEME_TOKEN);
186
+ const state = useSyncExternalStore(control?.subscribe ?? NOTHING, control?.snapshot ?? NONE, control?.serverSnapshot ?? NONE);
187
+ const select = useCallback((id: string | null) => control?.select(id), [control]);
188
+ return state ? { ...state, select } : null;
189
+ }
@@ -0,0 +1,34 @@
1
+ import { ILoadedTile, TILES_HOLDER_TOKEN } from '@mosetta/runtime/client';
2
+ import { useDi } from './di';
3
+
4
+
5
+ export function useTile<T>(reference: string): T {
6
+ // TODO: validate provider
7
+ const holder = useDi(TILES_HOLDER_TOKEN);
8
+ const moduleValue = holder.getModule(reference);
9
+
10
+ if (!moduleValue) {
11
+ // not loaded yet → if the holder can load on demand, kick it off and SUSPEND (throw the cached
12
+ // promise; the loader dedups so it's stable across re-renders). The nearest <Suspense> shows its
13
+ // fallback until it settles, then re-renders with the ILoadedTile. This is the lazy/dynamic path.
14
+ if (holder.loadModule) {
15
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense protocol: throw the promise
16
+ throw holder.loadModule(reference);
17
+ }
18
+ throw new Error(`Cannot find module [${reference}]`);
19
+ }
20
+
21
+ // A safely-loaded module carries its failure as data (load/exec error). Re-throw it so the
22
+ // nearest ComponentErrorHandler isolates this subtree and renders the fallback with the cause.
23
+ if (moduleValue.error) {
24
+ throw moduleValue.error;
25
+ }
26
+
27
+ const clientModule = moduleValue as ILoadedTile<T>;
28
+
29
+ if (!clientModule.module) {
30
+ throw new Error(`Cannot find module content for [${reference}]`);
31
+ }
32
+
33
+ return clientModule.module;
34
+ }