@drawnagency/primitives 0.1.98 → 0.1.100

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 (138) hide show
  1. package/dist/{chunk-EUSWEVOA.js → chunk-3TO4RRHY.js} +12 -1
  2. package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-NA5EJM6Z.js → chunk-CZLYECHC.js} +19 -8
  5. package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
  6. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  7. package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
  8. package/dist/collab/rebase.d.ts.map +1 -1
  9. package/dist/collab/rebase.js +3 -1
  10. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  11. package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
  12. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
  13. package/dist/components/docfile/DocFilePane.d.ts +17 -0
  14. package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
  15. package/dist/components/docfile/doc-file-state.d.ts +33 -0
  16. package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/editor/index.js +1 -1
  19. package/dist/components/sections/Document/index.d.ts +18 -0
  20. package/dist/components/sections/Document/index.d.ts.map +1 -0
  21. package/dist/components/sections/all-sections.d.ts +10 -0
  22. package/dist/components/sections/all-sections.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.d.ts +10 -0
  24. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  25. package/dist/components/sections/brand-guide.js +3 -1
  26. package/dist/components/sections/register-schemas.js +5 -3
  27. package/dist/components/sections/register.js +5 -3
  28. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  29. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  30. package/dist/components/shared/Navigation.d.ts.map +1 -1
  31. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  32. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  33. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  34. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  35. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  36. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  37. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  38. package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
  39. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  40. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  41. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  42. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  43. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  44. package/dist/hooks/useEditorPublish.d.ts +23 -1
  45. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  46. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  47. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  48. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  49. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  50. package/dist/index.js +60 -34
  51. package/dist/lib/block-move.d.ts.map +1 -1
  52. package/dist/lib/dexie.d.ts +11 -0
  53. package/dist/lib/dexie.d.ts.map +1 -1
  54. package/dist/lib/dexie.js +32 -0
  55. package/dist/lib/document-refs.d.ts +22 -0
  56. package/dist/lib/document-refs.d.ts.map +1 -0
  57. package/dist/lib/document-uploads.d.ts +38 -0
  58. package/dist/lib/document-uploads.d.ts.map +1 -0
  59. package/dist/lib/documents.d.ts +65 -0
  60. package/dist/lib/documents.d.ts.map +1 -0
  61. package/dist/lib/index-ops.d.ts +1 -0
  62. package/dist/lib/index-ops.d.ts.map +1 -1
  63. package/dist/lib/index-ops.js +13 -1
  64. package/dist/lib/index.js +6 -6
  65. package/dist/lib/loader.d.ts +6 -0
  66. package/dist/lib/loader.d.ts.map +1 -1
  67. package/dist/lib/media-folders.d.ts +8 -0
  68. package/dist/lib/media-folders.d.ts.map +1 -1
  69. package/dist/lib/media-folders.js +2 -0
  70. package/dist/lib/nav.d.ts +29 -1
  71. package/dist/lib/nav.d.ts.map +1 -1
  72. package/dist/lib/pages.d.ts +18 -0
  73. package/dist/lib/pages.d.ts.map +1 -1
  74. package/dist/lib/platform-broker.d.ts +12 -0
  75. package/dist/lib/platform-broker.d.ts.map +1 -1
  76. package/dist/lib/platform-broker.js +12 -3
  77. package/dist/lib/registry.d.ts +10 -0
  78. package/dist/lib/registry.d.ts.map +1 -1
  79. package/dist/lib/save-chunks.d.ts +15 -0
  80. package/dist/lib/save-chunks.d.ts.map +1 -1
  81. package/dist/lib/scroll-lock.d.ts +2 -0
  82. package/dist/lib/scroll-lock.d.ts.map +1 -0
  83. package/dist/schemas/documents.d.ts +80 -0
  84. package/dist/schemas/documents.d.ts.map +1 -0
  85. package/dist/schemas/documents.js +25 -0
  86. package/dist/schemas/index.d.ts +1 -0
  87. package/dist/schemas/index.d.ts.map +1 -1
  88. package/dist/schemas/index.js +24 -2
  89. package/dist/storage/index.d.ts +2 -2
  90. package/dist/storage/index.d.ts.map +1 -1
  91. package/dist/storage/index.js +5 -1
  92. package/dist/storage/types.d.ts +69 -5
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/dist/types/database.d.ts +14 -0
  95. package/dist/types/database.d.ts.map +1 -1
  96. package/package.json +5 -1
  97. package/src/collab/rebase.ts +15 -0
  98. package/src/components/chat/ChatPanel.tsx +5 -4
  99. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  100. package/src/components/docfile/DocFilePane.tsx +129 -0
  101. package/src/components/docfile/doc-file-state.ts +180 -0
  102. package/src/components/editor/SettingsForm.tsx +10 -0
  103. package/src/components/sections/Document/index.tsx +163 -0
  104. package/src/components/sections/brand-guide.ts +2 -1
  105. package/src/components/shared/DocumentSourceField.tsx +51 -0
  106. package/src/components/shared/Navigation.tsx +53 -10
  107. package/src/components/shell/EditorModal.tsx +2 -5
  108. package/src/components/shell/EditorShell.tsx +154 -10
  109. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  110. package/src/components/shell/MediaFolderTile.tsx +4 -2
  111. package/src/components/shell/MediaFolderTree.tsx +20 -3
  112. package/src/components/shell/MediaLibraryContext.tsx +18 -1
  113. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  114. package/src/hooks/useDocumentUploads.ts +330 -0
  115. package/src/hooks/useEditorPublish.ts +197 -11
  116. package/src/hooks/useMediaPipeline.ts +136 -5
  117. package/src/hooks/useResolvedDocument.ts +25 -0
  118. package/src/lib/block-move.ts +15 -4
  119. package/src/lib/dexie.ts +54 -0
  120. package/src/lib/document-refs.ts +83 -0
  121. package/src/lib/document-uploads.ts +66 -0
  122. package/src/lib/documents.ts +138 -0
  123. package/src/lib/index-ops.ts +16 -0
  124. package/src/lib/loader.ts +32 -11
  125. package/src/lib/media-folders.ts +9 -0
  126. package/src/lib/nav.ts +46 -2
  127. package/src/lib/pages.ts +48 -0
  128. package/src/lib/platform-broker.ts +18 -3
  129. package/src/lib/registry.ts +9 -0
  130. package/src/lib/save-chunks.ts +42 -0
  131. package/src/lib/scroll-lock.ts +27 -0
  132. package/src/schemas/documents.ts +82 -0
  133. package/src/schemas/index.ts +1 -0
  134. package/src/schemas/nav.ts +3 -3
  135. package/src/storage/index.ts +2 -2
  136. package/src/storage/types.ts +71 -5
  137. package/src/types/database.ts +14 -0
  138. /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
