@drawnagency/primitives 0.1.86 → 0.1.88

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 (60) hide show
  1. package/dist/{chunk-YKOLOZB2.js → chunk-DRCPDOW3.js} +14 -0
  2. package/dist/{chunk-MBJUWOM2.js → chunk-EO7K45GV.js} +1 -1
  3. package/dist/{chunk-2CVP2AGK.js → chunk-H2FENAQH.js} +6 -6
  4. package/dist/{chunk-DKXISNJ2.js → chunk-NEDOJHHR.js} +2 -2
  5. package/dist/{chunk-DO3ZENLN.js → chunk-OEL57NYV.js} +1 -1
  6. package/dist/{chunk-FDBZZ4AT.js → chunk-PTVY3Q7P.js} +1 -1
  7. package/dist/{chunk-665DW3HR.js → chunk-SK6GGTTO.js} +220 -207
  8. package/dist/{chunk-EP3LVOXZ.js → chunk-WA3HFN54.js} +1 -1
  9. package/dist/{chunk-YAPG44NS.js → chunk-XDG2K6UT.js} +1 -1
  10. package/dist/{chunk-E3MTIMOG.js → chunk-XPUBUR64.js} +10 -0
  11. package/dist/components/brandguide/Colors.d.ts.map +1 -1
  12. package/dist/components/brandguide/ColorsEditable.d.ts +20 -0
  13. package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -0
  14. package/dist/components/brandguide/ColorsView.d.ts +9 -0
  15. package/dist/components/brandguide/ColorsView.d.ts.map +1 -0
  16. package/dist/components/brandguide/colors-shared.d.ts +4 -0
  17. package/dist/components/brandguide/colors-shared.d.ts.map +1 -0
  18. package/dist/components/editor/index.js +4 -4
  19. package/dist/components/primitives/index.js +5 -5
  20. package/dist/components/sections/Colors/index.d.ts.map +1 -1
  21. package/dist/components/sections/SectionIsland.d.ts +8 -0
  22. package/dist/components/sections/SectionIsland.d.ts.map +1 -0
  23. package/dist/components/sections/brand-guide.js +5 -5
  24. package/dist/components/sections/register-schemas.js +8 -8
  25. package/dist/components/sections/register.js +8 -8
  26. package/dist/components/shared/Navigation.d.ts +6 -1
  27. package/dist/components/shared/Navigation.d.ts.map +1 -1
  28. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  29. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  30. package/dist/hooks/useCollabSession.d.ts.map +1 -1
  31. package/dist/hooks/useMediaPipeline.d.ts +2 -1
  32. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  33. package/dist/index.js +6 -4
  34. package/dist/lib/image-refs.d.ts +9 -0
  35. package/dist/lib/image-refs.d.ts.map +1 -1
  36. package/dist/lib/index.js +2 -2
  37. package/dist/lib/registry.d.ts +7 -0
  38. package/dist/lib/registry.d.ts.map +1 -1
  39. package/dist/media/index.js +4 -2
  40. package/dist/media/resolve.d.ts +25 -0
  41. package/dist/media/resolve.d.ts.map +1 -1
  42. package/dist/schemas/index.js +2 -2
  43. package/dist/schemas/site-config.d.ts +10 -0
  44. package/dist/schemas/site-config.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/components/brandguide/Colors.tsx +2 -275
  47. package/src/components/brandguide/ColorsEditable.tsx +155 -0
  48. package/src/components/brandguide/ColorsView.tsx +100 -0
  49. package/src/components/brandguide/colors-shared.ts +17 -0
  50. package/src/components/sections/Colors/index.tsx +1 -0
  51. package/src/components/sections/SectionIsland.tsx +21 -0
  52. package/src/components/shared/Navigation.tsx +20 -2
  53. package/src/components/shell/EditorShell.tsx +4 -0
  54. package/src/components/shell/SiteSettingsDisplay.tsx +106 -0
  55. package/src/hooks/useCollabSession.ts +27 -0
  56. package/src/hooks/useMediaPipeline.ts +16 -3
  57. package/src/lib/image-refs.ts +19 -0
  58. package/src/lib/registry.ts +7 -0
  59. package/src/media/resolve.ts +34 -0
  60. package/src/schemas/site-config.ts +13 -0
@@ -6,15 +6,27 @@ import { FontPicker } from "../shared/FontPicker";
6
6
  import { Input } from "../shared/Input";
