@drawnagency/primitives 0.1.93 → 0.1.95

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.
Files changed (84) hide show
  1. package/dist/{chunk-UFOWXOG6.js → chunk-ABV7OZB3.js} +6 -0
  2. package/dist/{chunk-LQQY2RW6.js → chunk-DJAJRBPD.js} +1 -0
  3. package/dist/{chunk-677NTHAZ.js → chunk-PTOLUMGV.js} +3 -0
  4. package/dist/{chunk-RBKBNQGB.js → chunk-QQBBYQYD.js} +7 -6
  5. package/dist/{chunk-YI2HUFE3.js → chunk-RQPHPAT2.js} +1 -1
  6. package/dist/chunk-VAOW4WXQ.js +15 -0
  7. package/dist/{chunk-XYFZ57AS.js → chunk-YUUUCDXN.js} +59 -5
  8. package/dist/{chunk-HLKAQLTV.js → chunk-ZKGMLLUQ.js} +1 -1
  9. package/dist/components/brandguide/Colors.d.ts +2 -2
  10. package/dist/components/brandguide/Colors.d.ts.map +1 -1
  11. package/dist/components/brandguide/ColorsEditable.d.ts +2 -2
  12. package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -1
  13. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  14. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  15. package/dist/components/editor/index.js +4 -3
  16. package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
  17. package/dist/components/primitives/index.js +2 -2
  18. package/dist/components/sections/Container/Container.d.ts +2 -2
  19. package/dist/components/sections/Container/Container.d.ts.map +1 -1
  20. package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -1
  21. package/dist/components/sections/brand-guide.js +2 -2
  22. package/dist/components/sections/register-schemas.js +5 -5
  23. package/dist/components/sections/register.js +5 -5
  24. package/dist/components/shared/HistoryChangesModal.d.ts +39 -0
  25. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -0
  26. package/dist/components/shared/HistoryPopover.d.ts +3 -1
  27. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  28. package/dist/components/shared/Navigation.d.ts.map +1 -1
  29. package/dist/components/shared/Popover.d.ts +10 -1
  30. package/dist/components/shared/Popover.d.ts.map +1 -1
  31. package/dist/components/shell/EditorModal.d.ts +6 -1
  32. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  33. package/dist/components/shell/EditorModalContext.d.ts +3 -2
  34. package/dist/components/shell/EditorModalContext.d.ts.map +1 -1
  35. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  36. package/dist/hooks/useEditorPublish.d.ts +5 -1
  37. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  38. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  39. package/dist/index.js +9 -12
  40. package/dist/lib/events.d.ts +18 -0
  41. package/dist/lib/events.d.ts.map +1 -1
  42. package/dist/lib/history-buckets.d.ts +30 -0
  43. package/dist/lib/history-buckets.d.ts.map +1 -0
  44. package/dist/lib/index.d.ts +1 -1
  45. package/dist/lib/index.d.ts.map +1 -1
  46. package/dist/lib/index.js +4 -2
  47. package/dist/lib/media-folders.d.ts +13 -0
  48. package/dist/lib/media-folders.d.ts.map +1 -1
  49. package/dist/lib/media-folders.js +172 -0
  50. package/dist/lib/registry.d.ts +7 -1
  51. package/dist/lib/registry.d.ts.map +1 -1
  52. package/dist/lib/theme-vars.d.ts +19 -4
  53. package/dist/lib/theme-vars.d.ts.map +1 -1
  54. package/dist/storage/index.d.ts +1 -1
  55. package/dist/storage/index.d.ts.map +1 -1
  56. package/dist/storage/index.js +9 -0
  57. package/dist/storage/types.d.ts +14 -0
  58. package/dist/storage/types.d.ts.map +1 -1
  59. package/package.json +9 -1
  60. package/src/components/brandguide/Colors.tsx +2 -1
  61. package/src/components/brandguide/ColorsEditable.tsx +2 -1
  62. package/src/components/editor/SectionWrapper.tsx +1 -0
  63. package/src/components/editor/SettingsForm.tsx +5 -2
  64. package/src/components/primitives/ResolvedMedia.tsx +6 -0
  65. package/src/components/sections/Container/Container.tsx +5 -4
  66. package/src/components/sections/Video/VideoBlock.tsx +9 -1
  67. package/src/components/shared/HistoryChangesModal.tsx +256 -0
  68. package/src/components/shared/HistoryPopover.tsx +255 -76
  69. package/src/components/shared/Navigation.tsx +30 -9
  70. package/src/components/shared/Popover.tsx +95 -2
  71. package/src/components/shell/EditorModal.tsx +26 -4
  72. package/src/components/shell/EditorModalContext.tsx +5 -4
  73. package/src/components/shell/EditorShell.tsx +51 -2
  74. package/src/components/shell/SiteSettingsModal.tsx +1 -1
  75. package/src/hooks/useEditorPublish.ts +15 -6
  76. package/src/hooks/useMediaPipeline.ts +4 -0
  77. package/src/lib/events.ts +11 -0
  78. package/src/lib/history-buckets.ts +190 -0
  79. package/src/lib/index.ts +1 -1
  80. package/src/lib/media-folders.ts +16 -0
  81. package/src/lib/registry.ts +9 -1
  82. package/src/lib/theme-vars.ts +25 -4
  83. package/src/storage/index.ts +1 -1
  84. package/src/storage/types.ts +15 -0