@@ -0,0 +1,180 @@
1
+ import type { SiteDocument } from "../../lib/documents";
2
+ import { createEvent } from "../../lib/events";
3
+
4
+ /** Toggled on <html> while a document is open (the chat's class mechanism). */
5
+ export const DOC_OPEN_CLASS = "bp-doc-open";
6
+
7
+ /** The one breakpoint the doc feature keys off: at or above it the overlay
8
+ * gets the desktop stage; below it a PDF goes to the OS viewer instead (iOS
9
+ * Safari cannot scroll an iframed PDF, Android Chrome forces a download).
10
+ * 1024px — the SAME breakpoint as U5's desktop CSS block and the width where
11
+ * the nav sidebar exists at all. Deliberately NOT the chat's 768px dock query:
12
+ * keying JS at 768 while the pane's desktop rules start at 1024 would give
13
+ * 768–1023px viewers framed PDFs inside the mobile pane geometry, under a
14
+ * docked chat. */
15
+ export const DOC_DESKTOP_QUERY = "(min-width: 1024px)";
16
+
17
+ /** Cross-island open request. Islands that would rather not import this module
18
+ * can dispatch it instead; applying an already-current value is a no-op, so
19
+ * the dispatch-listen pair cannot loop. */
20
+ export const docFileOpenEvent = createEvent<{ sectionId: string | null }>("bp-doc-file-open");
21
+
22
+ interface DocFileStore {
23
+ open: string | null;
24
+ /** null = not seeded yet. Distinct from [] (seeded, no documents) — a row
25
+ * that hydrates before the overlay must not flash "file missing". */
26
+ docs: SiteDocument[] | null;
27
+ openListeners: Set<(id: string | null) => void>;
28
+ docsListeners: Set<(docs: SiteDocument[] | null) => void>;
29
+ teardown: (() => void) | null;
30
+ }
31
+
32
+ // Symbol-keyed on globalThis for the same reason the section registry is: the
33
+ // bundler may hand two islands two copies of this module, and a split store
34
+ // would let the nav open a document the overlay never hears about.
35
+ const STORE_KEY = Symbol.for("@drawnagency/primitives/doc-file-state");
36
+ const g = globalThis as unknown as Record<symbol, DocFileStore | undefined>;
37
+
38
+ function store(): DocFileStore {
39
+ let s = g[STORE_KEY];
40
+ if (!s) {
41
+ s = { open: null, docs: null, openListeners: new Set(), docsListeners: new Set(), teardown: null };
42
+ g[STORE_KEY] = s;
43
+ }
44
+ return s;
45
+ }
46
+
47
+ export function isDocDesktop(): boolean {
48
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
49
+ return window.matchMedia(DOC_DESKTOP_QUERY).matches;
50
+ }
51
+
52
+ function readHash(): string | null {
53
+ if (typeof window === "undefined") return null;
54
+ const match = /^#doc=(.+)$/.exec(window.location.hash);
55
+ if (!match) return null;
56
+ try {
57
+ return decodeURIComponent(match[1]);
58
+ } catch {
59
+ return match[1];
60
+ }
61
+ }
62
+
63
+ function apply(sectionId: string | null): void {
64
+ const s = store();
65
+ if (s.open === sectionId) return;
66
+ s.open = sectionId;
67
+ if (typeof document !== "undefined") {
68
+ document.documentElement.classList.toggle(DOC_OPEN_CLASS, sectionId !== null);
69
+ }
70
+ for (const listener of [...s.openListeners]) listener(sectionId);
71
+ }
72
+
73
+ function syncFromHash(): void {
74
+ const next = readHash();
75
+ // "Ignores hash writes that still contain its own value" — a same-value
76
+ // write (e.g. a repeated nav click on the already-open document) must not
77
+ // re-notify. NOTE the one hash writer that CAN silently stomp #doc is a nav
78
+ // HEADING click (useActiveHeadings' setActiveSection does a replaceState —
79
+ // click-driven, not scroll-driven, so the scroll lock does not prevent it);
80
+ // U3 closes the overlay in handleHeadingNav before that write happens.
81
+ if (next === store().open) return;
82
+ apply(next);
83
+ }
84
+
85
+ export function openDocFile(sectionId: string): void {
86
+ if (typeof window !== "undefined") {
87
+ const hash = `#doc=${encodeURIComponent(sectionId)}`;
88
+ if (window.location.hash !== hash) {
89
+ // pushState so Back closes the overlay. It fires no hashchange, which is
90
+ // why the atom — not the hash — is the store.
91
+ window.history.pushState(null, "", hash);
92
+ }
93
+ }
94
+ apply(sectionId);
95
+ docFileOpenEvent.dispatch({ sectionId });
96
+ }
97
+
98
+ export function closeDocFile(): void {
99
+ if (typeof window !== "undefined" && readHash() !== null) {
100
+ // replaceState, never history.back(): back() races the editor's own
101
+ // pushState in goToPage and is unobservable in jsdom. The cost is that Back
102
+ // after a manual close returns to an identical URL — invisible to the user.
103
+ const { pathname, search } = window.location;
104
+ window.history.replaceState(null, "", `${pathname}${search}`);
105
+ }
106
+ apply(null);
107
+ docFileOpenEvent.dispatch({ sectionId: null });
108
+ }
109
+
110
+ export function getOpenDocFile(): string | null {
111
+ return store().open;
112
+ }
113
+
114
+ export function subscribeDocFile(cb: (sectionId: string | null) => void): () => void {
115
+ const s = store();
116
+ s.openListeners.add(cb);
117
+ if (!s.teardown && typeof window !== "undefined") {
118
+ const onHash = () => syncFromHash();
119
+ const onPop = () => syncFromHash();
120
+ const onRequest = (detail: { sectionId: string | null }) => {
121
+ // The already-current check must come FIRST and cover null too: this
122
+ // listener also hears the event openDocFile/closeDocFile dispatch
123
+ // themselves, and an unguarded close would re-close → re-dispatch →
124
+ // recurse until the stack blew (jsdom swallows listener throws, so the
125
+ // suite only surfaced it as an unhandled RangeError).
126
+ if (detail.sectionId === s.open) return;
127
+ if (detail.sectionId === null) closeDocFile();
128
+ else openDocFile(detail.sectionId);
129
+ };
130
+ window.addEventListener("hashchange", onHash);
131
+ window.addEventListener("popstate", onPop);
132
+ const unlistenRequest = docFileOpenEvent.listen(onRequest);
133
+ s.teardown = () => {
134
+ window.removeEventListener("hashchange", onHash);
135
+ window.removeEventListener("popstate", onPop);
136
+ unlistenRequest();
137
+ };
138
+ // Deep link: the hash is an INPUT, read once when the first listener wires up.
139
+ syncFromHash();
140
+ }
141
+ return () => {
142
+ s.openListeners.delete(cb);
143
+ if (s.openListeners.size === 0 && s.teardown) {
144
+ s.teardown();
145
+ s.teardown = null;
146
+ }
147
+ };
148
+ }
149
+
150
+ /** Authoritative publish — the editor's live draft list. */
151
+ export function setSiteDocuments(docs: SiteDocument[]): void {
152
+ const s = store();
153
+ s.docs = docs;
154
+ for (const listener of [...s.docsListeners]) listener(docs);
155
+ }
156
+
157
+ /** First-writer-wins publish for the SSR-derived list, so an island that mounts
158
+ * after the editor has already published cannot clobber the live list. */
159
+ export function seedSiteDocuments(docs: SiteDocument[]): void {
160
+ if (store().docs !== null) return;
161
+ setSiteDocuments(docs);
162
+ }
163
+
164
+ export function getSiteDocuments(): SiteDocument[] | null {
165
+ return store().docs;
166
+ }
167
+
168
+ export function subscribeSiteDocuments(cb: (docs: SiteDocument[] | null) => void): () => void {
169
+ const s = store();
170
+ s.docsListeners.add(cb);
171
+ return () => { s.docsListeners.delete(cb); };
172
+ }
173
+
174
+ /** Test-only: drop the store and any window wiring (mirrors clearRegistry). */
175
+ export function resetDocFileState(): void {
176
+ const s = g[STORE_KEY];
177
+ if (s?.teardown) s.teardown();
178
+ if (typeof document !== "undefined") document.documentElement.classList.remove(DOC_OPEN_CLASS);
179
+ g[STORE_KEY] = undefined;
180
+ }
@@ -6,6 +6,7 @@ import { Checkbox } from "../shared/Checkbox";
6
6
  import { FormLabel } from "../shared/FormLabel";
