@elytracms/studio 0.0.76 → 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 CHANGED
@@ -1,8 +1,7 @@
1
1
  import { v } from 'convex/values'
2
2
  import { documentsSemanticEqual, foldDocument, unfoldDocument } from '@elytracms/core/cms-core'
3
3
  import type { CmsDocument } from '@elytracms/core/cms-core'
4
- import { backfillHasUnpublishedChanges, backfillPublishedAt } from '@elytracms/core/persistence'
5
- import { internalMutation, mutation, query } from './functions'
4
+ import { mutation, query } from './functions'
6
5
  import { requireCapability } from './guard'
7
6
  import { assertWithinWriteLimits } from './write-limits'
8
7
 
@@ -330,109 +329,14 @@ export const unpublishDocument = mutation({
330
329
  })
331
330
 
332
331
  /**
333
- * EC-447 one-time backfill — close-reconstruct `publishedAt` on documents published
334
- * BEFORE it was materialized. For each currently-published doc that has NONE, sets
335
- * `publishedAt` to its PINNED published version's `createdAt` (the best-available
336
- * approximation of the real publish moment, owner-blessed). The decision is the pure,
337
- * unit-tested `backfillPublishedAt` (`@elytracms/core/persistence`) — one source of
338
- * truth: an already-stamped doc, an unpublished doc, or one whose pinned version row
339
- * is missing is SKIPPED, so a re-run performs ZERO writes (idempotent). Touches ONLY
340
- * `publishedAt` (never `createdAt`/`updatedAt`/the pin), so the write-chokepoint
341
- * trigger fires no revalidation (`publishedVersion` is unchanged — see triggers.ts).
342
- *
343
- * INTERNAL (scheduler/CLI only, EC-150): the operator runs it once per deployment
344
- * after `convex deploy` (e.g. `npx convex run cms:backfillPublishedAtColumn`); it is
345
- * not client-callable. Returns a small audit summary (`{ scanned, stamped }`).
346
- */
347
- export const backfillPublishedAtColumn = internalMutation({
348
- args: {},
349
- handler: async (ctx) => {
350
- const docs = await ctx.db.query('cmsDocuments').collect()
351
- let scanned = 0
352
- let stamped = 0
353
- for (const doc of docs) {
354
- scanned += 1
355
- // Skip the version read for the certain-skip cases (draft / already-stamped);
356
- // otherwise resolve the pinned version and let the pure helper decide.
357
- if (doc.publishedVersion === undefined || doc.publishedAt !== undefined) continue
358
- const pinned = await ctx.db
359
- .query('documentVersions')
360
- .withIndex('by_doc_version', (q) =>
361
- q
362
- .eq('collection', doc.collection)
363
- .eq('docId', doc.docId)
364
- .eq('version', doc.publishedVersion as number),
365
- )
366
- .unique()
367
- const publishedAt = backfillPublishedAt(
368
- { publishedVersion: doc.publishedVersion, publishedAt: doc.publishedAt },
369
- pinned?.createdAt,
370
- )
371
- if (publishedAt === undefined) continue
372
- await ctx.db.patch(doc._id, { publishedAt })
373
- stamped += 1
374
- }
375
- return { scanned, stamped }
376
- },
377
- })
378
-
379
- /**
380
- * EC-449 one-time backfill — materialize `hasUnpublishedChanges` on documents
381
- * published BEFORE the flag was recorded, so the amber "changed" dot is correct
382
- * immediately after the repoint instead of understating until the next save. For
383
- * each currently-published doc that has NO flag yet, runs the SAME semantic
384
- * compare the write chokepoint runs going forward (`documentsSemanticEqual`,
385
- * schema-less lens) of the stored draft against its pinned published snapshot —
386
- * the full compare was chosen over the ratified default-`false` fallback because
387
- * it costs one indexed version read + one compare per unstamped published doc
388
- * (the same order of work as `cms:backfillPublishedAtColumn`). The decision is
389
- * the pure, unit-tested `backfillHasUnpublishedChanges`
390
- * (`@elytracms/core/persistence`): an already-flagged doc, an unpublished doc,
391
- * or one whose pinned version row is missing is SKIPPED, so a re-run performs
392
- * ZERO writes (idempotent). Touches ONLY `hasUnpublishedChanges` (never the
393
- * pin/`document`/timestamps), so the write-chokepoint trigger fires no host
394
- * revalidation (`publishedVersion` unchanged — see triggers.ts).
395
- *
396
- * INTERNAL (scheduler/CLI only, EC-150): the operator runs it once per deployment
397
- * after `convex deploy` (e.g. `npx convex run cms:backfillHasUnpublishedChangesColumn`);
398
- * it is not client-callable. Returns a small audit summary (`{ scanned, stamped }`).
332
+ * One-time data migrations (EC-450 principle): a migration mutation ships
333
+ * transiently — added when a schema/behavior change needs to backfill existing
334
+ * deployments, then REMOVED once every deployment has confirmed running it. It
335
+ * never lingers in the permanent `@elytracms/*` shipped surface (a brand-new
336
+ * consumer never needs it — nothing pre-dates the change it backfills). Pattern:
337
+ * an `internalMutation` here delegating its per-row decision to a pure, unit-tested
338
+ * core in `@elytracms/core/persistence`; the operator runs it once per deployment
339
+ * via `npx convex run cms:<name>` after `convex deploy`, confirms all deployments,
340
+ * then deletes both the mutation and its core in one removal pass (see EC-447/449
341
+ * → EC-450 for the worked example).
399
342
  */