@@ -37,7 +37,8 @@ import { SectionTypePicker } from "./SectionTypePicker";
37
37
  import { ensureSectionsRegistered } from "../sections/register";
38
38
  import { getSection, getAllSections, getSchema } from "../../lib/registry";
39
39
  import { cornerRadiusValue } from "../../lib/corner-radius";
40
- import { primaryVarValue, accentVarValue } from "../../lib/theme-vars";
40
+ import { primaryVarValue, accentVarValue, onPrimaryVarValue } from "../../lib/theme-vars";
41
+ import { rebaseMediaManifest } from "../../lib/media-folders";
41
42
  import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
42
43
  import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
43
44
 
@@ -88,7 +89,7 @@ import { useBuildStatus } from "../../hooks/useBuildStatus";
88
89
  import { useMediaPipeline } from "../../hooks/useMediaPipeline";
89
90
  import { formatTimestamp } from "../../lib/timestamp";
90
91
  import { generateSiteNav } from "../../lib/nav";
91
- import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
92
+ import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
92
93
  import {
93
94
  homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
94
95
  reorderSectionInPage, audiencePasses,
@@ -260,6 +261,10 @@ export default function EditorShell({
260
261
  // writes onto the latest config instead of a stale closure.
261
262
  const siteConfigRef = useRef<SiteConfig | null>(siteConfig);
262
263
  useEffect(() => { siteConfigRef.current = siteConfig; }, [siteConfig]);
264
+ // Mirrors mediaManifest so recoverConflict can rebase it synchronously and the
265
+ // immediate 409 retry assembles the saved manifest against the rebased copy.
266
+ const mediaManifestRef = useRef<MediaManifest>(mediaManifest);
267
+ useEffect(() => { mediaManifestRef.current = mediaManifest; }, [mediaManifest]);
263
268
  // Guards the debounced preview-image composition effect (below): skips
264
269
  // recomposing on the very first run when a preview already exists, and
265
270
  // dedupes concurrent runs against the same composition signature.
@@ -487,6 +492,11 @@ export default function EditorShell({
487
492
  if (applied.droppedDirtyIds.length > 0) await deleteSectionRows(applied.droppedDirtyIds);
488
493
  }
489
494
 
495
+ const rebasedManifest = remote.mediaManifest
496
+ ? rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest)
497
+ : null;
498
+ if (rebasedManifest) setMediaManifest(rebasedManifest);
499
+
490
500
  setSections(mergedSections);
491
501
  setSiteIndex(mergedIndex);
492
502
  setSavedSha(remote.savedBranchSha);
@@ -497,6 +507,7 @@ export default function EditorShell({
497
507
  // (and onBaselineCommitted reads the diff refs right after it), before
498
508
  // React flushes the setState calls above into their mirror effects.
499
509
  savedShaRef.current = remote.savedBranchSha;
510
+ if (rebasedManifest) mediaManifestRef.current = rebasedManifest;
500
511
  sectionsRef.current = mergedSections;
501
512
  siteIndexRef.current = mergedIndex;
502
513
  changedSectionIdsRef.current = new Set(remote.changedSectionIds);
@@ -581,6 +592,11 @@ export default function EditorShell({
581
592
  });
582
593
 
583
594
  // The rebased result goes into React state (live view)...
595
+ if (remote.mediaManifest) {
596
+ const rebasedManifest = rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest);
597
+ setMediaManifest(rebasedManifest);
598
+ mediaManifestRef.current = rebasedManifest;
599
+ }
584
600
  setSections(result.sections);
585
601
  setSiteIndex(result.index);
586
602
  setSavedSha(remote.savedBranchSha);
@@ -778,6 +794,7 @@ export default function EditorShell({
778
794
  focusedSectionIdRef.current = null;
779
795
  },
780
796
  mediaManifest,
797
+ mediaManifestRef,
781
798
  manifestDirty: mediaPipeline.manifestDirty,
782
799
  clearManifestDirty: () => mediaPipeline.setManifestDirty(false),
783
800
  pendingMediaItems: mediaPipeline.pendingMediaItems,
@@ -890,6 +907,7 @@ export default function EditorShell({
890
907
  const accentVar = accentVarValue(config);
891
908
  root.style.setProperty("--color-primary", primaryVar);
892
909
  root.style.setProperty("--color-primary-contrast", accentVar);
910
+ root.style.setProperty("--color-on-primary", onPrimaryVarValue(config));
893
911
  root.style.setProperty("--font-heading", `${config.headingFont}, system-ui, sans-serif`);
894
912
  root.style.setProperty("--font-body", `${config.bodyFont}, system-ui, sans-serif`);
895
913
  root.style.setProperty("--heading-text-transform", config.uppercaseHeadings ? "uppercase" : "none");
@@ -1456,12 +1474,42 @@ export default function EditorShell({
1456
1474
  if (!response.ok) throw new Error(`Restore failed: ${response.status}`);
1457
1475
  await discardLocalChanges();
1458
1476
  window.location.reload();
1477
+ return true;
1459
1478
  } catch (err) {
1460
1479
  console.error("Failed to restore:", err);
1461
1480
  setIsRestoring(false);
1481
+ return false;
1462
1482
  }
1463
1483
  }, []);
1464
1484
 
1485
+ // Restore requested straight from the history navigator. The navigator has
1486
+ // already confirmed with the user, so this loads the content at that sha and
1487
+ // runs the same restore path the HistoryToolbar uses.
1488
+ useEffect(() => {
1489
+ return historyRestoreEvent.listen(async ({ sha }) => {
1490
+ try {
1491
+ const res = await fetch(`/api/content/history?sha=${sha}`);
1492
+ if (!res.ok) throw new Error(`Failed to load version: ${res.status}`);
1493
+ const data = await res.json();
1494
+ const ok = await handleHistoryRestore({
1495
+ sections: data.sections,
1496
+ index: data.index,
1497
+ siteConfig: data.siteConfig,
1498
+ });
1499
+ // On success the page reloads, so only a failure ever reaches here.
1500
+ if (!ok) historyRestoreFailedEvent.dispatch({ message: "Couldn't publish the restore." });
1501
+ } catch (err) {
1502
+ console.error("Failed to restore from history:", err);
1503
+ historyRestoreFailedEvent.dispatch({ message: "Couldn't load that version." });
1504
+ }
1505
+ });
1506
+ }, [handleHistoryRestore]);
1507
+
1508
+ // Publish unsaved-draft state to the other islands (see editorDirtyEvent).
1509
+ useEffect(() => {
1510
+ editorDirtyEvent.dispatch({ hasUnsavedChanges: dirtySectionIds.size > 0 });
1511
+ }, [dirtySectionIds]);
1512
+
1465
1513
  // --- Render ---
1466
1514
 
1467
1515
  if (shellState.phase === "loading-content") {
@@ -2269,6 +2317,7 @@ function GlobalModal() {
2269
2317
  isOpen={modalState !== null}
2270
2318
  onClose={closeModal}
2271
2319
  title={modalState?.title ?? ""}
2320
+ tabbed={modalState?.tabbed}
2272
2321
  >
2273
2322
  {modalState?.content}
2274
2323
  </EditorModal>
@@ -106,7 +106,7 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
106
106
  const tabItems = allTabs.filter((t) => t.show).map(({ id, label, content }) => ({ id, label, content }));
107
107
 
108
108
  return (
109
- <EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding>
109
+ <EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding tabbed>
110
110
  <div className="flex flex-1 flex-col overflow-hidden">
111
111
  <Tabs tabs={tabItems} />
112
112
 
@@ -70,6 +70,10 @@ interface PublishDeps {
70
70
  * clears only those (a deletion scheduled mid-save must stay scheduled). */
71
71
  onSuccess: (savedSectionIds: string[], meta: { editToken: number; sentDeletedIds: string[] }) => void;
72
72
  mediaManifest: MediaManifest;
73
+ /** Optional ref mirror of `mediaManifest`, read at save time. recoverConflict
74
+ * refreshes it synchronously, so the immediate 409 retry assembles against
75
+ * the rebased manifest instead of this render's pre-conflict copy. */
76
+ mediaManifestRef?: React.RefObject<MediaManifest>;
73
77
  manifestDirty: boolean;
74
78
  clearManifestDirty: () => void;
75
79
  pendingMediaItems: MediaItem[];
@@ -126,6 +130,7 @@ export function useEditorPublish({
126
130
  deletedSectionIds,
127
131
  onSuccess,
128
132
  mediaManifest,
133
+ mediaManifestRef,
129
134
  manifestDirty,
130
135
  clearManifestDirty,
131
136
  pendingMediaItems,
@@ -201,11 +206,12 @@ export function useEditorPublish({
201
206
  if (hasMediaChanges) {
202
207
  // Preserves folders (and any future manifest keys) and heals dangling
203
208
  // folder refs — never rebuild the manifest as a bare { images }.
204
- // KNOWN LIMITATION (deferred): the assembly starts from THIS editor's
205
- // view of the manifest, so concurrent media changes by another editor
206
- // are last-writer-wins — a manifest entry they added since our load is
207
- // dropped from the write (their files remain; the entry orphans). Fixing
208
- // this needs a merge against the remote manifest at save time.
209
+ // The assembly starts from THIS editor's view, so what we send can be
210
+ // missing entries another writer added since we loaded. That is no longer
211
+ // destructive: applyContentWrite folds this into the manifest already on
212
+ // the branch, so a save can only add, update, or explicitly delete. The
213
+ // client side of the same story is rebaseMediaManifest, which refreshes
214
+ // our view on 409 recovery and on a peer's saved-event.
209
215
  updatedManifest = assembleSavedManifest(
210
216
  manifest,
211
217
  mediaUploads.map((u) => u.item),
@@ -390,7 +396,10 @@ export function useEditorPublish({
390
396
  return performSave({
391
397
  siteConfig: siteConfig!,
392
398
  siteIndexRef, deletedSectionIds, isConfigDirty,
393
- pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
399
+ pendingMediaItems, pendingMediaDeletions, manifestDirty,
400
+ // Read through the ref (when the shell supplies one) so the 409 retry
401
+ // below sees recoverConflict's rebased manifest, not this render's copy.
402
+ mediaManifest: mediaManifestRef?.current ?? mediaManifest,
394
403
  saveChunkBudget,
395
404
  });
396
405
  }
@@ -13,6 +13,7 @@ import {
13
13
  removePendingMediaDeletion,
14
14
  } from "../lib/dexie";
15
15
  import { generateVideoPoster } from "../media/videoPoster";
16
+ import { ASSET_CACHE_CONTROL } from "../storage/types";
16
17
  import { tallyImageIds, collectImageIds, clearImageIds, applyLogoDeletion } from "../lib/image-refs";
17
18
  import {
18
19
  createFolderId, uniqueFolderName, effectiveParentId, canMoveFolder,
@@ -285,6 +286,9 @@ export function useMediaPipeline({
285
286
  const xhr = new XMLHttpRequest();
286
287
  xhr.open("PUT", uploadUrl);
287
288
  xhr.setRequestHeader("Content-Type", file.type);
289
+ // Without this the object is stored as `no-cache` and every later
290
+ // play is billed origin egress — see ASSET_CACHE_CONTROL.
291
+ xhr.setRequestHeader("Cache-Control", ASSET_CACHE_CONTROL);
288
292
  xhr.upload.onprogress = (e) => {
289
293
  if (e.lengthComputable) setUploadProgress((prev) => ({ ...prev, [hash as string]: e.loaded / e.total }));
290
294
  };
package/src/lib/events.ts CHANGED
@@ -23,5 +23,16 @@ export const navChangeEvent = createEvent<NavItem[]>("sitenavchange");
23
23
  export const darkModeEvent = createEvent<string>("sitedarkmode");
24
24
  export const siteNameChangeEvent = createEvent<string>("sitenamechange");
25
25
  export const historySelectEvent = createEvent<{ sha: string; date: string }>("history-select");
26
+ /** Restore straight from the history navigator, skipping the browse-then-restore
27
+ * detour. The shell loads the content at `sha` and runs the same restore path
28
+ * HistoryToolbar uses. Confirmation happens before this is dispatched. */
29
+ export const historyRestoreEvent = createEvent<{ sha: string; date: string }>("history-restore");
30
+ /** Restore failed. The navigator is a separate island from the shell that does
31
+ * the writing, so without this the failure is invisible and the UI sits on
32
+ * "Restoring…" forever. */
33
+ export const historyRestoreFailedEvent = createEvent<{ message: string }>("history-restore-failed");
34
+ /** Unsaved-draft state, published by the shell for other islands (the history
35
+ * navigator warns before restoring, which discards drafts irrecoverably). */
36
+ export const editorDirtyEvent = createEvent<{ hasUnsavedChanges: boolean }>("editor-dirty");
26
37
  export const siteNavChangeEvent = createEvent<SiteNav>("sitenavchange-v2");
27
38
  export const pageSelectEvent = createEvent<string>("pageselect");
@@ -0,0 +1,190 @@
1
+ export interface HistoryCommit {
2
+ sha: string;
3
+ date: string;
4
+ /** First parent's sha; null for the repo's root commit. */
5
+ parent: string | null;
6
+ }
7
+
8
+ export type NodeKind = "commit" | "day" | "week" | "month" | "year";
9
+
10
+ export interface HistoryNode {
11
+ /** Stable React key, unique within the tree. */
12
+ id: string;
13
+ kind: NodeKind;
14
+ label: string;
15
+ sublabel: string | null;
16
+ count: number;
17
+ /** Newest commit in this node — what "browse" opens. */
18
+ newestSha: string | null;
19
+ /** Compare base: parent of this node's oldest commit. Null at repo root. */
20
+ baseSha: string | null;
21
+ children: HistoryNode[];
22
+ }
23
+
24
+ export type GroupId =
25
+ | "today" | "this-week" | "previous-weeks" | "earlier-months" | "previous-years";
26
+
27
+ export interface HistoryGroup {
28
+ id: GroupId;
29
+ label: string;
30
+ nodes: HistoryNode[];
31
+ }
32
+
33
+ const GROUPS: { id: GroupId; label: string }[] = [
34
+ { id: "today", label: "Today" },
35
+ { id: "this-week", label: "Earlier this week" },
36
+ { id: "previous-weeks", label: "Previous weeks" },
37
+ { id: "earlier-months", label: "Earlier months" },
38
+ { id: "previous-years", label: "Previous years" },
39
+ ];
40
+
41
+ // Calendar-day arithmetic (not fixed-ms) so DST transitions don't shift boundaries.
42
+ const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
43
+ const addDays = (d: Date, n: number) =>
44
+ new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
45
+ const startOfYear = (d: Date) => new Date(d.getFullYear(), 0, 1);
46
+
47
+ const WEEKDAY = new Intl.DateTimeFormat(undefined, { weekday: "long" });
48
+ const MONTH = new Intl.DateTimeFormat(undefined, { month: "long" });
49
+ const MON_DAY = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" });
50
+ const TIME = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
51
+
52
+ interface Dated extends HistoryCommit {
53
+ at: Date;
54
+ }
55
+
56
+ /** Oldest-first list → node metadata shared by every non-commit node. */
57
+ function rollup(sorted: Dated[]): Pick<HistoryNode, "count" | "newestSha" | "baseSha"> {
58
+ return {
59
+ count: sorted.length,
60
+ newestSha: sorted.length ? sorted[sorted.length - 1].sha : null,
61
+ baseSha: sorted.length ? sorted[0].parent : null,
62
+ };
63
+ }
64
+
65
+ function commitNode(c: Dated): HistoryNode {
66
+ return {
67
+ id: `c:${c.sha}`,
68
+ kind: "commit",
69
+ label: TIME.format(c.at),
70
+ sublabel: null,
71
+ count: 1,
72
+ newestSha: c.sha,
73
+ baseSha: c.parent,
74
+ children: [],
75
+ };
76
+ }
77
+
78
+ /** Bucket by a key derived from each commit's date, newest bucket first. */
79
+ function bucketBy(sorted: Dated[], key: (d: Date) => string): Map<string, Dated[]> {
80
+ const out = new Map<string, Dated[]>();
81
+ for (const c of sorted) {
82
+ const k = key(c.at);
83
+ const list = out.get(k);
84
+ if (list) list.push(c);
85
+ else out.set(k, [c]);
86
+ }
87
+ // sorted is oldest-first, so reverse the insertion order for newest-first display.
88
+ return new Map([...out.entries()].reverse());
89
+ }
90
+
91
+ const dayKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
92
+ const monthKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}`;
93
+ const yearKey = (d: Date) => `${d.getFullYear()}`;
94
+
95
+ /** Day nodes whose children are commit nodes. `weekday` picks the label style. */
96
+ function dayNodes(sorted: Dated[], weekday: boolean): HistoryNode[] {
97
+ return [...bucketBy(sorted, dayKey).entries()].map(([k, list]) => ({
98
+ id: `d:${k}`,
99
+ kind: "day" as const,
100
+ label: weekday ? WEEKDAY.format(list[0].at) : MON_DAY.format(list[0].at),
101
+ sublabel: weekday ? MON_DAY.format(list[0].at) : null,
102
+ ...rollup(list),
103
+ children: [...list].reverse().map(commitNode),
104
+ }));
105
+ }
106
+
107
+ function monthNodes(sorted: Dated[], withYear: boolean): HistoryNode[] {
108
+ return [...bucketBy(sorted, monthKey).entries()].map(([k, list]) => ({
109
+ id: `m:${k}`,
110
+ kind: "month" as const,
111
+ label: MONTH.format(list[0].at),
112
+ sublabel: withYear ? String(list[0].at.getFullYear()) : null,
113
+ ...rollup(list),
114
+ children: dayNodes(list, false),
115
+ }));
116
+ }
117
+
118
+ function yearNodes(sorted: Dated[]): HistoryNode[] {
119
+ return [...bucketBy(sorted, yearKey).entries()].map(([k, list]) => ({
120
+ id: `y:${k}`,
121
+ kind: "year" as const,
122
+ label: k,
123
+ sublabel: null,
124
+ ...rollup(list),
125
+ children: monthNodes(list, false),
126
+ }));
127
+ }
128
+
129
+ /**
130
+ * Up to three 7-day windows, newest first. Windows with no commits are dropped
131
+ * rather than rendered empty — an editor scanning history wants the edits, not
132
+ * a fixed-width calendar.
133
+ */
134
+ function weekNodes(sorted: Dated[], windowStart: Date): HistoryNode[] {
135
+ const out: HistoryNode[] = [];
136
+ for (let i = 1; i <= 3; i++) {
137
+ const start = addDays(windowStart, -7 * i);
138
+ const end = addDays(windowStart, -7 * (i - 1));
139
+ const list = sorted.filter((c) => c.at >= start && c.at < end);
140
+ if (list.length === 0) continue;
141
+ out.push({
142
+ id: `w:${dayKey(start)}`,
143
+ kind: "week",
144
+ label: `${MON_DAY.format(start)} – ${MON_DAY.format(addDays(end, -1))}`,
145
+ sublabel: null,
146
+ ...rollup(list),
147
+ children: dayNodes(list, false),
148
+ });
149
+ }
150
+ return out;
151
+ }
152
+
153
+ export function buildHistoryTree(commits: HistoryCommit[], now: Date): HistoryGroup[] {
154
+ // Oldest-first throughout; each builder reverses for display.
155
+ const sorted: Dated[] = commits
156
+ .map((c) => ({ ...c, at: new Date(c.date) }))
157
+ .filter((c) => !Number.isNaN(c.at.getTime()))
158
+ .sort((a, b) => a.at.getTime() - b.at.getTime());
159
+
160
+ const t0 = startOfDay(now); // today begins
161
+ const t1 = addDays(t0, -6); // "earlier this week" window begins
162
+ const t2 = addDays(t1, -21); // "previous weeks" window begins
163
+ const y0 = startOfYear(now); // current year begins
164
+ const monthsEnd = t2; // earlier-months covers [y0, t2)
165
+ const yearsEnd = t2 < y0 ? t2 : y0; // previous-years covers [-inf, yearsEnd)
166
+
167
+ const inRange = (lo: Date | null, hi: Date) =>
168
+ sorted.filter((c) => (lo === null || c.at >= lo) && c.at < hi);
169
+
170
+ const byId: Record<GroupId, HistoryNode[]> = {
171
+ "today": [...sorted.filter((c) => c.at >= t0)].reverse().map(commitNode),
172
+ "this-week": dayNodes(inRange(t1, t0), true),
173
+ "previous-weeks": weekNodes(sorted, t1),
174
+ "earlier-months": monthsEnd > y0 ? monthNodes(inRange(y0, monthsEnd), true) : [],
175
+ "previous-years": yearNodes(inRange(null, yearsEnd)),
176
+ };
177
+
178
+ return GROUPS.map((g) => ({ ...g, nodes: byId[g.id] }));
179
+ }
180
+
181
+ /** Largest node count anywhere in the tree; never 0, so callers can divide by it. */
182
+ export function maxNodeCount(groups: HistoryGroup[]): number {
183
+ let max = 1;
184
+ const walk = (n: HistoryNode) => {
185
+ if (n.count > max) max = n.count;
186
+ n.children.forEach(walk);
187
+ };
188
+ groups.forEach((g) => g.nodes.forEach(walk));
189
+ return max;
190
+ }
package/src/lib/index.ts CHANGED
@@ -12,7 +12,7 @@ export {
12
12
  suggestAccessibleOn,
13
13
  type WcagLevel,
14
14
  } from "./contrast";
15
- export { primaryVarValue, accentVarValue } from "./theme-vars";
15
+ export { primaryVarValue, accentVarValue, onPrimaryVarValue } from "./theme-vars";
16
16
  export { sanitizeHtml, ensureSanitizer } from "./sanitize";
17
17
  export { gridColsClass } from "./grid";
18
18
  export { getIcon, curatedIcons, type IconEntry } from "./icons";
@@ -178,6 +178,22 @@ export function healMediaManifest(manifest: MediaManifest): MediaManifest {
178
178
  return { ...manifest, images, folders };
179
179
  }
180
180
 
181
+ /**
182
+ * Rebase a local manifest onto a freshly-fetched remote one, so media another
183
+ * writer added is in our copy before the next save assembles from it. Without
184
+ * this a rebased session still writes its pre-conflict manifest, which silently
185
+ * drops their entries and orphans the files they committed.
186
+ *
187
+ * Images union, ours winning, so local alt/folder-move edits survive; a pending
188
+ * local deletion isn't removed from the manifest until save, so the union can't
189
+ * resurrect one. Folders stay ours verbatim — deleting a folder removes it from
190
+ * our map with no wire signal, so unioning would undo the delete. Mirrors the
191
+ * server-side merge in core's content-ops.
192
+ */
193
+ export function rebaseMediaManifest(local: MediaManifest, remote: MediaManifest): MediaManifest {
194
+ return { ...local, images: { ...remote.images, ...local.images } };
195
+ }
196
+
181
197
  /**
182
198
  * Save-time manifest assembly: merge uploaded pending items, apply deletions,
183
199
  * preserve every other manifest key (folders!), then heal.
@@ -77,12 +77,20 @@ export type Thumbnail =
77
77
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
78
  export type CustomSettingsFormProps = Record<string, any>;
79
79
 
80
+ export interface ModalOptions {
81
+ /** Content is a tab set — lock the modal height so tab switches don't resize it. */
82
+ tabbed?: boolean;
83
+ }
84
+
85
+ /** Signature of the editor's modal opener as threaded down to sections. */
86
+ export type OpenModal = (title: string, content: ReactNode, options?: ModalOptions) => void;
87
+
80
88
  export interface SectionProps<T = unknown> {
81
89
  content: T;
82
90
  options?: Record<string, unknown>;
83
91
  onChange?: (content: T) => void;
84
92
  isEditMode: boolean;
85
- openModal?: (title: string, content: ReactNode) => void;
93
+ openModal?: OpenModal;
86
94
  /** Delete this whole section. Used e.g. when a colors section's last swatch
87
95
  * is dragged into another section, leaving it empty. */
88
96
  onDelete?: () => void;
@@ -1,11 +1,13 @@
1
1
  import type { SiteConfig } from "../schemas/site-config";
2
+ import { suggestAccessibleOn } from "./contrast";
2
3
 
3
4
  /**
4
5
  * Computes the CSS value for the brand `--color-primary` / `--color-primary-contrast`
5
- * custom properties. When darkMode is "optional" AND a dark variant is set, the
6
- * value is a CSS `light-dark(<light>, <dark>)` expression — the browser resolves
7
- * it per the element's `color-scheme` (the `.dark` class sets `color-scheme: dark`;
8
- * `:root` sets `color-scheme: light`). Otherwise it's the plain light hex.
6
+ * / `--color-on-primary` custom properties. When darkMode is "optional" AND a dark
7
+ * variant is set, the value is a CSS `light-dark(<light>, <dark>)` expression — the
8
+ * browser resolves it per the element's `color-scheme` (the `.dark` class sets
9
+ * `color-scheme: dark`; `:root` sets `color-scheme: light`). Otherwise it's the
10
+ * plain light hex.
9
11
  *
10
12
  * Single source of truth for every injection site: Layout.astro / LoginLayout.astro
11
13
  * (SSR inline `<html style>`) and EditorShell (live setProperty + localStorage).
@@ -29,3 +31,22 @@ export function primaryVarValue(c?: Partial<ThemeColorConfig> | null): string {
29
31
  export function accentVarValue(c?: Partial<ThemeColorConfig> | null): string {
30
32
  return lightDark(c?.primaryContrast ?? DEFAULT_CONTRAST, c?.primaryContrastDark, c?.darkMode === "optional");
31
33
  }
34
+
35
+ /**
36
+ * Foreground ink for text drawn ON a `--color-primary`-filled surface (branded
37
+ * plates, primary cards). Derived from `primaryColor` with the same
38
+ * black/white WCAG pick the editor's accent "Suggest" button uses, so a
39
+ * primary-filled surface is legible on every brand without configuration.
40
+ *
41
+ * This exists because `--color-primary-contrast` cannot serve the role: it is
42
+ * the *accent* — brands legitimately repoint it at a decorative colour (Siete's
43
+ * gold on aubergine) that reads as a highlight but cannot carry body copy.
44
+ * Sections should consume `var(--color-on-primary)` for text on primary and
45
+ * keep `--color-primary-contrast` for accents, and derive softer inks by mixing
46
+ * the two brand tokens: `color-mix(in srgb, var(--color-on-primary) 70%, var(--color-primary))`.
47
+ */
48
+ export function onPrimaryVarValue(c?: Partial<ThemeColorConfig> | null): string {
49
+ const light = suggestAccessibleOn(c?.primaryColor ?? DEFAULT_PRIMARY);
50
+ const dark = c?.primaryColorDark ? suggestAccessibleOn(c.primaryColorDark) : null;
51
+ return lightDark(light, dark, c?.darkMode === "optional");
52
+ }
@@ -1,2 +1,2 @@
1
1
  export type { StorageProvider, FileWrite, AssetStore } from "./types";
2
- export { StorageConflictError } from "./types";
2
+ export { StorageConflictError, ASSET_CACHE_CONTROL } from "./types";
@@ -39,6 +39,21 @@ export type FileWrite = {
39
39
  encoding?: "utf-8" | "base64";
40
40
  };
41
41
 
42
+ /**
43
+ * `Cache-Control` every bucket upload must set. Object paths are content-hashed
44
+ * ("media/{hash}/original.mp4"), so a given URL's bytes never change — replacing
45
+ * a video mints a new hash and therefore a new URL. That makes `immutable` safe
46
+ * and a one-year max-age correct.
47
+ *
48
+ * This is NOT cosmetic. A bucket upload sent with no `cache-control` request
49
+ * header is stored as `no-cache`, which means neither the browser nor a CDN will
50
+ * ever reuse a response — every play of every video becomes origin egress,
51
+ * including a plain page reload. That default is what exhausted the Supabase
52
+ * project's egress quota while the bucket sat at 46% of its size limit. Any new
53
+ * upload path MUST send this header.
54
+ */
55
+ export const ASSET_CACHE_CONTROL = "max-age=31536000, immutable";
56
+
42
57
  /**
43
58
  * Optional large-asset store (Supabase Storage bucket). Holds video originals
44
59
  * too big for the git path; deliberately generic so future large kinds (zips)