7
7
  import { LinkField } from "../shared/LinkField";
8
8
  import { VideoSourceField } from "../shared/VideoSourceField";
9
+ import { DocumentSourceField, type DocumentRef } from "../shared/DocumentSourceField";
9
10
  import { Tabs } from "../shared/Tabs";
10
11
  import type { LinkValue } from "../../schemas/link";
11
12
  import type { VideoSource } from "../sections/Video/VideoBlock";
@@ -174,6 +175,15 @@ function renderField(
174
175
  />
175
176
  );
176
177
 
178
+ case "document-source":
179
+ return (
180
+ <DocumentSourceField
181
+ key={key}
182
+ value={value as DocumentRef | undefined}
183
+ onChange={(v) => handleFieldChange(key, v)}
184
+ />
185
+ );
186
+
177
187
  default:
178
188
  return null;
179
189
  }
@@ -0,0 +1,163 @@
1
+ import { useEffect, useState } from "react";
2
+ import { z } from "zod";
3
+ import { ExternalLink, FileText } from "lucide-react";
4
+ import { defineSection } from "../../../lib/registry";
5
+ import { cn } from "../../../lib/cn";
6
+ import { formatDocSize, type SiteDocument } from "../../../lib/documents";
7
+ import {
8
+ getSiteDocuments,
9
+ subscribeSiteDocuments,
10
+ openDocFile,
11
+ isDocDesktop,
12
+ } from "../../docfile/doc-file-state";
13
+
14
+ const schema = z.object({
15
+ type: z.literal("document"),
16
+ content: z.object({
17
+ title: z
18
+ .string()
19
+ .describe("Row title. Leave it blank to show the file name without its extension; type a title to override that."),
20
+ doc: z.object({
21
+ documentId: z
22
+ .string()
23
+ .default("")
24
+ .describe(
25
+ "Hash key into the document manifest (src/content/document-manifest.json). Deliberately NOT `imageId`: documents are not resolved by the media ref-baker.",
26
+ ),
27
+ }),
28
+ showInDocumentsNav: z
29
+ .boolean()
30
+ .default(true)
31
+ .describe(
32
+ "When true, this document also appears in the auto-derived Documents group at the bottom of the left navigation, openable from any page.",
33
+ ),
34
+ }),
35
+ options: z.object({}).default({}),
36
+ });
37
+
38
+ /** Subscribes to the site-wide document list published by the DocFileOverlay
39
+ * island (viewer: SSR-derived; editor: live from EditorShell). `null` means
40
+ * "not seeded yet" — distinct from "seeded, and this id isn't in it", which is
41
+ * the missing-file state. */
42
+ function useSiteDocument(sectionId: string): { doc: SiteDocument | null; resolved: boolean } {
43
+ const [docs, setDocs] = useState<SiteDocument[] | null>(null);
44
+ useEffect(() => {
45
+ setDocs(getSiteDocuments());
46
+ return subscribeSiteDocuments(setDocs);
47
+ }, []);
48
+ return {
49
+ doc: docs ? (docs.find((d) => d.sectionId === sectionId) ?? null) : null,
50
+ resolved: docs !== null,
51
+ };
52
+ }
53
+
54
+ export function DocumentRow({
55
+ sectionId,
56
+ title,
57
+ isEditMode,
58
+ }: {
59
+ sectionId: string;
60
+ title: string;
61
+ isEditMode: boolean;
62
+ }) {
63
+ const { doc, resolved } = useSiteDocument(sectionId);
64
+
65
+ // Viewers never see a row whose bytes are gone — deriveSiteDocuments already
66
+ // dropped it, so absence here is authoritative. Editors keep a muted row so
67
+ // the section stays selectable and repairable.
68
+ if (resolved && !doc && !isEditMode) return null;
69
+
70
+ // `doc.title` is already `content.title || filenameStem(entry.filename)` —
71
+ // deriveSiteDocuments applies the fallback once so the row, the Documents nav
72
+ // entry and the overlay top bar can never disagree. Picking a file NEVER
73
+ // writes `title` (reconciliation decision 6). `title` (raw section content)
74
+ // is only the pre-seed placeholder, before the registry has published.
75
+ const label = doc?.title ?? title;
76
+
77
+ function handleClick() {
78
+ if (!doc) return;
79
+ // iOS Safari cannot scroll an iframed PDF and Android Chrome forces a
80
+ // download — hand phones the OS viewer instead. HTML still uses the overlay.
81
+ if (doc.kind === "pdf" && !isDocDesktop()) {
82
+ window.open(doc.url, "_blank", "noopener,noreferrer");
83
+ return;
84
+ }
85
+ openDocFile(sectionId);
86
+ }
87
+
88
+ return (
89
+ <button
90
+ type="button"
91
+ onClick={handleClick}
92
+ disabled={!doc}
93
+ aria-label={doc ? `Open ${label}` : undefined}
94
+ className={cn(
95
+ "flex w-full min-w-0 items-center gap-3 rounded-outer border border-base-200 bg-base px-4 py-3 text-left transition-colors",
96
+ doc ? "cursor-pointer hover:bg-base-accent" : "cursor-default opacity-60",
97
+ )}
98
+ >
99
+ <FileText size={18} aria-hidden="true" className="shrink-0 text-base-contrast/60" />
100
+ <div className="min-w-0 flex-1">
101
+ <div className="flex items-center gap-2">
102
+ <span className="truncate text-sm font-medium text-base-contrast">{label}</span>
103
+ {doc && (
104
+ <span className="shrink-0 rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">
105
+ {doc.kind === "pdf" ? "PDF" : "HTML"}
106
+ </span>
107
+ )}
108
+ </div>
109
+ <div className="truncate text-xs text-base-contrast/70">
110
+ {doc ? `${doc.filename} · ${formatDocSize(doc.size)}` : resolved ? "File missing" : "\u00a0"}
111
+ </div>
112
+ </div>
113
+ {doc && <ExternalLink size={16} aria-hidden="true" className="shrink-0 text-base-contrast/50" />}
114
+ </button>
115
+ );
116
+ }
117
+
118
+ export default defineSection({
119
+ type: "document",
120
+ category: "brand-guide",
121
+ label: "Document",
122
+ icon: <FileText size={18} />,
123
+ schema,
124
+ // The row reads the client-side document registry and opens the overlay, so
125
+ // the viewer hydrates it as an island rather than emitting static HTML.
126
+ interactive: true,
127
+ authoringNote:
128
+ "One downloadable/viewable file — a PDF or a single self-contained HTML file — presented as a clickable row. " +
129
+ "Clicking opens it in a full-viewport preview overlay without leaving the page; on phones a PDF opens in the OS viewer instead. " +
130
+ "Pick the file from the media library (documents tab); leave the title blank to show the file name without its extension, or type one to override it. " +
131
+ "Leave 'Show in Documents menu' on for anything a viewer should be able to reach from any page — those entries collect into a derived Documents group at the bottom of the left nav. " +
132
+ "Use one section per file; for a set of downloads, place several document sections in a container.",
133
+ component: ({ content, isEditMode }) => (
134
+ <DocumentRow
135
+ // The section id is added by the outer Section schema (id + layout
136
+ // intersected with the per-type schema), so it is present at render time
137
+ // but absent from this type's inferred content shape.
138
+ sectionId={(content as unknown as { id?: string }).id ?? ""}
139
+ title={content.content.title}
140
+ isEditMode={isEditMode}
141
+ />
142
+ ),
143
+ defaults: () => ({
144
+ type: "document" as const,
145
+ content: { title: "", doc: { documentId: "" }, showInDocumentsNav: true },
146
+ options: {},
147
+ }),
148
+ getLabel: (content) => content.content.title || "Document",
149
+ settings: {
150
+ doc: { type: "document-source", label: "File", target: "content", default: { documentId: "" } },
151
+ title: { type: "text", label: "Title", target: "content", default: "", placeholder: "Document title" },
152
+ showInDocumentsNav: {
153
+ type: "checkbox",
154
+ label: "Show in Documents menu",
155
+ target: "content",
156
+ default: true,
157
+ },
158
+ },
159
+ settingsTabs: [
160
+ { label: "File", fields: ["doc", "title"] },
161
+ { label: "Display", fields: ["showInDocumentsNav"] },
162
+ ],
163
+ });
@@ -1,13 +1,14 @@
1
1
  import colors from "./Colors";