7
7
  import { Select } from "../shared/Select";
8
8
  import { FormLabel } from "../shared/FormLabel";
9
+ import { useMediaLibrary } from "./MediaLibraryContext";
9
10
  import { deriveContrast } from "../../lib/contrast";
10
11
  import { buildGoogleFontsUrl } from "../../lib/google-fonts";
11
12
  import type { SiteConfig } from "../../schemas/site-config";
13
+ import type { MediaItem } from "../../media/types";
12
14
 
13
15
  interface Props {
14
16
  siteConfig: SiteConfig;
15
17
  onChange: (config: SiteConfig) => void;
16
18
  }
17
19
 
20
+ // Smallest generated variant for the editor-preview thumbnail (mirrors
21
+ // DocInspector's thumbSrc) — avoids needing a MediaProvider in this context.
22
+ function thumbSrc(imageId: string, item: MediaItem | undefined): string | null {
23
+ if (!item || item.kind !== "image") return null;
24
+ if (item.mimeType === "image/svg+xml") return `/api/media/${imageId}/original.svg`;
25
+ const widths = item.variants.map((v) => v.width).sort((a, b) => a - b);
26
+ if (widths.length === 0) return null;
27
+ return `/api/media/${imageId}/${widths[0]}.webp`;
28
+ }
29
+
18
30
  const darkModeOptions = [
19
31
  { label: "Light", value: "light" },
20
32
  { label: "Dark", value: "dark" },
@@ -33,11 +45,19 @@ const FAVICON_MAX_BYTES = 100 * 1024;
33
45
 
34
46
  export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
35
47
  const [faviconError, setFaviconError] = useState<string | null>(null);
48
+ const mediaLibrary = useMediaLibrary();
36
49
 
37
50
  function update(patch: Partial<SiteConfig>) {
38
51
  onChange({ ...siteConfig, ...patch });
39
52
  }
40
53
 
54
+ function pickLogo(which: "light" | "dark") {
55
+ mediaLibrary?.openSelectModal((imageId) => {
56
+ const base = siteConfig.logo ?? { light: { imageId: "" }, dark: null, invertInDark: false, showInNav: true };
57
+ update({ logo: { ...base, [which]: { imageId } } });
58
+ });
59
+ }
60
+
41
61
  function handleFaviconFile(file: File | undefined) {
42
62
  if (!file) return;
43
63
  if (!FAVICON_TYPES.includes(file.type)) {
@@ -154,6 +174,92 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
154
174
  {faviconError && <p className="mt-1 text-xs text-red-600">{faviconError}</p>}
155
175
  </div>
156
176
 
177
+ {(() => {
178
+ const logo = siteConfig.logo;
179
+ const lightSrc = logo ? thumbSrc(logo.light.imageId, mediaLibrary?.manifest.images[logo.light.imageId]) : null;
180
+ const darkSrc = logo?.dark ? thumbSrc(logo.dark.imageId, mediaLibrary?.manifest.images[logo.dark.imageId]) : null;
181
+ const needsDarkHint = logo != null && siteConfig.darkMode !== "light" && !logo.dark && !logo.invertInDark;
182
+
183
+ return (
184
+ <div>
185
+ <FormLabel>Logo</FormLabel>
186
+ <div className="flex items-center gap-3">
187
+ {logo && (lightSrc ? (
188
+ <img
189
+ src={lightSrc}
190
+ alt="Current logo"
191
+ className="h-8 w-auto max-w-[8rem] rounded border border-base-200 bg-base-accent object-contain p-1"
192
+ />
193
+ ) : (
194
+ <span className="text-xs text-base-contrast-light">Logo selected</span>
195
+ ))}
196
+ <Button variant="secondary" size="sm" onClick={() => pickLogo("light")}>
197
+ {logo ? "Replace logo" : "Add logo"}
198
+ </Button>
199
+ {logo && (
200
+ <Button variant="ghost" tone="destructive" size="sm" aria-label="Remove logo" onClick={() => update({ logo: null })}>
201
+ Remove logo
202
+ </Button>
203
+ )}
204
+ </div>
205
+ <p className="mt-1 text-xs text-base-contrast/70">Shown in the site navigation in place of the site name.</p>
206
+
207
+ {logo && (
208
+ <div className="mt-3 space-y-3 border-l-2 border-base-200 pl-3">
209
+ <div>
210
+ <FormLabel>Dark variant</FormLabel>
211
+ <div className="flex items-center gap-3">
212
+ {logo.dark && (darkSrc ? (
213
+ <img
214
+ src={darkSrc}
215
+ alt="Current dark-mode logo"
216
+ className="h-8 w-auto max-w-[8rem] rounded border border-base-200 bg-base-accent object-contain p-1"
217
+ />
218
+ ) : (
219
+ <span className="text-xs text-base-contrast-light">Dark logo selected</span>
220
+ ))}
221
+ <Button variant="secondary" size="sm" onClick={() => pickLogo("dark")}>
222
+ {logo.dark ? "Replace dark variant" : "Add dark variant"}
223
+ </Button>
224
+ {logo.dark && (
225
+ <Button
226
+ variant="ghost"
227
+ tone="destructive"
228
+ size="sm"
229
+ aria-label="Remove dark variant"
230
+ onClick={() => update({ logo: { ...logo, dark: null } })}
231
+ >
232
+ Remove
233
+ </Button>
234
+ )}
235
+ </div>
236
+ <Checkbox
237
+ className="mt-2"
238
+ checked={logo.invertInDark}
239
+ onChange={(v) => update({ logo: { ...logo, invertInDark: v } })}
240
+ label="Invert in dark mode"
241
+ description="Flips the light logo's colors instead of uploading a separate file."
242
+ disabled={Boolean(logo.dark)}
243
+ />
244
+ </div>
245
+
246
+ <Checkbox
247
+ checked={logo.showInNav}
248
+ onChange={(v) => update({ logo: { ...logo, showInNav: v } })}
249
+ label="Show in navigation"
250
+ />
251
+
252
+ {needsDarkHint && (
253
+ <p className="text-xs text-amber-600">
254
+ Add a dark variant or enable invert so the logo stays legible in dark mode.
255
+ </p>
256
+ )}
257
+ </div>
258
+ )}
259
+ </div>
260
+ );
261
+ })()}
262
+
157
263
  <Select
158
264
  label="Dark mode"
159
265
  value={siteConfig.darkMode}
@@ -5,6 +5,13 @@ import type {
5
5
  } from "../collab/types";
6
6
 
7
7
  const REJOIN_DELAYS_MS = [5_000, 15_000, 30_000, 60_000];
8
+ // Circuit breaker: after this many consecutive failed rejoins (~3.5 min of
9
+ // backoff), stop the auto-rejoin loop and stay in solo mode rather than retry
10
+ // forever — an unbounded reconnect loop is itself a way to hammer the backend.
11
+ // Refocusing the tab (visibilitychange → visible) resets the breaker and
12
+ // retries, so a user returning to a long-idle/offline tab reconnects without a
13
+ // manual reload.
14
+ const MAX_REJOIN_ATTEMPTS = 6;
8
15
 
9
16
  export interface UseCollabSessionArgs {
10
17
  provider: CollabProvider | null;
@@ -129,14 +136,34 @@ export function useCollabSession(args: UseCollabSessionArgs): UseCollabSessionRe
129
136
  // race two concurrent joins into two sessions, and the orphaned timer
130
137
  // would survive unmount (the cleanup only clears the tracked one).
131
138
  if (timer) clearTimeout(timer);
139
+ // Circuit breaker: give up after a capped number of consecutive failures
140
+ // rather than rejoin forever. onVisible() below rearms it on refocus.
141
+ if (attempt >= MAX_REJOIN_ATTEMPTS) {
142
+ console.warn(`Collab: stopped auto-rejoining after ${attempt} consecutive failures; staying solo until the tab is refocused.`);
143
+ return;
144
+ }
132
145
  const delay = REJOIN_DELAYS_MS[Math.min(attempt, REJOIN_DELAYS_MS.length - 1)];
133
146
  attempt += 1;
134
147
  timer = setTimeout(() => { timer = null; void join(); }, delay);
135
148
  }
136
149
 
150
+ // Rearm the breaker (and reconnect immediately) when the user tabs back to
151
+ // a disconnected editor — the common recovery from a laptop-sleep / network
152
+ // blip that exhausted the rejoin budget. No-op while already live/connecting.
153
+ function onVisible() {
154
+ if (cancelled) return;
155
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
156
+ if (sessionRef.current || joining) return;
157
+ if (timer) { clearTimeout(timer); timer = null; }
158
+ attempt = 0;
159
+ void join();
160
+ }
161
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
162
+
137
163
  void join();
138
164
  return () => {
139
165
  cancelled = true;
166
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
140
167
  if (timer) clearTimeout(timer);
141
168
  void sessionRef.current?.disconnect().catch(() => {});
142
169
  sessionRef.current = null;
@@ -13,7 +13,7 @@ import {
13
13
  removePendingMediaDeletion,
14
14
  } from "../lib/dexie";
15
15
  import { generateVideoPoster } from "../media/videoPoster";
16
- import { tallyImageIds, collectImageIds, clearImageIds } from "../lib/image-refs";
16
+ import { tallyImageIds, collectImageIds, clearImageIds, applyLogoDeletion } from "../lib/image-refs";
17
17
  import {
18
18
  createFolderId, uniqueFolderName, effectiveParentId, canMoveFolder,
19
19
  descendantFolderIds, itemFolderId, planFolderUpload,
@@ -36,6 +36,10 @@ interface UseMediaPipelineInput {
36
36
  // Surfaces non-error, informational notices (e.g. "N files already exist
37
37
  // and were left where they are" on dedup skip) to the caller.
38
38
  onUploadNotice?: (message: string) => void;
39
+ // Lets handleMediaDelete null out siteConfig.logo when its backing asset is
40
+ // deleted. Updater form (not a plain setter) so the caller can adapt its own
41
+ // value-based site-config change handler.
42
+ onSiteConfigChange?: (updater: (prev: SiteConfig) => SiteConfig) => void;
39
43
  }
40
44
 
41
45
  export function useMediaPipeline({
@@ -49,6 +53,7 @@ export function useMediaPipeline({
49
53
  markSectionDirty,
50
54
  onUploadError,
51
55
  onUploadNotice,
56
+ onSiteConfigChange,
52
57
  }: UseMediaPipelineInput) {
53
58
  const [processingItems, setProcessingItems] = useState<QueueItem[]>([]);
54
59
  const [pendingMediaItems, setPendingMediaItems] = useState<LibraryMediaItem[]>([]);
@@ -440,8 +445,15 @@ export function useMediaPipeline({
440
445
  }),
441
446
  );
442
447
 
448
+ if (siteConfig?.logo && onSiteConfigChange) {
449
+ const logoIds = collectImageIds(siteConfig.logo);
450
+ if (ids.some((id) => logoIds.has(id))) {
451
+ onSiteConfigChange((prev) => ({ ...prev, logo: applyLogoDeletion(prev.logo, idSet) }));
452
+ }
453
+ }
454
+
443
455
  setLocalChangesExist(true);
444
- }, [pendingMediaItems, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist]);
456
+ }, [pendingMediaItems, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist, siteConfig, onSiteConfigChange]);
445
457
 
446
458
  const handleMediaAltChange = useCallback((id: string, alt: string) => {
447
459
  setMediaManifest((prev) => {
@@ -585,8 +597,9 @@ export function useMediaPipeline({
585
597
  const referenceCountMap = useMemo((): Record<string, number> => {
586
598
  const counts = new Map<string, number>();
587
599
  for (const { section } of sections) tallyImageIds(section, counts);
600
+ if (siteConfig) tallyImageIds(siteConfig, counts);
588
601
  return Object.fromEntries(counts);
589
- }, [sections]);
602
+ }, [sections, siteConfig]);
590
603
 
591
604
  // --- Unified enqueue ---
592
605
 
@@ -1,3 +1,5 @@
1
+ import type { SiteConfig } from "../schemas/site-config";
2
+
1
3
  /**
2
4
  * Structural walkers over section content for `imageId` references.
3
5
  *
@@ -51,3 +53,20 @@ export function clearImageIds(value: unknown, ids: Set<string>): unknown {
51
53
  }
52
54
  return value;
53
55
  }
56
+
57
+ /**
58
+ * Applies a media-deletion id set to a site logo config, granularly: deleting
59
+ * the light variant's backing asset removes the logo entirely (no light = no
60
+ * logo to show), while deleting only the dark variant's asset keeps the
61
+ * light logo + flags and drops just the dark override. Returns the same
62
+ * `logo` reference unchanged when neither id was deleted.
63
+ */
64
+ export function applyLogoDeletion(
65
+ logo: SiteConfig["logo"],
66
+ deletedIds: Set<string>,
67
+ ): SiteConfig["logo"] {
68
+ if (!logo) return logo;
69
+ if (deletedIds.has(logo.light.imageId)) return null;
70
+ if (logo.dark && deletedIds.has(logo.dark.imageId)) return { ...logo, dark: null };
71
+ return logo;
72
+ }
@@ -176,6 +176,12 @@ export interface SectionDefinition<T = unknown> {
176
176
  * top-level `description`. Presentation/metadata only — not validated.
177
177
  */
178
178
  authoringNote?: string;
179
+ /**
180
+ * When true, this section must run client-side on the viewer page: the viewer
181
+ * renderer hydrates it as an Astro island (`client:visible`) instead of
182
+ * emitting static HTML. Opt-in per type; default (absent/false) stays zero-JS.
183
+ */
184
+ interactive?: boolean;
179
185
  }
180
186
 
181
187
  // --- defineSection ---
@@ -204,6 +210,7 @@ type DefineSectionInput<S extends ZodType> = {
204
210
  settingsTabs?: { label: string; fields: string[] }[];
205
211
  presets?: SectionPreset[];
206
212
  authoringNote?: string;
213
+ interactive?: boolean;
207
214
  };
208
215
 
209
216
  export function defineSection<S extends ZodType>(
@@ -86,3 +86,37 @@ export function rebindImageRef<T extends { imageId?: string }>(ref: T, imageId:
86
86
  for (const key of BAKED_MEDIA_KEYS) delete fresh[key];
87
87
  return fresh as T;
88
88
  }
89
+
90
+ export interface ResolvedLogo {
91
+ lightUrl: string;
92
+ darkUrl: string | null;
93
+ invertInDark: boolean;
94
+ showInNav: boolean;
95
+ }
96
+
97
+ /**
98
+ * Resolves a SiteConfig["logo"] entry into concrete URLs. Returns null when
99
+ * there is no logo configured, when the light variant's imageId can't be
100
+ * resolved against the manifest, or when the light variant resolves to a
101
+ * video (an `<img>` can't render mp4/webm) — callers should fall back to
102
+ * rendering the site name as text rather than a broken image. Still-image and
103
+ * animated (gif) picks are both fine — only a video pick is rejected, since
104
+ * both those tags resolve to `tag: "img"`.
105
+ */
106
+ export function resolveLogo(
107
+ logo: { light: { imageId: string }; dark: { imageId: string } | null; invertInDark: boolean; showInNav: boolean } | null,
108
+ manifest: MediaManifest,
109
+ provider: MediaProvider,
110
+ ): ResolvedLogo | null {
111
+ if (!logo) return null;
112
+ const light = resolveMedia({ imageId: logo.light.imageId }, manifest, [1], provider);
113
+ if (!light?.src || light.tag === "video") return null;
114
+ const dark = logo.dark ? resolveMedia({ imageId: logo.dark.imageId }, manifest, [1], provider) : null;
115
+ const darkUrl = dark && dark.tag !== "video" ? dark.src : null;
116
+ return {
117
+ lightUrl: light.src,
118
+ darkUrl,
119
+ invertInDark: logo.invertInDark,
120
+ showInNav: logo.showInNav,
121
+ };
122
+ }
@@ -211,6 +211,19 @@ export const SiteConfigSchema = z.object({
211
211
  .refine((v) => v.startsWith("data:image/"), "Favicon must be an image data URL")
212
212
  .nullable()
213
213
  .default(null),
214
+ // Optional site logo, stored as media-library references (so it participates
215
+ // in usage tracking + GC protection, and its resolved URL is reusable for the
216
+ // Phase 3 social-preview image). Every field defaults so pre-existing
217
+ // site-config.json files keep parsing.
218
+ logo: z
219
+ .object({
220
+ light: z.object({ imageId: z.string() }),
221
+ dark: z.object({ imageId: z.string() }).nullable().default(null),
222
+ invertInDark: z.boolean().default(false),
223
+ showInNav: z.boolean().default(true),
224
+ })
225
+ .nullable()
226
+ .default(null),
214
227
  media: MediaConfigSchema.default(MediaConfigSchema.parse({})),
215
228
  // Brand chatbot config (editor-owned; the platform kill switch lives in the
216
229
  // sites table, not here). Every field defaults so pre-existing