@actuate-media/cms-admin 0.52.1 → 0.54.0

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 (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +48 -30
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/layout/sidebar-brand.render.test.js +61 -13
  6. package/dist/__tests__/layout/sidebar-brand.render.test.js.map +1 -1
  7. package/dist/__tests__/lib/favicon.test.d.ts +2 -0
  8. package/dist/__tests__/lib/favicon.test.d.ts.map +1 -0
  9. package/dist/__tests__/lib/favicon.test.js +43 -0
  10. package/dist/__tests__/lib/favicon.test.js.map +1 -0
  11. package/dist/__tests__/views/section-inspector-media.test.d.ts +2 -0
  12. package/dist/__tests__/views/section-inspector-media.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/section-inspector-media.test.js +21 -0
  14. package/dist/__tests__/views/section-inspector-media.test.js.map +1 -0
  15. package/dist/layout/Sidebar.d.ts.map +1 -1
  16. package/dist/layout/Sidebar.js +29 -11
  17. package/dist/layout/Sidebar.js.map +1 -1
  18. package/dist/lib/favicon.d.ts +28 -0
  19. package/dist/lib/favicon.d.ts.map +1 -0
  20. package/dist/lib/favicon.js +36 -0
  21. package/dist/lib/favicon.js.map +1 -0
  22. package/dist/views/page-editor/SectionInspector.d.ts +11 -0
  23. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  24. package/dist/views/page-editor/SectionInspector.js +36 -7
  25. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  26. package/dist/views/settings/BrandingCard.d.ts.map +1 -1
  27. package/dist/views/settings/BrandingCard.js +6 -2
  28. package/dist/views/settings/BrandingCard.js.map +1 -1
  29. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  30. package/dist/views/settings/useAppearanceSettings.js +6 -0
  31. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/AdminRoot.tsx +50 -30
  34. package/src/__tests__/layout/sidebar-brand.render.test.tsx +92 -16
  35. package/src/__tests__/lib/favicon.test.ts +55 -0
  36. package/src/__tests__/views/section-inspector-media.test.ts +23 -0
  37. package/src/layout/Sidebar.tsx +39 -11
  38. package/src/lib/favicon.ts +44 -0
  39. package/src/views/page-editor/SectionInspector.tsx +43 -2
  40. package/src/views/settings/BrandingCard.tsx +7 -2
  41. package/src/views/settings/useAppearanceSettings.ts +6 -0
package/src/AdminRoot.tsx CHANGED
@@ -7,6 +7,7 @@ import { Layout } from './layout/Layout.js'
7
7
  import { adminPortalContainer } from './lib/portal-container.js'
8
8
  import { useAdminRouter } from './router/index.js'
9
9
  import { cmsApi, ensureCsrfToken } from './lib/api.js'
10
+ import { setDocumentFavicon, BRAND_UPDATED_EVENT } from './lib/favicon.js'
10
11
  import {
11
12
  registerSectionTypes,
12
13
  resetCustomSectionTypes,
@@ -743,50 +744,69 @@ function useBranding(config: any) {
743
744
 
744
745
  useEffect(() => {
745
746
  const branding = config?.admin?.branding
746
- if (!branding) return
747
-
748
- // --- Favicon ---
749
- if (branding.favicon) {
750
- const existing = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
751
- originalFaviconRef.current = existing?.href ?? null
752
-
753
- if (existing) {
754
- existing.href = branding.favicon
755
- } else {
756
- const link = document.createElement('link')
757
- link.rel = 'icon'
758
- link.href = branding.favicon
759
- document.head.appendChild(link)
760
- injectedLinkRef.current = link
761
- }
747
+
748
+ // Capture the originals up-front so cleanup can restore them even after the
749
+ // DB favicon (below) overrides whatever config set.
750
+ originalFaviconRef.current =
751
+ document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.href ?? null
752
+ originalTitleRef.current = document.title
753
+
754
+ const applyFavicon = (href: string, type?: string | null) => {
755
+ const { link, created } = setDocumentFavicon(href, type)
756
+ if (created && !injectedLinkRef.current) injectedLinkRef.current = link
762
757
  }
763
758
 
759
+ // --- Static config favicon (instant, no network) ---
760
+ // Acts as a fast default; the DB favicon below overrides it when one is set
761
+ // in Settings → Appearance.
762
+ if (branding?.favicon) applyFavicon(branding.favicon)
763
+
764
764
  // --- Document title ---
765
- originalTitleRef.current = document.title
766
- if (branding.title) {
765
+ if (branding?.title) {
767
766
  document.title = branding.title
768
- } else if (branding.name) {
767
+ } else if (branding?.name) {
769
768
  document.title = `${branding.name} Admin`
770
769
  }
771
770
 
772
771
  // --- Primary color ---
773
772
  const adminEl = document.querySelector<HTMLElement>('.actuate-admin')
774
- if (branding.primaryColor && adminEl) {
773
+ if (branding?.primaryColor && adminEl) {
775
774
  adminEl.style.setProperty('--primary', branding.primaryColor)
776
775
  adminEl.style.setProperty('--sidebar-primary', branding.primaryColor)
777
776
  }
778
777
 
778
+ // --- DB favicon (Settings → Appearance) ---
779
+ // The uploaded favicon is the source of truth for the browser tab. Fetch it
780
+ // on mount and re-apply whenever the brand is saved, so the tab icon tracks
781
+ // the upload without a full reload. The admin request carries the session
782
+ // cookie, so the public/brand read bypasses any shared cache (see
783
+ // cache/http.ts) and is always fresh.
784
+ let cancelled = false
785
+ const applyDbFavicon = async () => {
786
+ try {
787
+ const res = await cmsApi<{ favicon?: { icon?: string | null; type?: string | null } }>(
788
+ '/public/brand',
789
+ )
790
+ const favicon = res.data?.favicon
791
+ if (!cancelled && favicon?.icon) applyFavicon(favicon.icon, favicon.type ?? undefined)
792
+ } catch {
793
+ /* brand API unavailable — keep the config/default favicon */
794
+ }
795
+ }
796
+ void applyDbFavicon()
797
+ const onBrandUpdated = () => void applyDbFavicon()
798
+ window.addEventListener(BRAND_UPDATED_EVENT, onBrandUpdated)
799
+
779
800
  return () => {
801
+ cancelled = true
802
+ window.removeEventListener(BRAND_UPDATED_EVENT, onBrandUpdated)
803
+
780
804
  // Restore favicon
781
- if (branding.favicon) {
782
- const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
783
- if (link && originalFaviconRef.current) {
784
- link.href = originalFaviconRef.current
785
- }
786
- if (injectedLinkRef.current) {
787
- injectedLinkRef.current.remove()
788
- injectedLinkRef.current = null
789
- }
805
+ const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
806
+ if (link && originalFaviconRef.current) link.href = originalFaviconRef.current
807
+ if (injectedLinkRef.current) {
808
+ injectedLinkRef.current.remove()
809
+ injectedLinkRef.current = null
790
810
  }
791
811
 
792
812
  // Restore title
@@ -795,7 +815,7 @@ function useBranding(config: any) {
795
815
  }
796
816
 
797
817
  // Remove primary color override
798
- if (branding.primaryColor && adminEl) {
818
+ if (branding?.primaryColor && adminEl) {
799
819
  adminEl.style.removeProperty('--primary')
800
820
  adminEl.style.removeProperty('--sidebar-primary')
801
821
  }
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
- import { describe, expect, it, vi } from 'vitest'
3
- import { render, screen } from '@testing-library/react'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
4
 
5
5
  // The sidebar fetches forms unread counts on mount; stub it so these brand
6
6
  // tests stay offline and deterministic.
@@ -8,25 +8,40 @@ vi.mock('../../lib/forms-service.js', () => ({
8
8
  fetchFormsSidebarCounts: vi.fn(async () => []),
9
9
  }))
10
10
 
11
- // Drive the brand lockup directly: a branded install (initials derived from a
12
- // site title) with no uploaded logo. happy-dom's localStorage stub is
13
- // unreliable here, so we mock the theme hook rather than hydrating it.
11
+ // Drive the brand lockup directly via a mutable theme state. happy-dom's
12
+ // localStorage stub is unreliable here, so we mock the theme hook rather than
13
+ // hydrating it. Tests mutate `themeState` before rendering.
14
+ interface ThemeState {
15
+ adminBrand: {
16
+ primaryUrl: string | null
17
+ markUrl: string | null
18
+ darkUrl: string | null
19
+ initials: string
20
+ }
21
+ resolvedTheme: string
22
+ density: string
23
+ }
24
+ const DEFAULT_THEME: ThemeState = {
25
+ adminBrand: { primaryUrl: null, markUrl: null, darkUrl: null, initials: 'AC' },
26
+ resolvedTheme: 'light',
27
+ density: 'default',
28
+ }
29
+ let themeState: ThemeState = { ...DEFAULT_THEME }
30
+
14
31
  vi.mock('../../components/ThemeProvider.js', () => ({
15
- useTheme: () => ({
16
- adminBrand: { primaryUrl: null, markUrl: null, darkUrl: null, initials: 'AC' },
17
- resolvedTheme: 'light',
18
- density: 'default',
19
- }),
32
+ useTheme: () => themeState,
20
33
  }))
21
34
 
22
35
  const { Sidebar } = await import('../../layout/Sidebar.js')
23
36
 
24
- /**
25
- * Brand lockup fallback behaviour. When an install is branded (a site name is
26
- * set, so initials exist) but no logo has been uploaded, the sidebar must show
27
- * the neutral logo placeholder — NOT a filled initials badge, which reads as a
28
- * user avatar (the real account avatar already lives top-right).
29
- */
37
+ function setTheme(brand: Partial<ThemeState['adminBrand']>, resolvedTheme = 'light') {
38
+ themeState = {
39
+ ...DEFAULT_THEME,
40
+ resolvedTheme,
41
+ adminBrand: { ...DEFAULT_THEME.adminBrand, ...brand },
42
+ }
43
+ }
44
+
30
45
  function renderSidebar(collapsed: boolean, config?: unknown) {
31
46
  return render(
32
47
  <Sidebar
@@ -39,6 +54,16 @@ function renderSidebar(collapsed: boolean, config?: unknown) {
39
54
  )
40
55
  }
41
56
 
57
+ afterEach(() => {
58
+ themeState = { ...DEFAULT_THEME }
59
+ })
60
+
61
+ /**
62
+ * Brand lockup fallback behaviour. When an install is branded (a site name is
63
+ * set, so initials exist) but no logo has been uploaded, the sidebar must show
64
+ * the neutral logo placeholder — NOT a filled initials badge, which reads as a
65
+ * user avatar (the real account avatar already lives top-right).
66
+ */
42
67
  describe('Sidebar brand placeholder', () => {
43
68
  it('renders the logo placeholder (not an initials avatar) when branded with no logo — expanded', () => {
44
69
  renderSidebar(false, { admin: { branding: { name: 'Acme Co' } } })
@@ -56,3 +81,54 @@ describe('Sidebar brand placeholder', () => {
56
81
  expect(screen.queryByText('AC')).toBeNull()
57
82
  })
58
83
  })
84
+
85
+ describe('Sidebar brand logo rendering', () => {
86
+ it('shows the primary logo (scaled to fit) when collapsed — never the favicon', () => {
87
+ setTheme({ primaryUrl: 'https://cdn.example.com/logo.svg' })
88
+ renderSidebar(true)
89
+
90
+ const img = screen.getByRole('img', { name: 'Admin' }) as HTMLImageElement
91
+ expect(img.src).toContain('logo.svg')
92
+ // Wide logos must be width-capped so they scale down instead of overflowing.
93
+ expect(img.className).toContain('max-w-full')
94
+ })
95
+
96
+ it('caps the expanded logo width so wide logos stay responsive', () => {
97
+ setTheme({ primaryUrl: 'https://cdn.example.com/logo.svg' })
98
+ renderSidebar(false)
99
+
100
+ const img = screen.getByRole('img', { name: 'Admin' }) as HTMLImageElement
101
+ expect(img.className).toContain('max-w-full')
102
+ expect(img.className).toContain('object-contain')
103
+ })
104
+
105
+ it('prefers the dark logo in dark mode and falls back to primary on error', () => {
106
+ setTheme(
107
+ {
108
+ primaryUrl: 'https://cdn.example.com/logo.svg',
109
+ darkUrl: 'https://cdn.example.com/dark.svg',
110
+ },
111
+ 'dark',
112
+ )
113
+ renderSidebar(false)
114
+
115
+ const img = screen.getByRole('img', { name: 'Admin' }) as HTMLImageElement
116
+ expect(img.src).toContain('dark.svg')
117
+ })
118
+
119
+ it('falls through to the placeholder when the logo URL is broken (onError)', () => {
120
+ setTheme({ primaryUrl: 'https://cdn.example.com/broken.svg' })
121
+ renderSidebar(false, { admin: { branding: { name: 'Acme Co' } } })
122
+
123
+ // With a brand name configured, the logo alt is the brand name.
124
+ const img = screen.getByRole('img', { name: 'Acme Co' }) as HTMLImageElement
125
+ expect(img.src).toContain('broken.svg')
126
+ // Simulate the asset URL failing to load.
127
+ fireEvent.error(img)
128
+
129
+ // The broken logo is gone; the neutral placeholder + brand name take over
130
+ // (no browser broken-image glyph left behind).
131
+ expect(screen.queryByRole('img', { name: 'Acme Co' })).toBeNull()
132
+ expect(screen.getByText('Acme Co')).toBeTruthy()
133
+ })
134
+ })
@@ -0,0 +1,55 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+
4
+ import { setDocumentFavicon, BRAND_UPDATED_EVENT } from '../../lib/favicon.js'
5
+
6
+ afterEach(() => {
7
+ document.head.querySelectorAll('link[rel="icon"]').forEach((l) => l.remove())
8
+ })
9
+
10
+ describe('setDocumentFavicon', () => {
11
+ it('creates a link[rel=icon] when the document has none', () => {
12
+ const { created, previousHref, link } = setDocumentFavicon(
13
+ 'https://cdn.example.com/fav.png',
14
+ 'image/png',
15
+ )
16
+ expect(created).toBe(true)
17
+ expect(previousHref).toBeNull()
18
+ expect(link.rel).toBe('icon')
19
+ const found = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
20
+ expect(found).not.toBeNull()
21
+ expect(found!.href).toContain('fav.png')
22
+ expect(found!.type).toBe('image/png')
23
+ })
24
+
25
+ it('updates the existing link in place and reports the previous href', () => {
26
+ const existing = document.createElement('link')
27
+ existing.rel = 'icon'
28
+ existing.href = 'https://cdn.example.com/old.png'
29
+ document.head.appendChild(existing)
30
+
31
+ const { created, previousHref, link } = setDocumentFavicon(
32
+ 'https://cdn.example.com/new.svg',
33
+ 'image/svg+xml',
34
+ )
35
+ expect(created).toBe(false)
36
+ expect(previousHref).toContain('old.png')
37
+ expect(link).toBe(existing)
38
+ expect(existing.href).toContain('new.svg')
39
+ expect(existing.type).toBe('image/svg+xml')
40
+ // Never duplicates the icon link.
41
+ expect(document.querySelectorAll('link[rel="icon"]').length).toBe(1)
42
+ })
43
+
44
+ it('preserves the existing type when no new type is provided', () => {
45
+ setDocumentFavicon('https://cdn.example.com/a.png', 'image/png')
46
+ setDocumentFavicon('https://cdn.example.com/b.png')
47
+ const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
48
+ expect(link!.href).toContain('b.png')
49
+ expect(link!.type).toBe('image/png')
50
+ })
51
+
52
+ it('exposes a stable brand-updated event name', () => {
53
+ expect(BRAND_UPDATED_EVENT).toBe('actuate:brand-updated')
54
+ })
55
+ })
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { companionDimensionKeys } from '../../views/page-editor/SectionInspector.js'
3
+
4
+ describe('companionDimensionKeys', () => {
5
+ it('maps a *Url field to *Width / *Height (feature, hero)', () => {
6
+ expect(companionDimensionKeys('imageUrl')).toEqual({
7
+ width: 'imageWidth',
8
+ height: 'imageHeight',
9
+ })
10
+ expect(companionDimensionKeys('backgroundUrl')).toEqual({
11
+ width: 'backgroundWidth',
12
+ height: 'backgroundHeight',
13
+ })
14
+ })
15
+
16
+ it('maps a gallery-row `url` field to plain width / height', () => {
17
+ expect(companionDimensionKeys('url')).toEqual({ width: 'width', height: 'height' })
18
+ })
19
+
20
+ it('falls back to <key>Width / <key>Height for other keys', () => {
21
+ expect(companionDimensionKeys('photo')).toEqual({ width: 'photoWidth', height: 'photoHeight' })
22
+ })
23
+ })
@@ -98,16 +98,39 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
98
98
  const initials = adminBrand.initials
99
99
  const hasInitials = Boolean(initials) && initials !== 'A'
100
100
 
101
- // Prefer the dark logo on dark backgrounds when supplied.
102
- const primary =
103
- resolvedTheme === 'dark' && adminBrand.darkUrl ? adminBrand.darkUrl : adminBrand.primaryUrl
104
- const mark = adminBrand.markUrl
101
+ // Track image URLs that fail to load (broken/expired/deleted asset) so the
102
+ // resolution falls through to the next option instead of showing the browser's
103
+ // broken-image icon in the sidebar header.
104
+ const [failedSrcs, setFailedSrcs] = useState<Set<string>>(new Set())
105
+ const markFailed = (src: string) =>
106
+ setFailedSrcs((prev) => (prev.has(src) ? prev : new Set(prev).add(src)))
107
+ const usable = (url: string | null | undefined): string | null =>
108
+ url && !failedSrcs.has(url) ? url : null
109
+ const onErr = (src: string) => () => markFailed(src)
110
+
111
+ // The admin brand is ALWAYS the logo — the favicon is only for the browser tab
112
+ // and is never used here (see resolveAdminBrand). Prefer the dark logo on dark
113
+ // backgrounds when supplied, falling back to the primary if the dark asset is
114
+ // missing or broken.
115
+ const darkPref = resolvedTheme === 'dark' ? adminBrand.darkUrl : null
116
+ const primary = usable(darkPref) ?? usable(adminBrand.primaryUrl)
117
+ const mark = usable(adminBrand.markUrl)
118
+ const cfg = usable(configLogo)
105
119
  const alt = brandName ?? 'Admin'
106
120
 
121
+ // Wide horizontal logos must scale down to fit the narrow rail / sidebar
122
+ // header instead of overflowing — height-capped, width auto, never wider than
123
+ // the available space.
124
+ const logoClass = 'h-8 w-auto max-w-full object-contain'
125
+
107
126
  if (collapsed) {
108
- if (mark) return <img src={mark} alt={alt} className="h-8 w-8 object-contain" />
109
- if (primary) return <img src={primary} alt={alt} className="h-8 w-8 object-contain" />
110
- if (configLogo) return <img src={configLogo} alt={alt} className="h-8 w-8 object-contain" />
127
+ // Collapsed rail prefers a dedicated square mark; otherwise it shows the
128
+ // logo (scaled to fit) never the favicon.
129
+ if (mark)
130
+ return <img src={mark} alt={alt} className="h-8 w-8 object-contain" onError={onErr(mark)} />
131
+ if (primary)
132
+ return <img src={primary} alt={alt} className={logoClass} onError={onErr(primary)} />
133
+ if (cfg) return <img src={cfg} alt={alt} className={logoClass} onError={onErr(cfg)} />
111
134
  if (hasInitials) return <LogoPlaceholder tone="sidebar" />
112
135
  return <ActuateMark className="h-8 w-8" />
113
136
  }
@@ -115,7 +138,7 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
115
138
  if (primary) {
116
139
  return (
117
140
  <div className="flex min-w-0 items-center gap-2.5">
118
- <img src={primary} alt={alt} className="h-8 w-auto shrink-0 object-contain" />
141
+ <img src={primary} alt={alt} className={logoClass} onError={onErr(primary)} />
119
142
  {brandName && (
120
143
  <span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
121
144
  )}
@@ -123,10 +146,10 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
123
146
  )
124
147
  }
125
148
 
126
- if (configLogo) {
149
+ if (cfg) {
127
150
  return (
128
151
  <div className="flex min-w-0 items-center gap-2.5">
129
- <img src={configLogo} alt={alt} className="h-8 w-auto shrink-0 object-contain" />
152
+ <img src={cfg} alt={alt} className={logoClass} onError={onErr(cfg)} />
130
153
  {brandName && (
131
154
  <span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
132
155
  )}
@@ -137,7 +160,12 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
137
160
  if (mark) {
138
161
  return (
139
162
  <div className="flex min-w-0 items-center gap-2.5">
140
- <img src={mark} alt={alt} className="h-8 w-8 shrink-0 object-contain" />
163
+ <img
164
+ src={mark}
165
+ alt={alt}
166
+ className="h-8 w-8 shrink-0 object-contain"
167
+ onError={onErr(mark)}
168
+ />
141
169
  <span className="text-sidebar-foreground truncate text-sm font-medium">
142
170
  {brandName ?? alt}
143
171
  </span>
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Browser-tab favicon helpers for the admin SPA.
3
+ *
4
+ * The admin runs as a client app, so the favicon shown in the browser tab is
5
+ * whatever `<link rel="icon">` the document currently has. The Settings →
6
+ * Appearance favicon (stored in the CMS) is the source of truth, so the admin
7
+ * applies it at runtime instead of relying on the host app's `<head>` wiring.
8
+ */
9
+
10
+ /**
11
+ * Window event the admin dispatches after the brand (logo/favicon) is saved so
12
+ * live surfaces — the browser tab favicon — refresh without a full reload.
13
+ */
14
+ export const BRAND_UPDATED_EVENT = 'actuate:brand-updated'
15
+
16
+ export interface FaviconUpdate {
17
+ /** The `<link rel="icon">` element that is now active. */
18
+ link: HTMLLinkElement
19
+ /** True when we created the link (so callers can remove it on cleanup). */
20
+ created: boolean
21
+ /** The href the link had before this update — for restore on cleanup. */
22
+ previousHref: string | null
23
+ }
24
+
25
+ /**
26
+ * Point the browser tab favicon at `href`, creating the `<link rel="icon">` when
27
+ * the document doesn't already have one. Returns enough state for the caller to
28
+ * restore/remove it on cleanup. Client-only — call inside an effect.
29
+ */
30
+ export function setDocumentFavicon(href: string, type?: string | null): FaviconUpdate {
31
+ const existing = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
32
+ if (existing) {
33
+ const previousHref = existing.href || null
34
+ existing.href = href
35
+ if (type) existing.type = type
36
+ return { link: existing, created: false, previousHref }
37
+ }
38
+ const link = document.createElement('link')
39
+ link.rel = 'icon'
40
+ link.href = href
41
+ if (type) link.type = type
42
+ document.head.appendChild(link)
43
+ return { link, created: true, previousHref: null }
44
+ }
@@ -148,6 +148,7 @@ export function SectionInspector({
148
148
  field={field}
149
149
  value={section.content[field.key]}
150
150
  onChange={(v) => onContentChange({ [field.key]: v })}
151
+ onPatch={(patch) => onContentChange(patch)}
151
152
  />
152
153
  ))}
153
154
  </fieldset>
@@ -302,11 +303,17 @@ function ContentField({
302
303
  field,
303
304
  value,
304
305
  onChange,
306
+ onPatch,
305
307
  id: idProp,
306
308
  }: {
307
309
  field: SectionFieldDef
308
310
  value: unknown
309
311
  onChange: (v: unknown) => void
312
+ /**
313
+ * Optional multi-key patch sink — lets media fields persist companion
314
+ * dimension keys (width/height) atomically with the URL.
315
+ */
316
+ onPatch?: (patch: Record<string, unknown>) => void
310
317
  /** Explicit input id (repeater rows pass a row-scoped id to stay unique). */
311
318
  id?: string
312
319
  }) {
@@ -335,7 +342,7 @@ function ContentField({
335
342
  }
336
343
 
337
344
  if (field.type === 'media') {
338
- return <MediaField id={id} field={field} value={text} onChange={onChange} />
345
+ return <MediaField id={id} field={field} value={text} onChange={onChange} onPatch={onPatch} />
339
346
  }
340
347
 
341
348
  if (field.type === 'select') {
@@ -393,16 +400,39 @@ function ContentField({
393
400
  )
394
401
  }
395
402
 
403
+ /**
404
+ * Companion content keys that hold an image field's intrinsic dimensions.
405
+ * Picking from the media library persists width/height alongside the URL so the
406
+ * public renderer can use the CLS-safe `next/image` path without waiting on
407
+ * read-time `/resolve` enrichment. Mirrors the keys the section renderers read:
408
+ * `imageUrl` → `imageWidth` / `imageHeight`; `url` (gallery row) → `width` / `height`.
409
+ */
410
+ export function companionDimensionKeys(fieldKey: string): { width: string; height: string } {
411
+ if (fieldKey === 'url') return { width: 'width', height: 'height' }
412
+ if (fieldKey.endsWith('Url')) {
413
+ const base = fieldKey.slice(0, -'Url'.length)
414
+ return { width: `${base}Width`, height: `${base}Height` }
415
+ }
416
+ return { width: `${fieldKey}Width`, height: `${fieldKey}Height` }
417
+ }
418
+
396
419
  function MediaField({
397
420
  id,
398
421
  field,
399
422
  value,
400
423
  onChange,
424
+ onPatch,
401
425
  }: {
402
426
  id: string
403
427
  field: SectionFieldDef
404
428
  value: string
405
429
  onChange: (v: unknown) => void
430
+ /**
431
+ * When provided, picking from the library applies a multi-key patch (URL +
432
+ * intrinsic width/height) in one update. Falls back to `onChange` (URL only)
433
+ * for manual entry and when no patch sink is wired.
434
+ */
435
+ onPatch?: (patch: Record<string, unknown>) => void
406
436
  }) {
407
437
  const [open, setOpen] = useState(false)
408
438
  return (
@@ -434,8 +464,18 @@ function MediaField({
434
464
  <MediaPickerModal
435
465
  open={open}
436
466
  onClose={() => setOpen(false)}
467
+ onSelectItem={(item) => {
468
+ if (!onPatch) return
469
+ const { width: wKey, height: hKey } = companionDimensionKeys(field.key)
470
+ const patch: Record<string, unknown> = { [field.key]: item.url }
471
+ if (typeof item.width === 'number') patch[wKey] = item.width
472
+ if (typeof item.height === 'number') patch[hKey] = item.height
473
+ onPatch(patch)
474
+ }}
437
475
  onSelect={(url) => {
438
- onChange(url)
476
+ // onSelectItem handles persistence when a patch sink is wired; only
477
+ // fall back to a URL-only update when it isn't.
478
+ if (!onPatch) onChange(url)
439
479
  setOpen(false)
440
480
  }}
441
481
  accept="image/*"
@@ -743,6 +783,7 @@ function SortableRepeaterRow({
743
783
  field={sub}
744
784
  value={row[sub.key]}
745
785
  onChange={(v) => onPatch({ [sub.key]: v })}
786
+ onPatch={(patch) => onPatch(patch)}
746
787
  />
747
788
  ))}
748
789
  </div>
@@ -64,10 +64,14 @@ function LogoField({
64
64
  }) {
65
65
  const inputRef = useRef<HTMLInputElement>(null)
66
66
  const [pickerOpen, setPickerOpen] = useState(false)
67
+ // Remember a preview URL that failed to load so a broken/expired asset shows
68
+ // the neutral placeholder icon instead of the browser's broken-image glyph.
69
+ const [thumbErrorUrl, setThumbErrorUrl] = useState<string | null>(null)
67
70
  const assetId = brand[field]
68
71
  const asset = assetId ? assets[assetId] : undefined
69
72
  const uploading = uploadingField === field
70
73
  const inputId = `brand-${field}`
74
+ const showPreview = Boolean(asset?.url) && asset?.url !== thumbErrorUrl
71
75
 
72
76
  const thumbnail = (
73
77
  <div
@@ -76,12 +80,13 @@ function LogoField({
76
80
  )} ${square ? 'h-12 w-12' : 'h-12 w-20'}`}
77
81
  aria-hidden={!asset}
78
82
  >
79
- {asset?.url ? (
83
+ {showPreview ? (
80
84
  // Never stretch/crop — contain preserves aspect ratio + transparency.
81
85
  <img
82
- src={asset.url}
86
+ src={asset!.url}
83
87
  alt={`${label} preview`}
84
88
  className="max-h-full max-w-full object-contain"
89
+ onError={() => setThumbErrorUrl(asset!.url)}
85
90
  />
86
91
  ) : (
87
92
  <ImageIcon size={18} className="text-muted-foreground" aria-hidden="true" />
@@ -20,6 +20,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
20
20
 
21
21
  import { useTheme, type AdminBrandCache } from '../../components/ThemeProvider.js'
22
22
  import { cmsApi } from '../../lib/api.js'
23
+ import { BRAND_UPDATED_EVENT } from '../../lib/favicon.js'
23
24
  import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
24
25
 
25
26
  export type SaveState = 'idle' | 'saving' | 'success' | 'error'
@@ -548,6 +549,11 @@ export function useAppearanceSettings(
548
549
  }
549
550
  baselineRef.current = snapshot(appearance, brand)
550
551
  setAdminBrand(assetToCache(brand, assets, siteTitleRef.current))
552
+ // Notify live surfaces (browser-tab favicon) to refresh from the saved
553
+ // brand without a full reload.
554
+ if (typeof window !== 'undefined') {
555
+ window.dispatchEvent(new CustomEvent(BRAND_UPDATED_EVENT))
556
+ }
551
557
  setSaveState('success')
552
558
  }, [canEdit, issues, appearance, brand, assets, setAdminBrand])
553
559