400
- export const backfillHasUnpublishedChangesColumn = internalMutation({
401
- args: {},
402
- handler: async (ctx) => {
403
- const docs = await ctx.db.query('cmsDocuments').collect()
404
- let scanned = 0
405
- let stamped = 0
406
- for (const doc of docs) {
407
- scanned += 1
408
- // Skip the version read for the certain-skip cases (draft / already-flagged);
409
- // otherwise resolve the pinned snapshot, compare once, and let the pure
410
- // helper decide.
411
- if (doc.publishedVersion === undefined || doc.hasUnpublishedChanges !== undefined) continue
412
- const pinned = await ctx.db
413
- .query('documentVersions')
414
- .withIndex('by_doc_version', (q) =>
415
- q
416
- .eq('collection', doc.collection)
417
- .eq('docId', doc.docId)
418
- .eq('version', doc.publishedVersion as number),
419
- )
420
- .unique()
421
- const differs = pinned
422
- ? !documentsSemanticEqual(
423
- undefined,
424
- unfoldDocument(pinned.snapshot as CmsDocument),
425
- unfoldDocument(doc.document as CmsDocument),
426
- )
427
- : undefined
428
- const hasUnpublishedChanges = backfillHasUnpublishedChanges(
429
- { publishedVersion: doc.publishedVersion, hasUnpublishedChanges: doc.hasUnpublishedChanges },
430
- differs,
431
- )
432
- if (hasUnpublishedChanges === undefined) continue
433
- await ctx.db.patch(doc._id, { hasUnpublishedChanges })
434
- stamped += 1
435
- }
436
- return { scanned, stamped }
437
- },
438
- })
package/convex/schema.ts CHANGED
@@ -144,8 +144,9 @@ export default defineSchema({
144
144
  * publish-action ISO timestamp captured AT publish (NOT a revision `createdAt`),
145
145
  * written set-once on publish and cleared on unpublish. A distinct materialized
146
146
  * value alongside `createdAt`/`updatedAt`, backing the `$publishedAt` list column.
147
- * Optional (additive): existing rows stay valid; a one-time backfill
148
- * (`cms:backfillPublishedAt`) close-reconstructs it for pre-EC-447 published docs.
147
+ * Optional (additive): existing rows stay valid. Pre-EC-447 published docs on the
148
+ * three original deployments were close-reconstructed by a one-time backfill,
149
+ * since removed (EC-450 — ship-then-remove; a fresh deployment never needs it).
149
150
  */
150
151
  publishedAt: v.optional(v.string()),
151
152
  /**
@@ -155,9 +156,9 @@ export default defineSchema({
155
156
  * against the pinned published snapshot; publish writes `false`; unpublish
156
157
  * clears it. NOT derived from `updatedAt > publishedAt` (set-once EC-447
157
158
  * semantics would false-positive on every republish). Optional (additive):
158
- * existing rows stay valid; a one-time backfill
159
- * (`cms:backfillHasUnpublishedChangesColumn`) materializes it for
160
- * pre-EC-449 published docs.
159
+ * existing rows stay valid. Pre-EC-449 published docs on the three original
160
+ * deployments were materialized by a one-time backfill, since removed
161
+ * (EC-450 — ship-then-remove; a fresh deployment never needs it).
161
162
  */
162
163
  hasUnpublishedChanges: v.optional(v.boolean()),
163
164
  createdAt: v.string(),
@@ -49,11 +49,12 @@ export declare function docStateLabel(state: DocState): string;
49
49
  * The list `$state` cell indicator (EC-448) — the compact {@link StatusDot}
50
50
  * replacing {@link DocStateBadge} in the collection table's FIXED narrow leading
51
51
  * status column (checkbox → status-dot → title). Tone per {@link stateTone}; the
52
- * localized state label rides the dot's `aria-label`/`title` + hover pill.
52
+ * localized state label rides the dot's `aria-label` + hover pill (EC-452: no
53
+ * native `title` — it duplicated the pill's label on mouse hover).
53
54
  *
54
55
  * Rendered with the NON-focusable `StatusDot` variant (`interactive={false}`) so a
55
56
  * long list adds no tab stop per row (EC-446 operator note); the label stays
56
- * reachable via the accessible name + native `title`. A fixed-size dot (never a
57
+ * reachable via the accessible name + hover pill. A fixed-size dot (never a
57
58
  * text-width badge) is what keeps the title column's left edge consistent across
58
59
  * collections (EC-448 §4 — the alignment fix).
59
60
  */
@@ -57,11 +57,12 @@ export function docStateLabel(state) {
57
57
  * The list `$state` cell indicator (EC-448) — the compact {@link StatusDot}
58
58
  * replacing {@link DocStateBadge} in the collection table's FIXED narrow leading
59
59
  * status column (checkbox → status-dot → title). Tone per {@link stateTone}; the
60
- * localized state label rides the dot's `aria-label`/`title` + hover pill.
60
+ * localized state label rides the dot's `aria-label` + hover pill (EC-452: no
61
+ * native `title` — it duplicated the pill's label on mouse hover).
61
62
  *
62
63
  * Rendered with the NON-focusable `StatusDot` variant (`interactive={false}`) so a
63
64
  * long list adds no tab stop per row (EC-446 operator note); the label stays
64
- * reachable via the accessible name + native `title`. A fixed-size dot (never a
65
+ * reachable via the accessible name + hover pill. A fixed-size dot (never a
65
66
  * text-width badge) is what keeps the title column's left edge consistent across
66
67
  * collections (EC-448 §4 — the alignment fix).
67
68
  */
@@ -1 +1 @@
1
- {"version":3,"file":"doc-state.js","sourceRoot":"","sources":["../../../src/components/cms/doc-state.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAEvD,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAAC,EAAE,SAAS,EAA0B;IACjE,OAAO,SAAS,CAAC,CAAC,CAAC,CACjB,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,GAAG,IAAC,SAAS,EAAC,QAAQ,GAAG,OAAE,SAAS,CAAC,wBAAwB,CAAC,IACzD,CACT,CAAC,CAAC,CAAC,CACF,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,MAAM,IAAC,SAAS,EAAC,QAAQ,GAAG,OAAE,SAAS,CAAC,oBAAoB,CAAC,IACxD,CACT,CAAA;AACH,CAAC;AAoBD;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,SAAkB,EAAE,qBAA8B;IAC3E,IAAI,CAAC,SAAS;QAAE,OAAO,OAAO,CAAA;IAC9B,OAAO,qBAAqB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAA;AACxD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,KAAe;IACvC,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,WAAW;YACd,OAAO,SAAS,CAAA;QAClB,KAAK,SAAS;YACZ,OAAO,SAAS,CAAA;QAClB,KAAK,OAAO;YACV,OAAO,OAAO,CAAA;IAClB,CAAC;AACH,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,aAAa,CAAC,KAAe;IAC3C,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,WAAW;YACd,OAAO,SAAS,CAAC,wBAAwB,CAAC,CAAA;QAC5C,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC,sBAAsB,CAAC,CAAA;QAC1C,KAAK,OAAO;YACV,OAAO,SAAS,CAAC,oBAAoB,CAAC,CAAA;IAC1C,CAAC;AACH,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,WAAW,CAAC,EAAE,KAAK,EAAuB;IACxD,OAAO,KAAC,SAAS,IAAC,IAAI,EAAE,SAAS,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,aAAa,CAAC,KAAK,CAAC,EAAE,WAAW,EAAE,KAAK,GAAI,CAAA;AAC/F,CAAC","sourcesContent":["import { Eye, EyeOff } from 'lucide-react'\nimport { Badge, StatusDot } from '@elytracms/studio/ui'\nimport type { StatusTone } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * Draft vs published indicator (brief §8.4, EC-077). Published-ness is the\n * version pointer (EC-224) — the caller passes `cms.isPublished(ref)`, not the\n * flat `state` enum. Copy is localized (EC-241/EC-346) through the message\n * catalog — never hardcoded English.\n *\n * Still used by the detail-editor header (a single prominent badge). The dense\n * LIST `$state` column swapped to the compact {@link DocStateDot} (EC-448); this\n * badge is untouched for its non-list consumers.\n */\nexport function DocStateBadge({ published }: { published: boolean }) {\n return published ? (\n <Badge variant=\"success\">\n <Eye className=\"size-3\" /> {translate('cms.docState.published')}\n </Badge>\n ) : (\n <Badge variant=\"warning\">\n <EyeOff className=\"size-3\" /> {translate('cms.docState.draft')}\n </Badge>\n )\n}\n\n/**\n * The publication-state vocabulary a document's status expresses (EC-448 — owner\n * ratified 2026-07-17). Three states:\n *\n * - `published` — live, no pending changes.\n * - `changed` — published, but the working draft has unpublished changes (\"Changed\").\n * - `draft` — never published / currently unpublished.\n *\n * Since EC-449 the list feeds all THREE: `changed` comes from the cheap per-row\n * `hasUnpublishedChanges` signal (materialized at the write chokepoint via the\n * editor's own snapshot-vs-draft semantic compare — never re-derived per render,\n * and never the `updatedAt > publishedAt` heuristic, which false-positives on\n * every republish). {@link docStateOf} is the one derivation. This is the exact\n * `DocStateBadge` published/draft vocabulary, plus the ratified `changed` middle\n * state — nothing re-derived.\n */\nexport type DocState = 'published' | 'changed' | 'draft'\n\n/**\n * Derive a document's {@link DocState} from the two cheap per-row signals\n * (EC-449, the ratified three-way): not published → `draft`; published with\n * unpublished draft changes → `changed` (amber); else `published` (green).\n * Pure — the single derivation the list's status cell feeds `DocStateDot` with.\n */\nexport function docStateOf(published: boolean, hasUnpublishedChanges: boolean): DocState {\n if (!published) return 'draft'\n return hasUnpublishedChanges ? 'changed' : 'published'\n}\n\n/**\n * Map a doc-state to the shared {@link StatusTone} (EC-448) — pure, so it unit-tests\n * for every state without a DOM. The ratified mapping (owner, 2026-07-17): published\n * → `success` (green), changed → `warning` (amber), draft → `muted` (quiet, present\n * but low-key).\n */\nexport function stateTone(state: DocState): StatusTone {\n switch (state) {\n case 'published':\n return 'success'\n case 'changed':\n return 'warning'\n case 'draft':\n return 'muted'\n }\n}\n\n/** The localized label for a doc-state (EC-448) — the dot's hover/`aria` text. */\nexport function docStateLabel(state: DocState): string {\n switch (state) {\n case 'published':\n return translate('cms.docState.published')\n case 'changed':\n return translate('cms.docState.changed')\n case 'draft':\n return translate('cms.docState.draft')\n }\n}\n\n/**\n * The list `$state` cell indicator (EC-448) — the compact {@link StatusDot}\n * replacing {@link DocStateBadge} in the collection table's FIXED narrow leading\n * status column (checkbox → status-dot → title). Tone per {@link stateTone}; the\n * localized state label rides the dot's `aria-label`/`title` + hover pill.\n *\n * Rendered with the NON-focusable `StatusDot` variant (`interactive={false}`) so a\n * long list adds no tab stop per row (EC-446 operator note); the label stays\n * reachable via the accessible name + native `title`. A fixed-size dot (never a\n * text-width badge) is what keeps the title column's left edge consistent across\n * collections (EC-448 §4 — the alignment fix).\n */\nexport function DocStateDot({ state }: { state: DocState }) {\n return <StatusDot tone={stateTone(state)} label={docStateLabel(state)} interactive={false} />\n}\n"]}
1
+ {"version":3,"file":"doc-state.js","sourceRoot":"","sources":["../../../src/components/cms/doc-state.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAEvD,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAAC,EAAE,SAAS,EAA0B;IACjE,OAAO,SAAS,CAAC,CAAC,CAAC,CACjB,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,GAAG,IAAC,SAAS,EAAC,QAAQ,GAAG,OAAE,SAAS,CAAC,wBAAwB,CAAC,IACzD,CACT,CAAC,CAAC,CAAC,CACF,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,MAAM,IAAC,SAAS,EAAC,QAAQ,GAAG,OAAE,SAAS,CAAC,oBAAoB,CAAC,IACxD,CACT,CAAA;AACH,CAAC;AAoBD;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,SAAkB,EAAE,qBAA8B;IAC3E,IAAI,CAAC,SAAS;QAAE,OAAO,OAAO,CAAA;IAC9B,OAAO,qBAAqB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAA;AACxD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,KAAe;IACvC,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,WAAW;YACd,OAAO,SAAS,CAAA;QAClB,KAAK,SAAS;YACZ,OAAO,SAAS,CAAA;QAClB,KAAK,OAAO;YACV,OAAO,OAAO,CAAA;IAClB,CAAC;AACH,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,aAAa,CAAC,KAAe;IAC3C,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,WAAW;YACd,OAAO,SAAS,CAAC,wBAAwB,CAAC,CAAA;QAC5C,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC,sBAAsB,CAAC,CAAA;QAC1C,KAAK,OAAO;YACV,OAAO,SAAS,CAAC,oBAAoB,CAAC,CAAA;IAC1C,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,WAAW,CAAC,EAAE,KAAK,EAAuB;IACxD,OAAO,KAAC,SAAS,IAAC,IAAI,EAAE,SAAS,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,aAAa,CAAC,KAAK,CAAC,EAAE,WAAW,EAAE,KAAK,GAAI,CAAA;AAC/F,CAAC","sourcesContent":["import { Eye, EyeOff } from 'lucide-react'\nimport { Badge, StatusDot } from '@elytracms/studio/ui'\nimport type { StatusTone } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * Draft vs published indicator (brief §8.4, EC-077). Published-ness is the\n * version pointer (EC-224) — the caller passes `cms.isPublished(ref)`, not the\n * flat `state` enum. Copy is localized (EC-241/EC-346) through the message\n * catalog — never hardcoded English.\n *\n * Still used by the detail-editor header (a single prominent badge). The dense\n * LIST `$state` column swapped to the compact {@link DocStateDot} (EC-448); this\n * badge is untouched for its non-list consumers.\n */\nexport function DocStateBadge({ published }: { published: boolean }) {\n return published ? (\n <Badge variant=\"success\">\n <Eye className=\"size-3\" /> {translate('cms.docState.published')}\n </Badge>\n ) : (\n <Badge variant=\"warning\">\n <EyeOff className=\"size-3\" /> {translate('cms.docState.draft')}\n </Badge>\n )\n}\n\n/**\n * The publication-state vocabulary a document's status expresses (EC-448 — owner\n * ratified 2026-07-17). Three states:\n *\n * - `published` — live, no pending changes.\n * - `changed` — published, but the working draft has unpublished changes (\"Changed\").\n * - `draft` — never published / currently unpublished.\n *\n * Since EC-449 the list feeds all THREE: `changed` comes from the cheap per-row\n * `hasUnpublishedChanges` signal (materialized at the write chokepoint via the\n * editor's own snapshot-vs-draft semantic compare — never re-derived per render,\n * and never the `updatedAt > publishedAt` heuristic, which false-positives on\n * every republish). {@link docStateOf} is the one derivation. This is the exact\n * `DocStateBadge` published/draft vocabulary, plus the ratified `changed` middle\n * state — nothing re-derived.\n */\nexport type DocState = 'published' | 'changed' | 'draft'\n\n/**\n * Derive a document's {@link DocState} from the two cheap per-row signals\n * (EC-449, the ratified three-way): not published → `draft`; published with\n * unpublished draft changes → `changed` (amber); else `published` (green).\n * Pure — the single derivation the list's status cell feeds `DocStateDot` with.\n */\nexport function docStateOf(published: boolean, hasUnpublishedChanges: boolean): DocState {\n if (!published) return 'draft'\n return hasUnpublishedChanges ? 'changed' : 'published'\n}\n\n/**\n * Map a doc-state to the shared {@link StatusTone} (EC-448) — pure, so it unit-tests\n * for every state without a DOM. The ratified mapping (owner, 2026-07-17): published\n * → `success` (green), changed → `warning` (amber), draft → `muted` (quiet, present\n * but low-key).\n */\nexport function stateTone(state: DocState): StatusTone {\n switch (state) {\n case 'published':\n return 'success'\n case 'changed':\n return 'warning'\n case 'draft':\n return 'muted'\n }\n}\n\n/** The localized label for a doc-state (EC-448) — the dot's hover/`aria` text. */\nexport function docStateLabel(state: DocState): string {\n switch (state) {\n case 'published':\n return translate('cms.docState.published')\n case 'changed':\n return translate('cms.docState.changed')\n case 'draft':\n return translate('cms.docState.draft')\n }\n}\n\n/**\n * The list `$state` cell indicator (EC-448) — the compact {@link StatusDot}\n * replacing {@link DocStateBadge} in the collection table's FIXED narrow leading\n * status column (checkbox → status-dot → title). Tone per {@link stateTone}; the\n * localized state label rides the dot's `aria-label` + hover pill (EC-452: no\n * native `title` — it duplicated the pill's label on mouse hover).\n *\n * Rendered with the NON-focusable `StatusDot` variant (`interactive={false}`) so a\n * long list adds no tab stop per row (EC-446 operator note); the label stays\n * reachable via the accessible name + hover pill. A fixed-size dot (never a\n * text-width badge) is what keeps the title column's left edge consistent across\n * collections (EC-448 §4 — the alignment fix).\n */\nexport function DocStateDot({ state }: { state: DocState }) {\n return <StatusDot tone={stateTone(state)} label={docStateLabel(state)} interactive={false} />\n}\n"]}
@@ -22,12 +22,33 @@ export type RouteGuardDecision =
22
22
  readonly kind: 'redirect-to-sign-in';
23
23
  readonly redirect: string;
24
24
  }
25
- /** Authenticated user on the sign-in screen → send them home (or back). */
25
+ /**
26
+ * Authenticated user on the sign-in screen → send them to the validated
27
+ * still-pending `?redirect` target, falling back home (EC-451). Carrying
28
+ * the target IN the decision is what makes the post-sign-in navigation
29
+ * order-independent: whichever navigation wins the race — `SignInView`'s
30
+ * own `postSignInTarget` navigate or this gate bounce (dev auto-login, a
31
+ * fast real sign-in) — both compute the same validated target.
32
+ */
26
33
  | {
27
34
  readonly kind: 'redirect-home';
35
+ readonly target: string;
28
36
  };
29
- export declare function resolveRouteGuard(pathname: string, status: AuthStatus): RouteGuardDecision;
30
- /** Where to navigate after a successful sign-in. Rejects external targets. */
37
+ export declare function resolveRouteGuard(pathname: string, status: AuthStatus, redirect?: string): RouteGuardDecision;
38
+ /**
39
+ * Where to navigate after a successful sign-in. The ONE validator for the
40
+ * `?redirect` search param — an attacker-suppliable input — shared by
41
+ * `SignInView`'s post-sign-in navigate and the `AuthGate`'s signed-in bounce
42
+ * (EC-451). Rejects everything but a same-origin relative path:
43
+ *
44
+ * - no leading `/` → absolute URLs (`https://evil.example`), `javascript:` …;
45
+ * - `//…` → protocol-relative external URLs;
46
+ * - any `\` → browsers normalize `\`→`/` when resolving a URL, so
47
+ * `/\evil.example` would become `//evil.example` (EC-304 B-3 precedent);
48
+ * - any tab/CR/LF → the WHATWG URL parser strips them, so `/\t\/evil.example`
49
+ * would collapse to `//evil.example`;
50
+ * - the sign-in screen itself → circular.
51
+ */
31
52
  export declare function postSignInTarget(redirect: string | undefined): string;
32
53
  /** Map a session onto the operations-layer {@link Principal}. */
33
54
  export declare function sessionPrincipal(session: AuthSession): Principal;
@@ -13,7 +13,7 @@ const PUBLIC_PATHS = new Set([SIGN_IN_PATH, '/styleguide']);
13
13
  export function isPublicPath(pathname) {
14
14
  return PUBLIC_PATHS.has(normalize(pathname));
15
15
  }
16
- export function resolveRouteGuard(pathname, status) {
16
+ export function resolveRouteGuard(pathname, status, redirect) {
17
17
  const path = normalize(pathname);
18
18
  if (status === 'loading') {
19
19
  // Public paths render immediately; protected ones wait for the session.
@@ -26,15 +26,36 @@ export function resolveRouteGuard(pathname, status) {
26
26
  }
27
27
  // signed-in
28
28
  if (path === SIGN_IN_PATH)
29
- return { kind: 'redirect-home' };
29
+ return { kind: 'redirect-home', target: postSignInTarget(redirect) };
30
30
  return { kind: 'allow' };
31
31
  }
32
- /** Where to navigate after a successful sign-in. Rejects external targets. */
32
+ /**
33
+ * Where to navigate after a successful sign-in. The ONE validator for the
34
+ * `?redirect` search param — an attacker-suppliable input — shared by
35
+ * `SignInView`'s post-sign-in navigate and the `AuthGate`'s signed-in bounce
36
+ * (EC-451). Rejects everything but a same-origin relative path:
37
+ *
38
+ * - no leading `/` → absolute URLs (`https://evil.example`), `javascript:` …;
39
+ * - `//…` → protocol-relative external URLs;
40
+ * - any `\` → browsers normalize `\`→`/` when resolving a URL, so
41
+ * `/\evil.example` would become `//evil.example` (EC-304 B-3 precedent);
42
+ * - any tab/CR/LF → the WHATWG URL parser strips them, so `/\t\/evil.example`
43
+ * would collapse to `//evil.example`;
44
+ * - the sign-in screen itself → circular.
45
+ */
33
46
  export function postSignInTarget(redirect) {
34
- if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//'))
47
+ if (!redirect || !isSameOriginPath(redirect))
35
48
  return '/';
36
49
  return normalize(redirect) === SIGN_IN_PATH ? '/' : redirect;
37
50
  }
51
+ /** Same-origin relative-path check for {@link postSignInTarget}. */
52
+ function isSameOriginPath(target) {
53
+ if (!target.startsWith('/') || target.startsWith('//'))
54
+ return false;
55
+ // No legitimate studio path contains a backslash or a strippable control
56
+ // character — reject outright instead of normalizing (fallback is home).
57
+ return !/[\\\t\r\n]/.test(target);
58
+ }
38
59
  /** Map a session onto the operations-layer {@link Principal}. */
39
60
  export function sessionPrincipal(session) {
40
61
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"route-guard.js","sourceRoot":"","sources":["../../../src/lib/auth/route-guard.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAA;AAEtC;;;;GAIG;AACH,MAAM,YAAY,GAAwB,IAAI,GAAG,CAAC,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC,CAAA;AAEhF,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,OAAO,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAA;AAC9C,CAAC;AAcD,MAAM,UAAU,iBAAiB,CAAC,QAAgB,EAAE,MAAkB;IACpE,MAAM,IAAI,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAA;IAChC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,wEAAwE;QACxE,OAAO,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAA;IACrE,CAAC;IACD,IAAI,MAAM,KAAK,YAAY,EAAE,CAAC;QAC5B,IAAI,YAAY,CAAC,IAAI,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAA;QAChD,OAAO,EAAE,IAAI,EAAE,qBAAqB,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAA;IACxD,CAAC;IACD,YAAY;IACZ,IAAI,IAAI,KAAK,YAAY;QAAE,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,CAAA;IAC3D,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAA;AAC1B,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,gBAAgB,CAAC,QAA4B;IAC3D,IAAI,CAAC,QAAQ,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,GAAG,CAAA;IACnF,OAAO,SAAS,CAAC,QAAQ,CAAC,KAAK,YAAY,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAA;AAC9D,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,gBAAgB,CAAC,OAAoB;IACnD,OAAO;QACL,EAAE,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE;QACnB,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzD,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACzE,CAAA;AACH,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB;IACjC,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC/E,OAAO,QAAQ,CAAA;AACjB,CAAC","sourcesContent":["import type { Principal } from '@elytracms/core/operations'\nimport type { AuthSession } from './auth-adapter'\n\n/**\n * Pure route-guard + principal-mapping logic (EC-150), kept free of React and\n * router types so it is unit-testable and deterministic. The `__root` layout\n * applies {@link resolveRouteGuard} on every location change.\n */\n\nexport const SIGN_IN_PATH = '/sign-in'\n\n/**\n * Paths reachable without a session: the sign-in screen itself and the\n * component styleguide (a static internal reference, not a project surface).\n * Everything else — project surfaces, the launcher — requires a session.\n */\nconst PUBLIC_PATHS: ReadonlySet<string> = new Set([SIGN_IN_PATH, '/styleguide'])\n\nexport function isPublicPath(pathname: string): boolean {\n return PUBLIC_PATHS.has(normalize(pathname))\n}\n\nexport type AuthStatus = 'loading' | 'signed-in' | 'signed-out'\n\nexport type RouteGuardDecision =\n /** Render the route. */\n | { readonly kind: 'allow' }\n /** Session still resolving — render the deterministic checking state. */\n | { readonly kind: 'pending' }\n /** Unauthenticated on a protected path → sign-in, preserving the target. */\n | { readonly kind: 'redirect-to-sign-in'; readonly redirect: string }\n /** Authenticated user on the sign-in screen → send them home (or back). */\n | { readonly kind: 'redirect-home' }\n\nexport function resolveRouteGuard(pathname: string, status: AuthStatus): RouteGuardDecision {\n const path = normalize(pathname)\n if (status === 'loading') {\n // Public paths render immediately; protected ones wait for the session.\n return isPublicPath(path) ? { kind: 'allow' } : { kind: 'pending' }\n }\n if (status === 'signed-out') {\n if (isPublicPath(path)) return { kind: 'allow' }\n return { kind: 'redirect-to-sign-in', redirect: path }\n }\n // signed-in\n if (path === SIGN_IN_PATH) return { kind: 'redirect-home' }\n return { kind: 'allow' }\n}\n\n/** Where to navigate after a successful sign-in. Rejects external targets. */\nexport function postSignInTarget(redirect: string | undefined): string {\n if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) return '/'\n return normalize(redirect) === SIGN_IN_PATH ? '/' : redirect\n}\n\n/** Map a session onto the operations-layer {@link Principal}. */\nexport function sessionPrincipal(session: AuthSession): Principal {\n return {\n id: session.user.id,\n ...(session.user.name ? { name: session.user.name } : {}),\n ...(session.user.globalFlags ? { roles: session.user.globalFlags } : {}),\n }\n}\n\nfunction normalize(pathname: string): string {\n if (pathname.length > 1 && pathname.endsWith('/')) return pathname.slice(0, -1)\n return pathname\n}\n"]}
1
+ {"version":3,"file":"route-guard.js","sourceRoot":"","sources":["../../../src/lib/auth/route-guard.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAA;AAEtC;;;;GAIG;AACH,MAAM,YAAY,GAAwB,IAAI,GAAG,CAAC,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC,CAAA;AAEhF,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,OAAO,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAA;AAC9C,CAAC;AAqBD,MAAM,UAAU,iBAAiB,CAC/B,QAAgB,EAChB,MAAkB,EAClB,QAAiB;IAEjB,MAAM,IAAI,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAA;IAChC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,wEAAwE;QACxE,OAAO,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAA;IACrE,CAAC;IACD,IAAI,MAAM,KAAK,YAAY,EAAE,CAAC;QAC5B,IAAI,YAAY,CAAC,IAAI,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAA;QAChD,OAAO,EAAE,IAAI,EAAE,qBAAqB,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAA;IACxD,CAAC;IACD,YAAY;IACZ,IAAI,IAAI,KAAK,YAAY;QAAE,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,EAAE,gBAAgB,CAAC,QAAQ,CAAC,EAAE,CAAA;IAC/F,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAA;AAC1B,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,gBAAgB,CAAC,QAA4B;IAC3D,IAAI,CAAC,QAAQ,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC;QAAE,OAAO,GAAG,CAAA;IACxD,OAAO,SAAS,CAAC,QAAQ,CAAC,KAAK,YAAY,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAA;AAC9D,CAAC;AAED,oEAAoE;AACpE,SAAS,gBAAgB,CAAC,MAAc;IACtC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAA;IACpE,yEAAyE;IACzE,yEAAyE;IACzE,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;AACnC,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,gBAAgB,CAAC,OAAoB;IACnD,OAAO;QACL,EAAE,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE;QACnB,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzD,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACzE,CAAA;AACH,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB;IACjC,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC/E,OAAO,QAAQ,CAAA;AACjB,CAAC","sourcesContent":["import type { Principal } from '@elytracms/core/operations'\nimport type { AuthSession } from './auth-adapter'\n\n/**\n * Pure route-guard + principal-mapping logic (EC-150), kept free of React and\n * router types so it is unit-testable and deterministic. The `__root` layout\n * applies {@link resolveRouteGuard} on every location change.\n */\n\nexport const SIGN_IN_PATH = '/sign-in'\n\n/**\n * Paths reachable without a session: the sign-in screen itself and the\n * component styleguide (a static internal reference, not a project surface).\n * Everything else — project surfaces, the launcher — requires a session.\n */\nconst PUBLIC_PATHS: ReadonlySet<string> = new Set([SIGN_IN_PATH, '/styleguide'])\n\nexport function isPublicPath(pathname: string): boolean {\n return PUBLIC_PATHS.has(normalize(pathname))\n}\n\nexport type AuthStatus = 'loading' | 'signed-in' | 'signed-out'\n\nexport type RouteGuardDecision =\n /** Render the route. */\n | { readonly kind: 'allow' }\n /** Session still resolving — render the deterministic checking state. */\n | { readonly kind: 'pending' }\n /** Unauthenticated on a protected path → sign-in, preserving the target. */\n | { readonly kind: 'redirect-to-sign-in'; readonly redirect: string }\n /**\n * Authenticated user on the sign-in screen → send them to the validated\n * still-pending `?redirect` target, falling back home (EC-451). Carrying\n * the target IN the decision is what makes the post-sign-in navigation\n * order-independent: whichever navigation wins the race — `SignInView`'s\n * own `postSignInTarget` navigate or this gate bounce (dev auto-login, a\n * fast real sign-in) — both compute the same validated target.\n */\n | { readonly kind: 'redirect-home'; readonly target: string }\n\nexport function resolveRouteGuard(\n pathname: string,\n status: AuthStatus,\n redirect?: string,\n): RouteGuardDecision {\n const path = normalize(pathname)\n if (status === 'loading') {\n // Public paths render immediately; protected ones wait for the session.\n return isPublicPath(path) ? { kind: 'allow' } : { kind: 'pending' }\n }\n if (status === 'signed-out') {\n if (isPublicPath(path)) return { kind: 'allow' }\n return { kind: 'redirect-to-sign-in', redirect: path }\n }\n // signed-in\n if (path === SIGN_IN_PATH) return { kind: 'redirect-home', target: postSignInTarget(redirect) }\n return { kind: 'allow' }\n}\n\n/**\n * Where to navigate after a successful sign-in. The ONE validator for the\n * `?redirect` search param — an attacker-suppliable input — shared by\n * `SignInView`'s post-sign-in navigate and the `AuthGate`'s signed-in bounce\n * (EC-451). Rejects everything but a same-origin relative path:\n *\n * - no leading `/` → absolute URLs (`https://evil.example`), `javascript:` …;\n * - `//…` → protocol-relative external URLs;\n * - any `\\` → browsers normalize `\\`→`/` when resolving a URL, so\n * `/\\evil.example` would become `//evil.example` (EC-304 B-3 precedent);\n * - any tab/CR/LF → the WHATWG URL parser strips them, so `/\\t\\/evil.example`\n * would collapse to `//evil.example`;\n * - the sign-in screen itself → circular.\n */\nexport function postSignInTarget(redirect: string | undefined): string {\n if (!redirect || !isSameOriginPath(redirect)) return '/'\n return normalize(redirect) === SIGN_IN_PATH ? '/' : redirect\n}\n\n/** Same-origin relative-path check for {@link postSignInTarget}. */\nfunction isSameOriginPath(target: string): boolean {\n if (!target.startsWith('/') || target.startsWith('//')) return false\n // No legitimate studio path contains a backslash or a strippable control\n // character — reject outright instead of normalizing (fallback is home).\n return !/[\\\\\\t\\r\\n]/.test(target)\n}\n\n/** Map a session onto the operations-layer {@link Principal}. */\nexport function sessionPrincipal(session: AuthSession): Principal {\n return {\n id: session.user.id,\n ...(session.user.name ? { name: session.user.name } : {}),\n ...(session.user.globalFlags ? { roles: session.user.globalFlags } : {}),\n }\n}\n\nfunction normalize(pathname: string): string {\n if (pathname.length > 1 && pathname.endsWith('/')) return pathname.slice(0, -1)\n return pathname\n}\n"]}
@@ -53,8 +53,18 @@ export function createStudioRootRoute(options) {
53
53
  */
54
54
  function AuthGate({ children }) {
55
55
  const { status } = useAuth();
56
- const { pathname } = useLocation();
57
- const decision = resolveRouteGuard(pathname, status);
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);
58
68
  if (decision.kind === 'pending')
59
69
  return _jsx(SessionLoading, {});
60
70
  if (decision.kind === 'redirect-to-sign-in') {
@@ -64,7 +74,7 @@ function AuthGate({ children }) {
64
74
  return _jsx(NavigateOnce, { to: SIGN_IN_PATH, search: { redirect: decision.redirect }, replace: true });
65
75
  }
66
76
  if (decision.kind === 'redirect-home')
67
- return _jsx(NavigateOnce, { to: "/", replace: true });
77
+ return _jsx(NavigateOnce, { to: decision.target, replace: true });
68
78
  return children;
69
79
  }
70
80
  /** Deterministic session-restore state (also the SSR output for protected paths). */
@@ -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 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"]}
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"]}
@@ -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` and native `title`, and shown as a small pill on hover/focus so a
22
- * keyboard user (no mouse) can still read it.
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` + native `title` (and the hover pill on mouse) —
31
- * just not via focus. The focus-ring styling is likewise dropped when non-interactive.
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 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
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.
@@ -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 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
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, 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 })] }));
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;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"]}
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.76",
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.76"
153
+ "@elytracms/core": "^0.0.77"
154
154
  },
155
155
  "peerDependencies": {
156
156
  "@tanstack/react-router": "^1.170.0",