@elytracms/studio 0.0.75 → 0.0.77
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 +49 -48
- package/convex/schema.ts +15 -2
- 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 +19 -1
- package/dist/components/cms/cms-store.js +80 -8
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +5 -4
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/doc-state.d.ts +17 -9
- package/dist/components/cms/doc-state.js +14 -2
- package/dist/components/cms/doc-state.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +3 -0
- 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/auth/route-guard.d.ts +24 -3
- package/dist/lib/auth/route-guard.js +25 -4
- package/dist/lib/auth/route-guard.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +10 -0
- package/dist/lib/collection-view/collection-view-source.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 +11 -0
- package/dist/lib/operations/session-hydration.js +6 -0
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +5 -0
- 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 +19 -5
- package/dist/router/root.js.map +1 -1
- package/dist/ui/status-dot.d.ts +11 -8
- package/dist/ui/status-dot.js +7 -5
- package/dist/ui/status-dot.js.map +1 -1
- package/package.json +2 -2
|
@@ -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":"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","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';
|
|
@@ -52,15 +53,28 @@ export function createStudioRootRoute(options) {
|
|
|
52
53
|
*/
|
|
53
54
|
function AuthGate({ children }) {
|
|
54
55
|
const { status } = useAuth();
|
|
55
|
-
const
|
|
56
|
-
|
|
56
|
+
const location = useLocation();
|
|
57
|
+
// EC-451: feed the still-pending `?redirect` search param into the decision.
|
|
58
|
+
// Dev auto-login (or any fast real sign-in — SSO return, password-manager
|
|
59
|
+
// autofill) can flip the session to signed-in while the location is still
|
|
60
|
+
// `/sign-in?redirect=…`, BEFORE `SignInView`'s own post-sign-in navigation
|
|
61
|
+
// runs; the gate's bounce then fires first and must consume the target
|
|
62
|
+
// instead of dropping it on an unconditional `/`. The target is validated
|
|
63
|
+
// by `postSignInTarget` inside `resolveRouteGuard` (same-origin relative
|
|
64
|
+
// paths only — redirect is an attacker-suppliable search param).
|
|
65
|
+
const rawRedirect = location.search.redirect;
|
|
66
|
+
const redirect = typeof rawRedirect === 'string' ? rawRedirect : undefined;
|
|
67
|
+
const decision = resolveRouteGuard(location.pathname, status, redirect);
|
|
57
68
|
if (decision.kind === 'pending')
|
|
58
69
|
return _jsx(SessionLoading, {});
|
|
59
70
|
if (decision.kind === 'redirect-to-sign-in') {
|
|
60
|
-
|
|
71
|
+
// EC-441: `NavigateOnce`, not `<Navigate>` — the gate stays mounted while
|
|
72
|
+
// the sign-in transition is pending, and a per-render re-navigate spirals
|
|
73
|
+
// into the router's update-depth rejection.
|
|
74
|
+
return _jsx(NavigateOnce, { to: SIGN_IN_PATH, search: { redirect: decision.redirect }, replace: true });
|
|
61
75
|
}
|
|
62
76
|
if (decision.kind === 'redirect-home')
|
|
63
|
-
return _jsx(
|
|
77
|
+
return _jsx(NavigateOnce, { to: decision.target, replace: true });
|
|
64
78
|
return children;
|
|
65
79
|
}
|
|
66
80
|
/** 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":";AACA,OAAO,EACL,eAAe,EACf,WAAW,EACX,QAAQ,EACR,MAAM,EACN,OAAO,EACP,WAAW,GACZ,MAAM,wBAAwB,CAAA;AAC/B,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,OAAO,KAAC,QAAQ,IAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,EAAE,OAAO,SAAG,CAAA;IACxF,CAAC;IACD,IAAI,QAAQ,CAAC,IAAI,KAAK,eAAe;QAAE,OAAO,KAAC,QAAQ,IAAC,EAAE,EAAC,GAAG,EAAC,OAAO,SAAG,CAAA;IACzE,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 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":"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","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 location = useLocation()\n // EC-451: feed the still-pending `?redirect` search param into the decision.\n // Dev auto-login (or any fast real sign-in — SSO return, password-manager\n // autofill) can flip the session to signed-in while the location is still\n // `/sign-in?redirect=…`, BEFORE `SignInView`'s own post-sign-in navigation\n // runs; the gate's bounce then fires first and must consume the target\n // instead of dropping it on an unconditional `/`. The target is validated\n // by `postSignInTarget` inside `resolveRouteGuard` (same-origin relative\n // paths only — redirect is an attacker-suppliable search param).\n const rawRedirect = (location.search as { redirect?: unknown }).redirect\n const redirect = typeof rawRedirect === 'string' ? rawRedirect : undefined\n const decision = resolveRouteGuard(location.pathname, status, redirect)\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={decision.target} 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
|
@@ -18,8 +18,9 @@ export interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'c
|
|
|
18
18
|
tone: StatusTone;
|
|
19
19
|
/**
|
|
20
20
|
* The accessible + hover/focus-revealed text (e.g. "Published"). Exposed as the
|
|
21
|
-
* `aria-label`
|
|
22
|
-
* keyboard user
|
|
21
|
+
* `aria-label` (screen readers) and shown as a small pill on hover/focus so a
|
|
22
|
+
* mouse OR keyboard user can read it — no native `title` (EC-452: it duplicated
|
|
23
|
+
* the pill, showing the label twice on mouse hover).
|
|
23
24
|
*/
|
|
24
25
|
label: string;
|
|
25
26
|
/**
|
|
@@ -27,8 +28,8 @@ export interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'c
|
|
|
27
28
|
* EC-446 focusable behavior (the versions pane — a few rows — so a keyboard user can
|
|
28
29
|
* focus a dot to reveal its pill). Pass `false` for a DENSE surface (the list
|
|
29
30
|
* `$state` column, EC-448): a long list must not add a tab stop per row. The label
|
|
30
|
-
* stays reachable via `aria-label`
|
|
31
|
-
*
|
|
31
|
+
* stays reachable via `aria-label` (and the hover pill on mouse) — just not via
|
|
32
|
+
* focus. The focus-ring styling is likewise dropped when non-interactive.
|
|
32
33
|
*/
|
|
33
34
|
interactive?: boolean;
|
|
34
35
|
}
|
|
@@ -39,10 +40,12 @@ export interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'c
|
|
|
39
40
|
* list `$state` column). Pure-presentational and domain-agnostic: it knows a
|
|
40
41
|
* `tone` and a `label`, nothing about versions or documents.
|
|
41
42
|
*
|
|
42
|
-
* Accessibility: the wrapper is `role="img"` and carries the label as
|
|
43
|
-
* `aria-label` (screen readers)
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* Accessibility: the wrapper is `role="img"` and carries the label as its
|
|
44
|
+
* `aria-label` (screen readers) — no native `title` (EC-452: pairing one with
|
|
45
|
+
* the CSS pill below made the label show twice on mouse hover; the pill is the
|
|
46
|
+
* one hover surface now). When `interactive` (the default), it is also
|
|
47
|
+
* focusable (`tabIndex=0`) and the same CSS pill reveals the label on
|
|
48
|
+
* `:focus-visible`, so the meaning is reachable without a mouse; a
|
|
46
49
|
* non-interactive dot (EC-448, dense lists) drops the tab stop + focus ring but
|
|
47
50
|
* keeps the name + hover pill. The dot glyph and the pill are `aria-hidden` — the
|
|
48
51
|
* wrapper's `aria-label` is the single announced name.
|
package/dist/ui/status-dot.js
CHANGED
|
@@ -24,15 +24,17 @@ 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 `role="img"` and carries the label as
|
|
28
|
-
* `aria-label` (screen readers)
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* Accessibility: the wrapper is `role="img"` and carries the label as its
|
|
28
|
+
* `aria-label` (screen readers) — no native `title` (EC-452: pairing one with
|
|
29
|
+
* the CSS pill below made the label show twice on mouse hover; the pill is the
|
|
30
|
+
* one hover surface now). When `interactive` (the default), it is also
|
|
31
|
+
* focusable (`tabIndex=0`) and the same CSS pill reveals the label on
|
|
32
|
+
* `:focus-visible`, so the meaning is reachable without a mouse; a
|
|
31
33
|
* non-interactive dot (EC-448, dense lists) drops the tab stop + focus ring but
|
|
32
34
|
* keeps the name + hover pill. The dot glyph and the pill are `aria-hidden` — the
|
|
33
35
|
* wrapper's `aria-label` is the single announced name.
|
|
34
36
|
*/
|
|
35
37
|
export function StatusDot({ tone, label, className, interactive = true, ...props }) {
|
|
36
|
-
return (_jsxs("span", { role: "img", ...(interactive ? { tabIndex: 0 } : {}), "aria-label": label,
|
|
38
|
+
return (_jsxs("span", { role: "img", ...(interactive ? { tabIndex: 0 } : {}), "aria-label": 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 })] }));
|
|
37
39
|
}
|
|
38
40
|
//# 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;AAuBD;;;;;;;;;;;;;;;;GAgBG;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,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` (screen readers) and shown as a small pill on hover/focus so a\n * mouse OR keyboard user can read it — no native `title` (EC-452: it duplicated\n * the pill, showing the label twice on mouse hover).\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` (and the hover pill on mouse) — just not via\n * 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 its\n * `aria-label` (screen readers) — no native `title` (EC-452: pairing one with\n * the CSS pill below made the label show twice on mouse hover; the pill is the\n * one hover surface now). When `interactive` (the default), it is also\n * focusable (`tabIndex=0`) and the same CSS pill reveals the label on\n * `: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 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.77",
|
|
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.77"
|
|
154
154
|
},
|
|
155
155
|
"peerDependencies": {
|
|
156
156
|
"@tanstack/react-router": "^1.170.0",
|