@elytracms/studio 0.0.74 → 0.0.76
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.
- package/convex/cms.ts +166 -5
- package/convex/schema.ts +21 -0
- package/dist/components/app-views/default-modules.js +4 -4
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +37 -2
- package/dist/components/cms/cms-store.js +121 -8
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +34 -17
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/column-width.d.ts +8 -0
- package/dist/components/cms/column-width.js +8 -0
- package/dist/components/cms/column-width.js.map +1 -1
- package/dist/components/cms/doc-state.d.ts +53 -0
- package/dist/components/cms/doc-state.js +58 -1
- package/dist/components/cms/doc-state.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +8 -4
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/project-list.js +6 -2
- package/dist/components/project-list.js.map +1 -1
- package/dist/components/studio-project-screen.js +4 -2
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +29 -11
- package/dist/lib/cms-views/view-model.js +43 -16
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +16 -5
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +4 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +2 -0
- package/dist/lib/i18n/messages.en.js +7 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +8 -0
- package/dist/lib/operations/cms-persistence.js +9 -1
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/operations/session-hydration.d.ts +22 -0
- package/dist/lib/operations/session-hydration.js +12 -0
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +14 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/router/index.d.ts +7 -0
- package/dist/router/index.js +12 -1
- package/dist/router/index.js.map +1 -1
- package/dist/router/navigate-once.d.ts +77 -0
- package/dist/router/navigate-once.js +104 -0
- package/dist/router/navigate-once.js.map +1 -0
- package/dist/router/root.js +7 -3
- package/dist/router/root.js.map +1 -1
- package/dist/ui/status-dot.d.ts +17 -6
- package/dist/ui/status-dot.js +9 -7
- package/dist/ui/status-dot.js.map +1 -1
- package/package.json +2 -2
package/dist/router/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/router/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EACL,IAAI,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/router/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EACL,IAAI;AACJ,6EAA6E;AAC7E,2EAA2E;AAC3E,uEAAuE;AACvE,yDAAyD;AACzD,QAAQ,EACR,UAAU,EACV,WAAW,EACX,WAAW,EACX,SAAS,EACT,SAAS,GACV,MAAM,wBAAwB,CAAA;AAG/B;;;;GAIG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAG9C;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,eAAwB,CAAA;AAE5D;;;;GAIG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAEpC;;;;GAIG;AACH,OAAO,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA","sourcesContent":["/**\n * Studio router adapter (EC-223 Stage 3 — the gating seam). The studio UI\n * navigates through these primitives instead of importing `@tanstack/react-router`\n * directly, so the engine owns its router CHOICE (TanStack Router) without being\n * hard-wired to a specific app: the consuming app provides the router INSTANCE +\n * the generated route tree (for Aurora, via TanStack Start). `@tanstack/react-router`\n * is a PEER dependency so there is exactly one instance — hence one router context\n * the re-exported hooks read.\n *\n * Only the runtime navigation primitives the studio components use are surfaced\n * here; route DEFINITION (`createFileRoute`/`createRouter`) stays in the consumer's\n * route files. Paths are addressed as plain strings (`to=\"/$projectId\"`), so the\n * components don't depend on the consumer's route-tree types.\n */\nexport {\n Link,\n // NOTE (EC-441): `Navigate` re-fires on every parent re-render (props object\n // identity). For the studio's own render-time redirects use `NavigateOnce`\n // below — one redirect intent, one navigation. The re-export stays for\n // callback-driven navigation surfaces and consumer code.\n Navigate,\n useBlocker,\n useLocation,\n useNavigate,\n useParams,\n useSearch,\n} from '@tanstack/react-router'\nexport type { LinkProps } from '@tanstack/react-router'\n\n/**\n * EC-441: value-keyed render-time redirect — fires once per intent, never once\n * per render, so a slow target transition (cold dev boot, slow chunks) can't\n * spiral into TanStack Router's \"Maximum update depth exceeded\" rejection.\n */\nexport { NavigateOnce } from './navigate-once'\nexport type { NavigateOnceIntent } from './navigate-once'\n\n/**\n * The studio's single in-project route (EC-223 Stage 6 — catch-all routing).\n * Every URL under a project is served by one splat, addressed by its `_splat`\n * subpath. By the route-table contract (`./match`), that subpath is the section\n * id for a top-level section (`'cms'`, `'settings'`, …) and the deep path for a\n * nested screen (`'cms/<collection>/<doc>'`). Navigate to a section with\n * `to={STUDIO_PROJECT_ROUTE} params={{ projectId, _splat }}` rather than a\n * per-section path that no longer exists in the consumer's route tree.\n */\nexport const STUDIO_PROJECT_ROUTE = '/$projectId/$' as const\n\n/**\n * Typed `_splat` builder for the studio splat — the inverse of the engine route\n * table (`./match`). Re-exported here so a component imports the link seam from\n * one place: `{ Link, STUDIO_PROJECT_ROUTE, studioPath }`.\n */\nexport { studioPath } from './match'\n\n/**\n * EC-401: history-state marker distinguishing an explicit \"all projects\"\n * navigation from a bare launcher hit, so a single-project workspace's launcher\n * auto-forward never swallows a deliberate visit to `/`.\n */\nexport { isExplicitLauncherVisit, launcherVisitState } from './launcher-visit'\n"]}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-time redirect that fires its navigation ONCE per redirect intent per
|
|
3
|
+
* committed location (EC-441). The studio's declarative redirects (launcher
|
|
4
|
+
* auto-forward, project index → landing view, auth-gate bounces, deep-link
|
|
5
|
+
* normalizations) must use THIS instead of TanStack Router's `<Navigate>`.
|
|
6
|
+
*
|
|
7
|
+
* Why: `<Navigate>` re-fires `navigate(props)` whenever its props OBJECT
|
|
8
|
+
* IDENTITY changes — and JSX builds a fresh props object on every parent
|
|
9
|
+
* render. While the redirect's target transition is still pending (slow route
|
|
10
|
+
* modules on a cold dev boot, slow chunks in production), the redirecting
|
|
11
|
+
* component stays mounted and re-renders on every router-store tick; each
|
|
12
|
+
* re-render re-navigates, each `navigate()` re-enters `commitLocation`/`load`
|
|
13
|
+
* and schedules another transition state update, and the cycle compounds until
|
|
14
|
+
* React throws "Maximum update depth exceeded" inside `commitLocation` — an
|
|
15
|
+
* unhandled rejection of the router's load promise (the EC-439 finding: 104
|
|
16
|
+
* launcher re-navigations on one cold playground boot).
|
|
17
|
+
*
|
|
18
|
+
* The decision core (`nextRedirectAttempt`) is pure and value-keyed:
|
|
19
|
+
*
|
|
20
|
+
* - already at the target → never fire (idempotence — covers the EC-179 keyed
|
|
21
|
+
* `AuthProvider` remount, which recreates the redirecting component with
|
|
22
|
+
* fresh refs while the router has already committed the target);
|
|
23
|
+
* - one fire per intent per committed location — re-renders and store ticks
|
|
24
|
+
* while the target transition is pending are inert;
|
|
25
|
+
* - a NEW committed location re-arms the attempt, so navigating back to a URL
|
|
26
|
+
* that redirects (e.g. the bare project index) redirects again.
|
|
27
|
+
*/
|
|
28
|
+
export interface NavigateOnceIntent {
|
|
29
|
+
/** Target route path, addressed as a plain string (the router-seam contract). */
|
|
30
|
+
to: string;
|
|
31
|
+
/** Route params (`{ projectId, _splat }` for the studio splat). */
|
|
32
|
+
params?: Record<string, string>;
|
|
33
|
+
/** Search params for the target location. */
|
|
34
|
+
search?: Record<string, unknown>;
|
|
35
|
+
/** Replace the current history entry (a redirect must not create one). */
|
|
36
|
+
replace?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Pure value identity of a redirect intent. Fresh-but-equal `params`/`search`
|
|
40
|
+
* objects (the upstream failure mode: new object literal every render) map to
|
|
41
|
+
* ONE key; top-level entries are order-normalized so key insertion order never
|
|
42
|
+
* splits an intent. Values are JSON-serialized — the studio's redirect targets
|
|
43
|
+
* are strings and flat search records, both deterministic under JSON.
|
|
44
|
+
*/
|
|
45
|
+
export declare function navigateIntentKey(intent: NavigateOnceIntent): string;
|
|
46
|
+
/**
|
|
47
|
+
* Href equality for "is this redirect already committed?", trailing-slash
|
|
48
|
+
* trimmed on the path segment (matching the router's own same-URL comparison)
|
|
49
|
+
* while search and hash stay significant.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isSameRedirectTarget(targetHref: string, committedHref: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Attempt bookkeeping for one mounted redirect: which intent keys have already
|
|
54
|
+
* fired at the CURRENT committed location. A new committed href resets the set
|
|
55
|
+
* (re-arm); the state is immutable so the caller can hold it in a ref.
|
|
56
|
+
*/
|
|
57
|
+
export interface RedirectAttemptState {
|
|
58
|
+
committedHref: string;
|
|
59
|
+
firedKeys: ReadonlySet<string>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The pure fire decision (EC-441's loop breaker, node-tested):
|
|
63
|
+
*
|
|
64
|
+
* 1. target already committed → no fire;
|
|
65
|
+
* 2. this intent already fired at this committed location → no fire (the
|
|
66
|
+
* per-render re-fire cycle, and any intent ping-pong at one location);
|
|
67
|
+
* 3. otherwise → fire, and record the intent for this location.
|
|
68
|
+
*/
|
|
69
|
+
export declare function nextRedirectAttempt(state: RedirectAttemptState | null, input: {
|
|
70
|
+
intentKey: string;
|
|
71
|
+
targetHref: string;
|
|
72
|
+
committedHref: string;
|
|
73
|
+
}): {
|
|
74
|
+
fire: boolean;
|
|
75
|
+
state: RedirectAttemptState;
|
|
76
|
+
};
|
|
77
|
+
export declare function NavigateOnce({ to, params, search, replace }: NavigateOnceIntent): null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import { useLocation, useNavigate, useRouter } from '@tanstack/react-router';
|
|
3
|
+
/**
|
|
4
|
+
* Pure value identity of a redirect intent. Fresh-but-equal `params`/`search`
|
|
5
|
+
* objects (the upstream failure mode: new object literal every render) map to
|
|
6
|
+
* ONE key; top-level entries are order-normalized so key insertion order never
|
|
7
|
+
* splits an intent. Values are JSON-serialized — the studio's redirect targets
|
|
8
|
+
* are strings and flat search records, both deterministic under JSON.
|
|
9
|
+
*/
|
|
10
|
+
export function navigateIntentKey(intent) {
|
|
11
|
+
const sorted = (record) => record
|
|
12
|
+
? Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
13
|
+
: null;
|
|
14
|
+
return JSON.stringify([
|
|
15
|
+
intent.to,
|
|
16
|
+
sorted(intent.params),
|
|
17
|
+
sorted(intent.search),
|
|
18
|
+
intent.replace === true,
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Href equality for "is this redirect already committed?", trailing-slash
|
|
23
|
+
* trimmed on the path segment (matching the router's own same-URL comparison)
|
|
24
|
+
* while search and hash stay significant.
|
|
25
|
+
*/
|
|
26
|
+
export function isSameRedirectTarget(targetHref, committedHref) {
|
|
27
|
+
const trim = (href) => {
|
|
28
|
+
const [pathAndSearch = '', hash = ''] = href.split('#', 2);
|
|
29
|
+
const [path = '', search = ''] = pathAndSearch.split('?', 2);
|
|
30
|
+
const trimmedPath = path.length > 1 ? path.replace(/\/+$/, '') : path;
|
|
31
|
+
return `${trimmedPath}${search ? `?${search}` : ''}${hash ? `#${hash}` : ''}`;
|
|
32
|
+
};
|
|
33
|
+
return trim(targetHref) === trim(committedHref);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The pure fire decision (EC-441's loop breaker, node-tested):
|
|
37
|
+
*
|
|
38
|
+
* 1. target already committed → no fire;
|
|
39
|
+
* 2. this intent already fired at this committed location → no fire (the
|
|
40
|
+
* per-render re-fire cycle, and any intent ping-pong at one location);
|
|
41
|
+
* 3. otherwise → fire, and record the intent for this location.
|
|
42
|
+
*/
|
|
43
|
+
export function nextRedirectAttempt(state, input) {
|
|
44
|
+
const base = state && state.committedHref === input.committedHref
|
|
45
|
+
? state
|
|
46
|
+
: { committedHref: input.committedHref, firedKeys: new Set() };
|
|
47
|
+
if (isSameRedirectTarget(input.targetHref, input.committedHref))
|
|
48
|
+
return { fire: false, state: base };
|
|
49
|
+
if (base.firedKeys.has(input.intentKey))
|
|
50
|
+
return { fire: false, state: base };
|
|
51
|
+
return {
|
|
52
|
+
fire: true,
|
|
53
|
+
state: {
|
|
54
|
+
committedHref: base.committedHref,
|
|
55
|
+
firedKeys: new Set(base.firedKeys).add(input.intentKey),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Layout-effect timing on the client (start the redirect before paint, like
|
|
60
|
+
// upstream `<Navigate>`); plain effect on the server, where layout effects warn
|
|
61
|
+
// and redirects never run (the studio's redirect surfaces render null on SSR).
|
|
62
|
+
const useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
|
|
63
|
+
export function NavigateOnce({ to, params, search, replace }) {
|
|
64
|
+
const router = useRouter();
|
|
65
|
+
const navigate = useNavigate();
|
|
66
|
+
// The COMMITTED location (not transient transition state): effect deps stay
|
|
67
|
+
// stable across the pending-transition re-renders that used to re-fire
|
|
68
|
+
// `<Navigate>`, and a commit re-runs the decision exactly once.
|
|
69
|
+
const committedHref = useLocation({ select: (location) => location.href });
|
|
70
|
+
const key = navigateIntentKey({ to, params, search, replace });
|
|
71
|
+
// Latest-value ref: the effect is keyed on the intent's VALUE (`key`), so it
|
|
72
|
+
// reads the current props through a ref instead of listing unstable object
|
|
73
|
+
// identities as dependencies.
|
|
74
|
+
const intentRef = useRef({ to, params, search, replace });
|
|
75
|
+
intentRef.current = { to, params, search, replace };
|
|
76
|
+
const attemptRef = useRef(null);
|
|
77
|
+
useIsomorphicLayoutEffect(() => {
|
|
78
|
+
const intent = intentRef.current;
|
|
79
|
+
const target = router.buildLocation({
|
|
80
|
+
to: intent.to,
|
|
81
|
+
params: intent.params,
|
|
82
|
+
search: intent.search,
|
|
83
|
+
});
|
|
84
|
+
const decision = nextRedirectAttempt(attemptRef.current, {
|
|
85
|
+
intentKey: key,
|
|
86
|
+
targetHref: target.href,
|
|
87
|
+
committedHref,
|
|
88
|
+
});
|
|
89
|
+
attemptRef.current = decision.state;
|
|
90
|
+
if (!decision.fire)
|
|
91
|
+
return;
|
|
92
|
+
// Fire-and-forget, exactly like upstream `<Navigate>`: with the re-fire
|
|
93
|
+
// cycle gone the transition settles normally, so nothing here swallows a
|
|
94
|
+
// real navigation failure.
|
|
95
|
+
void navigate({
|
|
96
|
+
to: intent.to,
|
|
97
|
+
params: intent.params,
|
|
98
|
+
search: intent.search,
|
|
99
|
+
replace: intent.replace,
|
|
100
|
+
});
|
|
101
|
+
}, [key, committedHref, navigate, router]);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=navigate-once.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"navigate-once.js","sourceRoot":"","sources":["../../src/router/navigate-once.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAC1D,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAA;AAwC5E;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAA0B;IAC1D,MAAM,MAAM,GAAG,CAAC,MAA2C,EAAE,EAAE,CAC7D,MAAM;QACJ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACzE,CAAC,CAAC,IAAI,CAAA;IACV,OAAO,IAAI,CAAC,SAAS,CAAC;QACpB,MAAM,CAAC,EAAE;QACT,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;QACrB,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;QACrB,MAAM,CAAC,OAAO,KAAK,IAAI;KACxB,CAAC,CAAA;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAkB,EAAE,aAAqB;IAC5E,MAAM,IAAI,GAAG,CAAC,IAAY,EAAE,EAAE;QAC5B,MAAM,CAAC,aAAa,GAAG,EAAE,EAAE,IAAI,GAAG,EAAE,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAA;QAC1D,MAAM,CAAC,IAAI,GAAG,EAAE,EAAE,MAAM,GAAG,EAAE,CAAC,GAAG,aAAa,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAA;QAC5D,MAAM,WAAW,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;QACrE,OAAO,GAAG,WAAW,GAAG,MAAM,CAAC,CAAC,CAAC,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAA;IAC/E,CAAC,CAAA;IACD,OAAO,IAAI,CAAC,UAAU,CAAC,KAAK,IAAI,CAAC,aAAa,CAAC,CAAA;AACjD,CAAC;AAYD;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAkC,EAClC,KAAuE;IAEvE,MAAM,IAAI,GACR,KAAK,IAAI,KAAK,CAAC,aAAa,KAAK,KAAK,CAAC,aAAa;QAClD,CAAC,CAAC,KAAK;QACP,CAAC,CAAC,EAAE,aAAa,EAAE,KAAK,CAAC,aAAa,EAAE,SAAS,EAAE,IAAI,GAAG,EAAU,EAAE,CAAA;IAC1E,IAAI,oBAAoB,CAAC,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,aAAa,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;IACpG,IAAI,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;IAC5E,OAAO;QACL,IAAI,EAAE,IAAI;QACV,KAAK,EAAE;YACL,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,SAAS,EAAE,IAAI,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,CAAC;SACxD;KACF,CAAA;AACH,CAAC;AAED,4EAA4E;AAC5E,gFAAgF;AAChF,+EAA+E;AAC/E,MAAM,yBAAyB,GAAG,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,SAAS,CAAA;AAE/F,MAAM,UAAU,YAAY,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAsB;IAC9E,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;IAC1B,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAA;IAC9B,4EAA4E;IAC5E,uEAAuE;IACvE,gEAAgE;IAChE,MAAM,aAAa,GAAG,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;IAC1E,MAAM,GAAG,GAAG,iBAAiB,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAA;IAC9D,6EAA6E;IAC7E,2EAA2E;IAC3E,8BAA8B;IAC9B,MAAM,SAAS,GAAG,MAAM,CAAqB,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAA;IAC7E,SAAS,CAAC,OAAO,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,CAAA;IACnD,MAAM,UAAU,GAAG,MAAM,CAA8B,IAAI,CAAC,CAAA;IAE5D,yBAAyB,CAAC,GAAG,EAAE;QAC7B,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,CAAA;QAChC,MAAM,MAAM,GAAG,MAAM,CAAC,aAAa,CAAC;YAClC,EAAE,EAAE,MAAM,CAAC,EAAE;YACb,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,MAAM,EAAE,MAAM,CAAC,MAAM;SACtB,CAAC,CAAA;QACF,MAAM,QAAQ,GAAG,mBAAmB,CAAC,UAAU,CAAC,OAAO,EAAE;YACvD,SAAS,EAAE,GAAG;YACd,UAAU,EAAE,MAAM,CAAC,IAAI;YACvB,aAAa;SACd,CAAC,CAAA;QACF,UAAU,CAAC,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAA;QACnC,IAAI,CAAC,QAAQ,CAAC,IAAI;YAAE,OAAM;QAC1B,wEAAwE;QACxE,yEAAyE;QACzE,2BAA2B;QAC3B,KAAK,QAAQ,CAAC;YACZ,EAAE,EAAE,MAAM,CAAC,EAAE;YACb,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,OAAO,EAAE,MAAM,CAAC,OAAO;SACxB,CAAC,CAAA;IACJ,CAAC,EAAE,CAAC,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAA;IAE1C,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["import { useEffect, useLayoutEffect, useRef } from 'react'\nimport { useLocation, useNavigate, useRouter } from '@tanstack/react-router'\n\n/**\n * Render-time redirect that fires its navigation ONCE per redirect intent per\n * committed location (EC-441). The studio's declarative redirects (launcher\n * auto-forward, project index → landing view, auth-gate bounces, deep-link\n * normalizations) must use THIS instead of TanStack Router's `<Navigate>`.\n *\n * Why: `<Navigate>` re-fires `navigate(props)` whenever its props OBJECT\n * IDENTITY changes — and JSX builds a fresh props object on every parent\n * render. While the redirect's target transition is still pending (slow route\n * modules on a cold dev boot, slow chunks in production), the redirecting\n * component stays mounted and re-renders on every router-store tick; each\n * re-render re-navigates, each `navigate()` re-enters `commitLocation`/`load`\n * and schedules another transition state update, and the cycle compounds until\n * React throws \"Maximum update depth exceeded\" inside `commitLocation` — an\n * unhandled rejection of the router's load promise (the EC-439 finding: 104\n * launcher re-navigations on one cold playground boot).\n *\n * The decision core (`nextRedirectAttempt`) is pure and value-keyed:\n *\n * - already at the target → never fire (idempotence — covers the EC-179 keyed\n * `AuthProvider` remount, which recreates the redirecting component with\n * fresh refs while the router has already committed the target);\n * - one fire per intent per committed location — re-renders and store ticks\n * while the target transition is pending are inert;\n * - a NEW committed location re-arms the attempt, so navigating back to a URL\n * that redirects (e.g. the bare project index) redirects again.\n */\nexport interface NavigateOnceIntent {\n /** Target route path, addressed as a plain string (the router-seam contract). */\n to: string\n /** Route params (`{ projectId, _splat }` for the studio splat). */\n params?: Record<string, string>\n /** Search params for the target location. */\n search?: Record<string, unknown>\n /** Replace the current history entry (a redirect must not create one). */\n replace?: boolean\n}\n\n/**\n * Pure value identity of a redirect intent. Fresh-but-equal `params`/`search`\n * objects (the upstream failure mode: new object literal every render) map to\n * ONE key; top-level entries are order-normalized so key insertion order never\n * splits an intent. Values are JSON-serialized — the studio's redirect targets\n * are strings and flat search records, both deterministic under JSON.\n */\nexport function navigateIntentKey(intent: NavigateOnceIntent): string {\n const sorted = (record: Record<string, unknown> | undefined) =>\n record\n ? Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n : null\n return JSON.stringify([\n intent.to,\n sorted(intent.params),\n sorted(intent.search),\n intent.replace === true,\n ])\n}\n\n/**\n * Href equality for \"is this redirect already committed?\", trailing-slash\n * trimmed on the path segment (matching the router's own same-URL comparison)\n * while search and hash stay significant.\n */\nexport function isSameRedirectTarget(targetHref: string, committedHref: string): boolean {\n const trim = (href: string) => {\n const [pathAndSearch = '', hash = ''] = href.split('#', 2)\n const [path = '', search = ''] = pathAndSearch.split('?', 2)\n const trimmedPath = path.length > 1 ? path.replace(/\\/+$/, '') : path\n return `${trimmedPath}${search ? `?${search}` : ''}${hash ? `#${hash}` : ''}`\n }\n return trim(targetHref) === trim(committedHref)\n}\n\n/**\n * Attempt bookkeeping for one mounted redirect: which intent keys have already\n * fired at the CURRENT committed location. A new committed href resets the set\n * (re-arm); the state is immutable so the caller can hold it in a ref.\n */\nexport interface RedirectAttemptState {\n committedHref: string\n firedKeys: ReadonlySet<string>\n}\n\n/**\n * The pure fire decision (EC-441's loop breaker, node-tested):\n *\n * 1. target already committed → no fire;\n * 2. this intent already fired at this committed location → no fire (the\n * per-render re-fire cycle, and any intent ping-pong at one location);\n * 3. otherwise → fire, and record the intent for this location.\n */\nexport function nextRedirectAttempt(\n state: RedirectAttemptState | null,\n input: { intentKey: string; targetHref: string; committedHref: string },\n): { fire: boolean; state: RedirectAttemptState } {\n const base: RedirectAttemptState =\n state && state.committedHref === input.committedHref\n ? state\n : { committedHref: input.committedHref, firedKeys: new Set<string>() }\n if (isSameRedirectTarget(input.targetHref, input.committedHref)) return { fire: false, state: base }\n if (base.firedKeys.has(input.intentKey)) return { fire: false, state: base }\n return {\n fire: true,\n state: {\n committedHref: base.committedHref,\n firedKeys: new Set(base.firedKeys).add(input.intentKey),\n },\n }\n}\n\n// Layout-effect timing on the client (start the redirect before paint, like\n// upstream `<Navigate>`); plain effect on the server, where layout effects warn\n// and redirects never run (the studio's redirect surfaces render null on SSR).\nconst useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect\n\nexport function NavigateOnce({ to, params, search, replace }: NavigateOnceIntent) {\n const router = useRouter()\n const navigate = useNavigate()\n // The COMMITTED location (not transient transition state): effect deps stay\n // stable across the pending-transition re-renders that used to re-fire\n // `<Navigate>`, and a commit re-runs the decision exactly once.\n const committedHref = useLocation({ select: (location) => location.href })\n const key = navigateIntentKey({ to, params, search, replace })\n // Latest-value ref: the effect is keyed on the intent's VALUE (`key`), so it\n // reads the current props through a ref instead of listing unstable object\n // identities as dependencies.\n const intentRef = useRef<NavigateOnceIntent>({ to, params, search, replace })\n intentRef.current = { to, params, search, replace }\n const attemptRef = useRef<RedirectAttemptState | null>(null)\n\n useIsomorphicLayoutEffect(() => {\n const intent = intentRef.current\n const target = router.buildLocation({\n to: intent.to,\n params: intent.params,\n search: intent.search,\n })\n const decision = nextRedirectAttempt(attemptRef.current, {\n intentKey: key,\n targetHref: target.href,\n committedHref,\n })\n attemptRef.current = decision.state\n if (!decision.fire) return\n // Fire-and-forget, exactly like upstream `<Navigate>`: with the re-fire\n // cycle gone the transition settles normally, so nothing here swallows a\n // real navigation failure.\n void navigate({\n to: intent.to,\n params: intent.params,\n search: intent.search,\n replace: intent.replace,\n })\n }, [key, committedHref, navigate, router])\n\n return null\n}\n"]}
|
package/dist/router/root.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { createRootRoute, HeadContent,
|
|
2
|
+
import { createRootRoute, HeadContent, Outlet, Scripts, useLocation, } from '@tanstack/react-router';
|
|
3
|
+
import { NavigateOnce } from './navigate-once.js';
|
|
3
4
|
import { AuthProvider, useAuth } from '@elytracms/studio/components/auth-context';
|
|
4
5
|
import { DeploymentSetupGate } from '@elytracms/studio/components/deployment-setup-gate';
|
|
5
6
|
import { SessionExpiredOverlay } from '@elytracms/studio/components/session-expired-overlay';
|
|
@@ -57,10 +58,13 @@ function AuthGate({ children }) {
|
|
|
57
58
|
if (decision.kind === 'pending')
|
|
58
59
|
return _jsx(SessionLoading, {});
|
|
59
60
|
if (decision.kind === 'redirect-to-sign-in') {
|
|
60
|
-
|
|
61
|
+
// EC-441: `NavigateOnce`, not `<Navigate>` — the gate stays mounted while
|
|
62
|
+
// the sign-in transition is pending, and a per-render re-navigate spirals
|
|
63
|
+
// into the router's update-depth rejection.
|
|
64
|
+
return _jsx(NavigateOnce, { to: SIGN_IN_PATH, search: { redirect: decision.redirect }, replace: true });
|
|
61
65
|
}
|
|
62
66
|
if (decision.kind === 'redirect-home')
|
|
63
|
-
return _jsx(
|
|
67
|
+
return _jsx(NavigateOnce, { to: "/", replace: true });
|
|
64
68
|
return children;
|
|
65
69
|
}
|
|
66
70
|
/** Deterministic session-restore state (also the SSR output for protected paths). */
|
package/dist/router/root.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"root.js","sourceRoot":"","sources":["../../src/router/root.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { ReactNode } from 'react'\nimport {\n createRootRoute,\n HeadContent,\n Navigate,\n Outlet,\n Scripts,\n useLocation,\n} from '@tanstack/react-router'\nimport { AuthProvider, useAuth } from '@elytracms/studio/components/auth-context'\nimport { DeploymentSetupGate } from '@elytracms/studio/components/deployment-setup-gate'\nimport { SessionExpiredOverlay } from '@elytracms/studio/components/session-expired-overlay'\nimport { StudioProvider } from '@elytracms/studio/components/studio-context'\nimport { ThemeProvider } from '@elytracms/studio/components/theme-context'\nimport { Text } from '@elytracms/studio/ui'\nimport { SIGN_IN_PATH, resolveRouteGuard } from '@elytracms/studio/lib/auth'\nimport { useT } from '@elytracms/studio/lib/i18n'\nimport { setStudioBaseTitle } from '@elytracms/studio/lib/tab-title'\nimport { activeSlugForLocation, setActiveProjectSlug } from '@elytracms/studio/lib/workspace'\nimport { THEME_INIT_SCRIPT } from '@elytracms/studio/lib/theme'\n\n/**\n * Studio root-route boot factory (EC-375; consolidated from the copy-pasted\n * consumer `__root.tsx` by EC-369 finding #7). It assembles the ONE canonical\n * provider/gate stack — ThemeProvider → DeploymentSetupGate → AuthProvider(keyed)\n * → AuthGate → StudioProvider → Outlet — plus the `RootDocument` shell\n * (html/head with the pre-hydration theme script + the chrome stylesheet).\n *\n * Its whole purpose is to make the NEXT required-gate addition automatic on\n * package upgrade: when EC-258 added `DeploymentSetupGate`, every hand-copied\n * `__root.tsx` had to be manually re-synced, and Konditorei's silently missed\n * it for five releases (EC-367). Owning the composition here means a consumer's\n * `__root.tsx` shrinks to a thin shell and picks up new gates for free.\n *\n * The consumer re-exports the returned route as `Route` (the generated route\n * tree imports `Route` from `./routes/__root`):\n *\n * ```tsx\n * import { createStudioRootRoute } from '@elytracms/studio/router/root'\n * import appCss from '../styles/app.css?url'\n * export const Route = createStudioRootRoute({ appCssUrl: appCss })\n * ```\n */\nexport interface StudioRootRouteOptions {\n /**\n * URL of the studio chrome stylesheet, from the consumer's Vite `?url` import\n * (`import appCss from '../styles/app.css?url'`). The one option every consumer\n * must pass — the sheet is project-owned (it carries the delivery theme).\n */\n appCssUrl: string\n /** Document `<title>`. Default: `'Elytra'`. */\n title?: string\n /** `<html lang>`. Default: `'en'`. */\n htmlLang?: string\n /**\n * Extra `<head>` meta entries appended after the defaults (charset, viewport,\n * title). Same shape TanStack Router's `head().meta` takes.\n */\n meta?: Array<Record<string, string>>\n /** Extra `<head>` link entries appended after the app stylesheet. */\n links?: Array<Record<string, string>>\n /**\n * Escape hatch: wrap additional project-specific providers around the routed\n * subtree. Rendered INSIDE `StudioProvider` and around `<Outlet />`, so nested\n * providers see the full studio/auth/theme context and remount with the active\n * project. Return the passed `children` wrapped in your providers.\n */\n providers?: (children: ReactNode) => ReactNode\n}\n\nexport function createStudioRootRoute(options: StudioRootRouteOptions) {\n const { appCssUrl, title = 'Elytra', htmlLang = 'en', meta = [], links = [], providers } = options\n\n // EC-402: register the base title for the shell's per-view tab-title effect\n // (`StudioTabTitle` composes `deepest crumb · view · project · <this title>`\n // and restores exactly this value when leaving a project view). Module-holder\n // seam — same pattern as `setActiveProjectSlug` — so the shell never has to\n // capture `document.title` and race the router's own head effect.\n setStudioBaseTitle(title)\n\n function RootComponent() {\n const location = useLocation()\n // EC-179: the active project selects its Convex backend. Resolve it from the\n // URL (or the sign-in redirect target) and bind it *before* the auth and\n // persistence adapters are selected, so the initial sign-in authenticates\n // against the project the user is heading for. Keying the auth/studio subtree\n // by the active project remounts it on a project switch, so the adapters\n // re-select against the new backend and the session is restored per-backend.\n const activeSlug = activeSlugForLocation(\n location.pathname,\n (location.search as { redirect?: string }).redirect,\n )\n setActiveProjectSlug(activeSlug)\n\n return (\n <RootDocument htmlLang={htmlLang}>\n <ThemeProvider>\n {/* EC-258: one deployment-setup gate runs before auth — a misconfigured or\n unreachable backend, an invalid workspace config, or any failing readiness\n precondition (missing auth signing key, required collections, …) is a\n single visible boot screen with copy-pasteable fixes, never a silent\n in-memory fallback or a misleading downstream error. All checks passing\n renders children as before. */}\n <DeploymentSetupGate>\n <AuthProvider key={activeSlug ?? '__launcher__'}>\n <AuthGate>\n <StudioProvider>\n {providers ? providers(<Outlet />) : <Outlet />}\n {/* EC-403: the blocking in-place re-auth overlay — mounted over\n every route, INSIDE StudioProvider (it invalidates the studio\n query cache on re-auth) and inside AuthProvider (it reads the\n sessionExpired state). Renders null unless the session died. */}\n <SessionExpiredOverlay />\n </StudioProvider>\n </AuthGate>\n </AuthProvider>\n </DeploymentSetupGate>\n </ThemeProvider>\n </RootDocument>\n )\n }\n\n return createRootRoute({\n head: () => ({\n meta: [\n { charSet: 'utf-8' },\n { name: 'viewport', content: 'width=device-width, initial-scale=1' },\n { title },\n ...meta,\n ],\n links: [{ rel: 'stylesheet', href: appCssUrl }, ...links],\n }),\n component: RootComponent,\n })\n}\n\n/**\n * Route guard (EC-150): one chokepoint in the root layout. Unauthenticated\n * visitors on protected paths are redirected to the sign-in screen (with the\n * intended target preserved); signed-in users never see the sign-in screen.\n * The decision logic is the pure, unit-tested `resolveRouteGuard`.\n */\nfunction AuthGate({ children }: { children: ReactNode }) {\n const { status } = useAuth()\n const { pathname } = useLocation()\n const decision = resolveRouteGuard(pathname, status)\n\n if (decision.kind === 'pending') return <SessionLoading />\n if (decision.kind === 'redirect-to-sign-in') {\n return <Navigate to={SIGN_IN_PATH} search={{ redirect: decision.redirect }} replace />\n }\n if (decision.kind === 'redirect-home') return <Navigate to=\"/\" replace />\n return children\n}\n\n/** Deterministic session-restore state (also the SSR output for protected paths). */\nfunction SessionLoading() {\n const t = useT()\n return (\n <div className=\"grid min-h-dvh place-items-center bg-background\">\n <Text size=\"sm\" tone=\"muted\">\n {t('auth.boot.checkingSession')}\n </Text>\n </div>\n )\n}\n\nfunction RootDocument({ children, htmlLang }: { children: ReactNode; htmlLang: string }) {\n // Dark-first SSR baseline; THEME_INIT_SCRIPT flips the class pre-hydration\n // from the stored preference, and ThemeProvider keeps it reactive thereafter.\n // suppressHydrationWarning: the script intentionally mutates the class before\n // React hydrates, so a className mismatch on <html> is expected, not a bug.\n // The app stylesheet is emitted via head().links and rendered by <HeadContent />.\n return (\n <html lang={htmlLang} className=\"dark\" suppressHydrationWarning>\n <head>\n <HeadContent />\n <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />\n </head>\n <body className=\"min-h-screen bg-background font-sans text-foreground antialiased\">\n {children}\n <Scripts />\n </body>\n </html>\n )\n}\n"]}
|
|
1
|
+
{"version":3,"file":"root.js","sourceRoot":"","sources":["../../src/router/root.tsx"],"names":[],"mappings":";AACA,OAAO,EACL,eAAe,EACf,WAAW,EACX,MAAM,EACN,OAAO,EACP,WAAW,GACZ,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAC9C,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,2CAA2C,CAAA;AACjF,OAAO,EAAE,mBAAmB,EAAE,MAAM,oDAAoD,CAAA;AACxF,OAAO,EAAE,qBAAqB,EAAE,MAAM,sDAAsD,CAAA;AAC5F,OAAO,EAAE,cAAc,EAAE,MAAM,6CAA6C,CAAA;AAC5E,OAAO,EAAE,aAAa,EAAE,MAAM,4CAA4C,CAAA;AAC1E,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAA;AAC5E,OAAO,EAAE,IAAI,EAAE,MAAM,4BAA4B,CAAA;AACjD,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAA;AACpE,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,iCAAiC,CAAA;AAC7F,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;AAmD/D,MAAM,UAAU,qBAAqB,CAAC,OAA+B;IACnE,MAAM,EAAE,SAAS,EAAE,KAAK,GAAG,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,GAAG,EAAE,EAAE,KAAK,GAAG,EAAE,EAAE,SAAS,EAAE,GAAG,OAAO,CAAA;IAElG,4EAA4E;IAC5E,6EAA6E;IAC7E,8EAA8E;IAC9E,4EAA4E;IAC5E,kEAAkE;IAClE,kBAAkB,CAAC,KAAK,CAAC,CAAA;IAEzB,SAAS,aAAa;QACpB,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAA;QAC9B,6EAA6E;QAC7E,yEAAyE;QACzE,0EAA0E;QAC1E,8EAA8E;QAC9E,yEAAyE;QACzE,6EAA6E;QAC7E,MAAM,UAAU,GAAG,qBAAqB,CACtC,QAAQ,CAAC,QAAQ,EAChB,QAAQ,CAAC,MAAgC,CAAC,QAAQ,CACpD,CAAA;QACD,oBAAoB,CAAC,UAAU,CAAC,CAAA;QAEhC,OAAO,CACL,KAAC,YAAY,IAAC,QAAQ,EAAE,QAAQ,YAC9B,KAAC,aAAa,cAOZ,KAAC,mBAAmB,cAClB,KAAC,YAAY,cACX,KAAC,QAAQ,cACP,MAAC,cAAc,eACZ,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,KAAC,MAAM,KAAG,CAAC,CAAC,CAAC,CAAC,KAAC,MAAM,KAAG,EAK/C,KAAC,qBAAqB,KAAG,IACV,GACR,IAVM,UAAU,IAAI,cAAc,CAWhC,GACK,GACR,GACH,CAChB,CAAA;IACH,CAAC;IAED,OAAO,eAAe,CAAC;QACrB,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;YACX,IAAI,EAAE;gBACJ,EAAE,OAAO,EAAE,OAAO,EAAE;gBACpB,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,qCAAqC,EAAE;gBACpE,EAAE,KAAK,EAAE;gBACT,GAAG,IAAI;aACR;YACD,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,GAAG,KAAK,CAAC;SAC1D,CAAC;QACF,SAAS,EAAE,aAAa;KACzB,CAAC,CAAA;AACJ,CAAC;AAED;;;;;GAKG;AACH,SAAS,QAAQ,CAAC,EAAE,QAAQ,EAA2B;IACrD,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,EAAE,CAAA;IAC5B,MAAM,EAAE,QAAQ,EAAE,GAAG,WAAW,EAAE,CAAA;IAClC,MAAM,QAAQ,GAAG,iBAAiB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;IAEpD,IAAI,QAAQ,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,KAAC,cAAc,KAAG,CAAA;IAC1D,IAAI,QAAQ,CAAC,IAAI,KAAK,qBAAqB,EAAE,CAAC;QAC5C,0EAA0E;QAC1E,0EAA0E;QAC1E,4CAA4C;QAC5C,OAAO,KAAC,YAAY,IAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,EAAE,OAAO,SAAG,CAAA;IAC5F,CAAC;IACD,IAAI,QAAQ,CAAC,IAAI,KAAK,eAAe;QAAE,OAAO,KAAC,YAAY,IAAC,EAAE,EAAC,GAAG,EAAC,OAAO,SAAG,CAAA;IAC7E,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,qFAAqF;AACrF,SAAS,cAAc;IACrB,MAAM,CAAC,GAAG,IAAI,EAAE,CAAA;IAChB,OAAO,CACL,cAAK,SAAS,EAAC,iDAAiD,YAC9D,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,CAAC,CAAC,2BAA2B,CAAC,GAC1B,GACH,CACP,CAAA;AACH,CAAC;AAED,SAAS,YAAY,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAA6C;IACrF,2EAA2E;IAC3E,8EAA8E;IAC9E,8EAA8E;IAC9E,4EAA4E;IAC5E,kFAAkF;IAClF,OAAO,CACL,gBAAM,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAC,MAAM,EAAC,wBAAwB,mBAC7D,2BACE,KAAC,WAAW,KAAG,EACf,iBAAQ,uBAAuB,EAAE,EAAE,MAAM,EAAE,iBAAiB,EAAE,GAAI,IAC7D,EACP,gBAAM,SAAS,EAAC,kEAAkE,aAC/E,QAAQ,EACT,KAAC,OAAO,KAAG,IACN,IACF,CACR,CAAA;AACH,CAAC","sourcesContent":["import type { ReactNode } from 'react'\nimport {\n createRootRoute,\n HeadContent,\n Outlet,\n Scripts,\n useLocation,\n} from '@tanstack/react-router'\nimport { NavigateOnce } from './navigate-once'\nimport { AuthProvider, useAuth } from '@elytracms/studio/components/auth-context'\nimport { DeploymentSetupGate } from '@elytracms/studio/components/deployment-setup-gate'\nimport { SessionExpiredOverlay } from '@elytracms/studio/components/session-expired-overlay'\nimport { StudioProvider } from '@elytracms/studio/components/studio-context'\nimport { ThemeProvider } from '@elytracms/studio/components/theme-context'\nimport { Text } from '@elytracms/studio/ui'\nimport { SIGN_IN_PATH, resolveRouteGuard } from '@elytracms/studio/lib/auth'\nimport { useT } from '@elytracms/studio/lib/i18n'\nimport { setStudioBaseTitle } from '@elytracms/studio/lib/tab-title'\nimport { activeSlugForLocation, setActiveProjectSlug } from '@elytracms/studio/lib/workspace'\nimport { THEME_INIT_SCRIPT } from '@elytracms/studio/lib/theme'\n\n/**\n * Studio root-route boot factory (EC-375; consolidated from the copy-pasted\n * consumer `__root.tsx` by EC-369 finding #7). It assembles the ONE canonical\n * provider/gate stack — ThemeProvider → DeploymentSetupGate → AuthProvider(keyed)\n * → AuthGate → StudioProvider → Outlet — plus the `RootDocument` shell\n * (html/head with the pre-hydration theme script + the chrome stylesheet).\n *\n * Its whole purpose is to make the NEXT required-gate addition automatic on\n * package upgrade: when EC-258 added `DeploymentSetupGate`, every hand-copied\n * `__root.tsx` had to be manually re-synced, and Konditorei's silently missed\n * it for five releases (EC-367). Owning the composition here means a consumer's\n * `__root.tsx` shrinks to a thin shell and picks up new gates for free.\n *\n * The consumer re-exports the returned route as `Route` (the generated route\n * tree imports `Route` from `./routes/__root`):\n *\n * ```tsx\n * import { createStudioRootRoute } from '@elytracms/studio/router/root'\n * import appCss from '../styles/app.css?url'\n * export const Route = createStudioRootRoute({ appCssUrl: appCss })\n * ```\n */\nexport interface StudioRootRouteOptions {\n /**\n * URL of the studio chrome stylesheet, from the consumer's Vite `?url` import\n * (`import appCss from '../styles/app.css?url'`). The one option every consumer\n * must pass — the sheet is project-owned (it carries the delivery theme).\n */\n appCssUrl: string\n /** Document `<title>`. Default: `'Elytra'`. */\n title?: string\n /** `<html lang>`. Default: `'en'`. */\n htmlLang?: string\n /**\n * Extra `<head>` meta entries appended after the defaults (charset, viewport,\n * title). Same shape TanStack Router's `head().meta` takes.\n */\n meta?: Array<Record<string, string>>\n /** Extra `<head>` link entries appended after the app stylesheet. */\n links?: Array<Record<string, string>>\n /**\n * Escape hatch: wrap additional project-specific providers around the routed\n * subtree. Rendered INSIDE `StudioProvider` and around `<Outlet />`, so nested\n * providers see the full studio/auth/theme context and remount with the active\n * project. Return the passed `children` wrapped in your providers.\n */\n providers?: (children: ReactNode) => ReactNode\n}\n\nexport function createStudioRootRoute(options: StudioRootRouteOptions) {\n const { appCssUrl, title = 'Elytra', htmlLang = 'en', meta = [], links = [], providers } = options\n\n // EC-402: register the base title for the shell's per-view tab-title effect\n // (`StudioTabTitle` composes `deepest crumb · view · project · <this title>`\n // and restores exactly this value when leaving a project view). Module-holder\n // seam — same pattern as `setActiveProjectSlug` — so the shell never has to\n // capture `document.title` and race the router's own head effect.\n setStudioBaseTitle(title)\n\n function RootComponent() {\n const location = useLocation()\n // EC-179: the active project selects its Convex backend. Resolve it from the\n // URL (or the sign-in redirect target) and bind it *before* the auth and\n // persistence adapters are selected, so the initial sign-in authenticates\n // against the project the user is heading for. Keying the auth/studio subtree\n // by the active project remounts it on a project switch, so the adapters\n // re-select against the new backend and the session is restored per-backend.\n const activeSlug = activeSlugForLocation(\n location.pathname,\n (location.search as { redirect?: string }).redirect,\n )\n setActiveProjectSlug(activeSlug)\n\n return (\n <RootDocument htmlLang={htmlLang}>\n <ThemeProvider>\n {/* EC-258: one deployment-setup gate runs before auth — a misconfigured or\n unreachable backend, an invalid workspace config, or any failing readiness\n precondition (missing auth signing key, required collections, …) is a\n single visible boot screen with copy-pasteable fixes, never a silent\n in-memory fallback or a misleading downstream error. All checks passing\n renders children as before. */}\n <DeploymentSetupGate>\n <AuthProvider key={activeSlug ?? '__launcher__'}>\n <AuthGate>\n <StudioProvider>\n {providers ? providers(<Outlet />) : <Outlet />}\n {/* EC-403: the blocking in-place re-auth overlay — mounted over\n every route, INSIDE StudioProvider (it invalidates the studio\n query cache on re-auth) and inside AuthProvider (it reads the\n sessionExpired state). Renders null unless the session died. */}\n <SessionExpiredOverlay />\n </StudioProvider>\n </AuthGate>\n </AuthProvider>\n </DeploymentSetupGate>\n </ThemeProvider>\n </RootDocument>\n )\n }\n\n return createRootRoute({\n head: () => ({\n meta: [\n { charSet: 'utf-8' },\n { name: 'viewport', content: 'width=device-width, initial-scale=1' },\n { title },\n ...meta,\n ],\n links: [{ rel: 'stylesheet', href: appCssUrl }, ...links],\n }),\n component: RootComponent,\n })\n}\n\n/**\n * Route guard (EC-150): one chokepoint in the root layout. Unauthenticated\n * visitors on protected paths are redirected to the sign-in screen (with the\n * intended target preserved); signed-in users never see the sign-in screen.\n * The decision logic is the pure, unit-tested `resolveRouteGuard`.\n */\nfunction AuthGate({ children }: { children: ReactNode }) {\n const { status } = useAuth()\n const { pathname } = useLocation()\n const decision = resolveRouteGuard(pathname, status)\n\n if (decision.kind === 'pending') return <SessionLoading />\n if (decision.kind === 'redirect-to-sign-in') {\n // EC-441: `NavigateOnce`, not `<Navigate>` — the gate stays mounted while\n // the sign-in transition is pending, and a per-render re-navigate spirals\n // into the router's update-depth rejection.\n return <NavigateOnce to={SIGN_IN_PATH} search={{ redirect: decision.redirect }} replace />\n }\n if (decision.kind === 'redirect-home') return <NavigateOnce to=\"/\" replace />\n return children\n}\n\n/** Deterministic session-restore state (also the SSR output for protected paths). */\nfunction SessionLoading() {\n const t = useT()\n return (\n <div className=\"grid min-h-dvh place-items-center bg-background\">\n <Text size=\"sm\" tone=\"muted\">\n {t('auth.boot.checkingSession')}\n </Text>\n </div>\n )\n}\n\nfunction RootDocument({ children, htmlLang }: { children: ReactNode; htmlLang: string }) {\n // Dark-first SSR baseline; THEME_INIT_SCRIPT flips the class pre-hydration\n // from the stored preference, and ThemeProvider keeps it reactive thereafter.\n // suppressHydrationWarning: the script intentionally mutates the class before\n // React hydrates, so a className mismatch on <html> is expected, not a bug.\n // The app stylesheet is emitted via head().links and rendered by <HeadContent />.\n return (\n <html lang={htmlLang} className=\"dark\" suppressHydrationWarning>\n <head>\n <HeadContent />\n <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />\n </head>\n <body className=\"min-h-screen bg-background font-sans text-foreground antialiased\">\n {children}\n <Scripts />\n </body>\n </html>\n )\n}\n"]}
|
package/dist/ui/status-dot.d.ts
CHANGED
|
@@ -22,6 +22,15 @@ export interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'c
|
|
|
22
22
|
* keyboard user (no mouse) can still read it.
|
|
23
23
|
*/
|
|
24
24
|
label: string;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the dot is a keyboard tab stop (EC-448). **Default `true`** preserves the
|
|
27
|
+
* EC-446 focusable behavior (the versions pane — a few rows — so a keyboard user can
|
|
28
|
+
* focus a dot to reveal its pill). Pass `false` for a DENSE surface (the list
|
|
29
|
+
* `$state` column, EC-448): a long list must not add a tab stop per row. The label
|
|
30
|
+
* stays reachable via `aria-label` + native `title` (and the hover pill on mouse) —
|
|
31
|
+
* just not via focus. The focus-ring styling is likewise dropped when non-interactive.
|
|
32
|
+
*/
|
|
33
|
+
interactive?: boolean;
|
|
25
34
|
}
|
|
26
35
|
/**
|
|
27
36
|
* EC-446 — a compact, reusable status indicator: a small colored dot whose
|
|
@@ -30,10 +39,12 @@ export interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'c
|
|
|
30
39
|
* list `$state` column). Pure-presentational and domain-agnostic: it knows a
|
|
31
40
|
* `tone` and a `label`, nothing about versions or documents.
|
|
32
41
|
*
|
|
33
|
-
* Accessibility: the wrapper is
|
|
34
|
-
*
|
|
35
|
-
* (
|
|
36
|
-
* the meaning is reachable without a mouse
|
|
37
|
-
*
|
|
42
|
+
* Accessibility: the wrapper is `role="img"` and carries the label as both
|
|
43
|
+
* `aria-label` (screen readers) and native `title` (mouse). When `interactive`
|
|
44
|
+
* (the default), it is also focusable (`tabIndex=0`) and a CSS pill reveals the
|
|
45
|
+
* label on `:focus-visible`, so the meaning is reachable without a mouse; a
|
|
46
|
+
* non-interactive dot (EC-448, dense lists) drops the tab stop + focus ring but
|
|
47
|
+
* keeps the name + hover pill. The dot glyph and the pill are `aria-hidden` — the
|
|
48
|
+
* wrapper's `aria-label` is the single announced name.
|
|
38
49
|
*/
|
|
39
|
-
export declare function StatusDot({ tone, label, className, ...props }: StatusDotProps): import("react").JSX.Element;
|
|
50
|
+
export declare function StatusDot({ tone, label, className, interactive, ...props }: StatusDotProps): import("react").JSX.Element;
|
package/dist/ui/status-dot.js
CHANGED
|
@@ -24,13 +24,15 @@ export function statusDotToneClass(tone) {
|
|
|
24
24
|
* list `$state` column). Pure-presentational and domain-agnostic: it knows a
|
|
25
25
|
* `tone` and a `label`, nothing about versions or documents.
|
|
26
26
|
*
|
|
27
|
-
* Accessibility: the wrapper is
|
|
28
|
-
*
|
|
29
|
-
* (
|
|
30
|
-
* the meaning is reachable without a mouse
|
|
31
|
-
*
|
|
27
|
+
* Accessibility: the wrapper is `role="img"` and carries the label as both
|
|
28
|
+
* `aria-label` (screen readers) and native `title` (mouse). When `interactive`
|
|
29
|
+
* (the default), it is also focusable (`tabIndex=0`) and a CSS pill reveals the
|
|
30
|
+
* label on `:focus-visible`, so the meaning is reachable without a mouse; a
|
|
31
|
+
* non-interactive dot (EC-448, dense lists) drops the tab stop + focus ring but
|
|
32
|
+
* keeps the name + hover pill. The dot glyph and the pill are `aria-hidden` — the
|
|
33
|
+
* wrapper's `aria-label` is the single announced name.
|
|
32
34
|
*/
|
|
33
|
-
export function StatusDot({ tone, label, className, ...props }) {
|
|
34
|
-
return (_jsxs("span", { role: "img", tabIndex: 0, "aria-label": label, title: label, className: cn('group/status-dot relative inline-flex items-center justify-center rounded-full outline-none', 'focus-visible:ring-2 focus-visible:ring-ring/60', className), ...props, children: [_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', statusDotToneClass(tone)) }), _jsx("span", { "aria-hidden": true, className: cn('pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 whitespace-nowrap', 'rounded-md border border-border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground shadow-md', 'opacity-0 transition-opacity duration-100', 'group-hover/status-dot:opacity-100 group-focus-visible/status-dot:opacity-100'), children: label })] }));
|
|
35
|
+
export function StatusDot({ tone, label, className, interactive = true, ...props }) {
|
|
36
|
+
return (_jsxs("span", { role: "img", ...(interactive ? { tabIndex: 0 } : {}), "aria-label": label, title: label, className: cn('group/status-dot relative inline-flex items-center justify-center rounded-full outline-none', interactive && 'focus-visible:ring-2 focus-visible:ring-ring/60', className), ...props, children: [_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', statusDotToneClass(tone)) }), _jsx("span", { "aria-hidden": true, className: cn('pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 whitespace-nowrap', 'rounded-md border border-border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground shadow-md', 'opacity-0 transition-opacity duration-100', 'group-hover/status-dot:opacity-100 group-focus-visible/status-dot:opacity-100'), children: label })] }));
|
|
35
37
|
}
|
|
36
38
|
//# sourceMappingURL=status-dot.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"status-dot.js","sourceRoot":"","sources":["../../src/ui/status-dot.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAWjC;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAgB;IACjD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,SAAS;YACZ,OAAO,YAAY,CAAA;QACrB,KAAK,SAAS;YACZ,OAAO,YAAY,CAAA;QACrB,KAAK,OAAO;YACV,OAAO,wBAAwB,CAAA;QACjC,KAAK,SAAS;YACZ,OAAO,qBAAqB,CAAA;IAChC,CAAC;AACH,CAAC;
|
|
1
|
+
{"version":3,"file":"status-dot.js","sourceRoot":"","sources":["../../src/ui/status-dot.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAWjC;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAgB;IACjD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,SAAS;YACZ,OAAO,YAAY,CAAA;QACrB,KAAK,SAAS;YACZ,OAAO,YAAY,CAAA;QACrB,KAAK,OAAO;YACV,OAAO,wBAAwB,CAAA;QACjC,KAAK,SAAS;YACZ,OAAO,qBAAqB,CAAA;IAChC,CAAC;AACH,CAAC;AAsBD;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,WAAW,GAAG,IAAI,EAAE,GAAG,KAAK,EAAkB;IAChG,OAAO,CACL,gBACE,IAAI,EAAC,KAAK,KACN,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,gBAC5B,KAAK,EACjB,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,EAAE,CACX,6FAA6F,EAC7F,WAAW,IAAI,iDAAiD,EAChE,SAAS,CACV,KACG,KAAK,aAET,oCAAkB,SAAS,EAAE,EAAE,CAAC,8BAA8B,EAAE,kBAAkB,CAAC,IAAI,CAAC,CAAC,GAAI,EAC7F,oCAEE,SAAS,EAAE,EAAE,CACX,+FAA+F,EAC/F,4GAA4G,EAC5G,2CAA2C,EAC3C,+EAA+E,CAChF,YAEA,KAAK,GACD,IACF,CACR,CAAA;AACH,CAAC","sourcesContent":["import type { HTMLAttributes } from 'react'\nimport { cn } from '../lib/utils'\n\n/**\n * The status vocabulary a {@link StatusDot} can express. Kept deliberately\n * generic (not domain-specific) so the same primitive marks a published version\n * (`success`), a draft/pending state (`warning`), or a quiet/absent state\n * (`muted`/`neutral`) — the versions pane (EC-446) is its first consumer, the\n * list `$state` column (EC-445) its next.\n */\nexport type StatusTone = 'success' | 'warning' | 'muted' | 'neutral'\n\n/**\n * The dot's fill class per tone — a pure mapping onto the shared token palette\n * (the same `--success`/`--warning`/`--muted-foreground` tokens {@link Badge}\n * uses), extracted so it is unit-testable without a DOM.\n */\nexport function statusDotToneClass(tone: StatusTone): string {\n switch (tone) {\n case 'success':\n return 'bg-success'\n case 'warning':\n return 'bg-warning'\n case 'muted':\n return 'bg-muted-foreground/40'\n case 'neutral':\n return 'bg-muted-foreground'\n }\n}\n\nexport interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'aria-label'> {\n /** Which status the dot expresses — maps onto the shared token palette. */\n tone: StatusTone\n /**\n * The accessible + hover/focus-revealed text (e.g. \"Published\"). Exposed as the\n * `aria-label` and native `title`, and shown as a small pill on hover/focus so a\n * keyboard user (no mouse) can still read it.\n */\n label: string\n /**\n * Whether the dot is a keyboard tab stop (EC-448). **Default `true`** preserves the\n * EC-446 focusable behavior (the versions pane — a few rows — so a keyboard user can\n * focus a dot to reveal its pill). Pass `false` for a DENSE surface (the list\n * `$state` column, EC-448): a long list must not add a tab stop per row. The label\n * stays reachable via `aria-label` + native `title` (and the hover pill on mouse) —\n * just not via focus. The focus-ring styling is likewise dropped when non-interactive.\n */\n interactive?: boolean\n}\n\n/**\n * EC-446 — a compact, reusable status indicator: a small colored dot whose\n * meaning is revealed on hover/focus rather than spent as a full-width pill. The\n * primary space win over {@link Badge} for dense rows (the versions history, the\n * list `$state` column). Pure-presentational and domain-agnostic: it knows a\n * `tone` and a `label`, nothing about versions or documents.\n *\n * Accessibility: the wrapper is `role=\"img\"` and carries the label as both\n * `aria-label` (screen readers) and native `title` (mouse). When `interactive`\n * (the default), it is also focusable (`tabIndex=0`) and a CSS pill reveals the\n * label on `:focus-visible`, so the meaning is reachable without a mouse; a\n * non-interactive dot (EC-448, dense lists) drops the tab stop + focus ring but\n * keeps the name + hover pill. The dot glyph and the pill are `aria-hidden` — the\n * wrapper's `aria-label` is the single announced name.\n */\nexport function StatusDot({ tone, label, className, interactive = true, ...props }: StatusDotProps) {\n return (\n <span\n role=\"img\"\n {...(interactive ? { tabIndex: 0 } : {})}\n aria-label={label}\n title={label}\n className={cn(\n 'group/status-dot relative inline-flex items-center justify-center rounded-full outline-none',\n interactive && 'focus-visible:ring-2 focus-visible:ring-ring/60',\n className,\n )}\n {...props}\n >\n <span aria-hidden className={cn('size-2 shrink-0 rounded-full', statusDotToneClass(tone))} />\n <span\n aria-hidden\n className={cn(\n 'pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 whitespace-nowrap',\n 'rounded-md border border-border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground shadow-md',\n 'opacity-0 transition-opacity duration-100',\n 'group-hover/status-dot:opacity-100 group-focus-visible/status-dot:opacity-100',\n )}\n >\n {label}\n </span>\n </span>\n )\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytracms/studio",
|
|
3
3
|
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.76",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "The Elytra studio engine (UI + lib + routes + data-only Convex schema) as an importable package. Extraction staged in EXTRACTION.md (EC-223); Stages 1-2 = UI primitives + router-free lib leaves.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
"lucide-react": "^1.23.0",
|
|
151
151
|
"tailwind-merge": "^3.6.0",
|
|
152
152
|
"zod": "^4.4.3",
|
|
153
|
-
"@elytracms/core": "^0.0.
|
|
153
|
+
"@elytracms/core": "^0.0.76"
|
|
154
154
|
},
|
|
155
155
|
"peerDependencies": {
|
|
156
156
|
"@tanstack/react-router": "^1.170.0",
|