2
2
  import iconList from "./IconList";
3
3
  import doDontMedia from "./DoDontMedia";
4
+ import documentSection from "./Document";
4
5
 
5
6
  /** Opinionated brand-guide sections. Self-contained subtree: a build that does
6
7
  * not import this module drops these sections (and their cmyk/pantone schemas
7
8
  * and do/dont compositions) entirely. Exposed at
8
9
  * `@drawnagency/primitives/components/sections/brand-guide`. */
9
10
  // Un-annotated for the same generic-variance reason as coreSectionDefs.
10
- export const brandGuideSectionDefs = [colors, iconList, doDontMedia];
11
+ export const brandGuideSectionDefs = [colors, iconList, doDontMedia, documentSection];
11
12
 
12
13
  export { ColorSpaceSchema, ColorItemSchema } from "./Colors/schema";
13
14
  export type { ColorSpace, ColorItem } from "./Colors/schema";
@@ -0,0 +1,51 @@
1
+ import { FormLabel } from "./FormLabel";
2
+ import { Button } from "./Button";
3
+ import { useMediaLibrary } from "../shell/MediaLibraryContext";
4
+ import { useResolvedDocument } from "../../hooks/useResolvedDocument";
5
+ import { formatFileSize } from "../../media/utils";
6
+
7
+ export interface DocumentRef {
8
+ documentId: string;
9
+ }
10
+
11
+ interface Props {
12
+ value: DocumentRef | undefined;
13
+ onChange: (value: DocumentRef) => void;
14
+ }
15
+
16
+ /** Picker for the `document` section type. Writes only { documentId } — the
17
+ * filename/size shown here are read back out of the sidecar manifest, never
18
+ * copied into section content. */
19
+ export function DocumentSourceField({ value, onChange }: Props) {
20
+ const mediaLibrary = useMediaLibrary();
21
+ const documentId = value?.documentId || undefined;
22
+ const { entry, missing } = useResolvedDocument(documentId);
23
+
24
+ return (
25
+ <div className="flex flex-col gap-3">
26
+ <FormLabel>Document</FormLabel>
27
+ <div className="flex items-center gap-3">
28
+ {entry ? (
29
+ <span className="min-w-0 flex-1 truncate text-sm text-base-contrast">
30
+ {entry.filename}
31
+ <span className="text-base-contrast-light"> · {formatFileSize(entry.size)}</span>
32
+ </span>
33
+ ) : (
34
+ <span className="flex-1 text-sm text-base-contrast-light">
35
+ {missing ? "File missing" : "No document selected"}
36
+ </span>
37
+ )}
38
+ <Button
39
+ type="button"
40
+ variant="secondary"
41
+ size="sm"
42
+ onClick={() =>
43
+ mediaLibrary?.openSelectModal((id) => onChange({ documentId: id }), { kinds: ["document"] })
44
+ }
45
+ >
46
+ {documentId ? "Replace document" : "Choose document"}
47
+ </Button>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -1,9 +1,10 @@
1
- import { useState, useCallback, useEffect, useRef } from "react";
1
+ import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
2
2
  import { ChevronRight } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { Toggle } from "./Toggle";
5
- import type { SiteNav, NavItem, GroupNavItem, PageNavItem } from "../../lib/nav";
5
+ import type { SiteNav, NavItem, GroupNavItem, PageNavItem, DocGroupNavItem, DocNavItem } from "../../lib/nav";
6
6
  import { groupDisplayLabel } from "../../lib/nav-tree";
7
+ import { openDocFile, closeDocFile } from "../docfile/doc-file-state";
7
8
  import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, editorDirtyEvent } from "../../lib/events";
8
9
  import { useActiveHeadings } from "../../hooks/useActiveHeadings";
9
10
  import { formatDate } from "../../lib/timestamp";
@@ -81,7 +82,9 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
81
82
 
82
83
  // The active page can live in any tier (editors navigate to hidden and
83
84
  // archived pages via the Pages modal).
84
- const treePages = siteNav.entries.flatMap((e) => (e.kind === "group" ? e.pages : [e]));
85
+ const treePages = siteNav.entries.flatMap((e) =>
86
+ e.kind === "group" ? e.pages : e.kind === "page" ? [e] : [],
87
+ );
85
88
  const activePage =
86
89
  [...treePages, ...siteNav.hidden, ...siteNav.archive].find((p) => p.isActive) ?? null;
87
90
  const headings: NavItem[] = activePage?.headings ?? [];
@@ -100,6 +103,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
100
103
  useActiveHeadings(parentIds, childIdsByParent, grandchildIdsByChild);
101
104
 
102
105
  const handleHeadingNav = useCallback((href: string) => {
106
+ closeDocFile(); // a nav heading click always leaves doc mode first
103
107
  const id = href.replace("#", "");
104
108
  const el = document.getElementById(id);
105
109
  if (el) { el.scrollIntoView({ behavior: "smooth" }); setActiveSection(id); }
@@ -206,11 +210,12 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
206
210
  </li>
207
211
  );
208
212
 
209
- // The one collapsible-section body, shared by authored groups and the derived
210
- // editor buckets so the two can never drift apart visually. Callers supply the
211
- // wrapper element, because authored groups are interleaved <li>s inside the
212
- // main nav list while the buckets are their own blocks below it.
213
- const renderGroupBody = (key: string, label: string, pages: PageNavItem[], defaultOpen: boolean) => {
213
+ // The one collapsible-section body, shared by authored groups, the derived
214
+ // editor buckets and the derived Documents group so they can never drift
215
+ // apart visually. Callers supply the wrapper element, because authored groups
216
+ // are interleaved <li>s inside the main nav list while the buckets are their
217
+ // own blocks below it.
218
+ const renderCollapsible = (key: string, label: string, defaultOpen: boolean, body: ReactNode) => {
214
219
  const isOpen = openGroups[key] ?? defaultOpen;
215
220
  return (
216
221
  <>
@@ -223,11 +228,45 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
223
228
  <span>{label}</span>
224
229
  <ChevronRight size={14} aria-hidden="true" className={cn("transition-transform", isOpen && "rotate-90")} />
225
230
  </button>
226
- {isOpen && <ul className="space-y-1">{pages.map(renderPage)}</ul>}
231
+ {isOpen && body}
227
232
  </>
228
233
  );
229
234
  };
230
235
 
236
+ const renderGroupBody = (key: string, label: string, pages: PageNavItem[], defaultOpen: boolean) =>
237
+ renderCollapsible(key, label, defaultOpen, <ul className="space-y-1">{pages.map(renderPage)}</ul>);
238
+
239
+ // Documents are launchers, not destinations: the viewer sets the deep-link
240
+ // hash (the overlay atom reads it), the editor opens the atom directly —
241
+ // never pageSelectEvent, which would dead-end on a page that doesn't exist.
242
+ const renderDoc = (doc: DocNavItem) => (
243
+ <li key={doc.sectionId}>
244
+ {asButton ? (
245
+ <button
246
+ type="button"
247
+ onClick={() => { openDocFile(doc.sectionId); setIsOpen(false); }}
248
+ className="cursor-pointer w-full rounded-inner px-3 py-2 text-left text-sm font-bold text-base-contrast transition-colors hover:text-primary"
249
+ >
250
+ {doc.title}
251
+ </button>
252
+ ) : (
253
+ <a
254
+ href={`#doc=${doc.sectionId}`}
255
+ onClick={() => setIsOpen(false)}
256
+ className="block w-full rounded-inner px-3 py-2 text-left text-sm font-bold text-base-contrast transition-colors hover:text-primary"
257
+ >
258
+ {doc.title}
259
+ </a>
260
+ )}
261
+ </li>
262
+ );
263
+
264
+ const renderDocGroup = (group: DocGroupNavItem) => (
265
+ <li key={group.id} className="mt-2 border-t border-base-200 pt-2">
266
+ {renderCollapsible(group.id, group.label, true, <ul className="space-y-1">{group.docs.map(renderDoc)}</ul>)}
267
+ </li>
268
+ );
269
+
231
270
  const renderAuthoredGroup = (group: GroupNavItem) => (
232
271
  // No mx-4: the enclosing list is already padded, unlike the bucket blocks.
233
272
  <li key={group.id} className="mt-2 border-t border-base-200 pt-2">
@@ -288,7 +327,11 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
288
327
 
289
328
  <ul className="space-y-1 px-4 py-2">
290
329
  {siteNav.entries.map((entry) =>
291
- entry.kind === "group" ? renderAuthoredGroup(entry) : renderPage(entry),
330
+ entry.kind === "group"
331
+ ? renderAuthoredGroup(entry)
332
+ : entry.kind === "doc-group"
333
+ ? renderDocGroup(entry)
334
+ : renderPage(entry),
292
335
  )}
293
336
  </ul>
294
337
 
@@ -4,6 +4,7 @@ import { RemoveIcon } from "../shared/icons";
4
4
  import { IconButton } from "../shared/IconButton";
5
5
  import { useFocusTrap } from "../../hooks/useFocusTrap";
6
6
  import { cn } from "../../lib/cn";
7
+ import { lockBodyScroll } from "../../lib/scroll-lock";
7
8
 
8
9
  interface EditorModalProps {
9
10
  isOpen: boolean;
@@ -37,11 +38,7 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
37
38
 
38
39
  useEffect(() => {
39
40
  if (!isOpen) return;
40
- const original = document.body.style.overflow;
41
- document.body.style.overflow = "hidden";
42
- return () => {
43
- document.body.style.overflow = original;
44
- };
41
+ return lockBodyScroll();
45
42
  }, [isOpen]);
46
43
 
47
44
  if (!isOpen) return null;