@bygghammar/cms-platform 0.62.1 → 0.63.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.63.0
4
+
5
+ - Site config gains `branding` (`logo`, `logoDark`, `logoAlt`,
6
+ `invertLogoOnLightSurface`) and it is an overridable setting, so the logo can be
7
+ uploaded from **Site settings → Branding** with the code value as the fallback.
8
+ - `SiteSettingsView` renders the branding section, using the host media field
9
+ (library picker + upload) when available and live previews on both surfaces.
10
+ - New `loadSiteBrandingCore(client, config)` for a public read of the resolved
11
+ logo, and `brandingLogoFor()` to pick artwork per surface.
12
+ - `SiteHeader` accepts `logoDarkSrc` and `logoAlt`; a dark artwork replaces the
13
+ CSS inversion on light surfaces.
14
+
3
15
  ## 0.61.1
4
16
 
5
17
  - `site/host-indexing-response` takes the configured origin from the site instead
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.62.1",
3
+ "version": "0.63.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -300,4 +300,4 @@
300
300
  "cms-platform-migrations": "scripts/sync-migrations.mjs",
301
301
  "create-cms-site": "./scripts/create-cms-site.mjs"
302
302
  }
303
- }
303
+ }
@@ -1,10 +1,12 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useState, type ReactNode } from "react";
2
2
  import { toast } from "sonner";
3
3
  import { Save, RotateCcw } from "lucide-react";
4
4
 
5
5
  import {
6
6
  DEFAULT_FEATURE_FLAGS,
7
+ EMPTY_BRANDING,
7
8
  EMPTY_COMPANY,
9
+ type SiteBranding,
8
10
  type SiteConfig,
9
11
  type SiteFeatureFlags,
10
12
  type SiteSettingOverrides,
@@ -37,6 +39,12 @@ export type SiteSettingsDeps = {
37
39
  companyValues?: Record<string, string>;
38
40
  /** Navigate to the contact details screen, where company fields are edited. */
39
41
  onOpenContactDetails?: () => void;
42
+ /**
43
+ * Site media field (library picker + upload). When omitted the branding
44
+ * section falls back to plain URL inputs so the screen still works.
45
+ */
46
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
47
+ MediaField?: (props: any) => ReactNode;
40
48
  };
41
49
 
42
50
  const FEATURE_LABELS: Record<keyof SiteFeatureFlags, string> = {
@@ -80,6 +88,10 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
80
88
  const [company, setCompany] = useState({ ...EMPTY_COMPANY, ...config.company });
81
89
  const [features, setFeatures] = useState<SiteFeatureFlags>({ ...config.features });
82
90
  const [brand, setBrand] = useState<Record<string, string>>({ ...config.brand });
91
+ const [branding, setBranding] = useState<SiteBranding>({
92
+ ...EMPTY_BRANDING,
93
+ ...config.branding,
94
+ });
83
95
  const [busy, setBusy] = useState<string | null>(null);
84
96
 
85
97
  useEffect(() => {
@@ -87,6 +99,7 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
87
99
  setCompany({ ...EMPTY_COMPANY, ...config.company });
88
100
  setFeatures({ ...config.features });
89
101
  setBrand({ ...config.brand });
102
+ setBranding({ ...EMPTY_BRANDING, ...config.branding });
90
103
  }, [config]);
91
104
 
92
105
  const save = async (key: string, patch: SiteSettingOverrides) => {
@@ -212,6 +225,60 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
212
225
  ) : null}
213
226
  </section>
214
227
 
228
+
229
+ <section className={`mt-6 ${cardClass}`}>
230
+ <SectionHeader
231
+ title="Branding"
232
+ overridden={overridden("branding")}
233
+ onReset={() => reset("branding")}
234
+ busy={busy === "branding"}
235
+ />
236
+ <p className="mt-2 max-w-2xl text-sm text-ink/60">
237
+ The logo shown in the site header. Upload the artwork you want here; leaving a field empty
238
+ falls back to the logo that ships with the site in code. Use a transparent PNG or SVG.
239
+ </p>
240
+ <div className="mt-5 grid gap-6 md:grid-cols-2">
241
+ <BrandingLogoField
242
+ label="Logo (light artwork, used on dark backgrounds)"
243
+ value={branding.logo}
244
+ onChange={(logo) => setBranding({ ...branding, logo })}
245
+ MediaField={deps.MediaField}
246
+ preview="dark"
247
+ />
248
+ <BrandingLogoField
249
+ label="Dark logo (optional, used on light backgrounds)"
250
+ value={branding.logoDark}
251
+ onChange={(logoDark) => setBranding({ ...branding, logoDark })}
252
+ MediaField={deps.MediaField}
253
+ preview="light"
254
+ />
255
+ </div>
256
+ <div className="mt-5 grid gap-4 md:grid-cols-2 md:items-end">
257
+ <label className="block">
258
+ <span className={labelClass}>Logo alt text</span>
259
+ <input
260
+ className={inputClass}
261
+ value={branding.logoAlt}
262
+ placeholder={config.name}
263
+ onChange={(e) => setBranding({ ...branding, logoAlt: e.target.value })}
264
+ />
265
+ </label>
266
+ <label className="flex items-center gap-3 text-sm text-ink">
267
+ <input
268
+ type="checkbox"
269
+ checked={branding.invertLogoOnLightSurface}
270
+ onChange={(e) =>
271
+ setBranding({ ...branding, invertLogoOnLightSurface: e.target.checked })
272
+ }
273
+ />
274
+ Invert the light artwork on light backgrounds (when no dark logo is set)
275
+ </label>
276
+ </div>
277
+ <div className="mt-5">
278
+ <SaveButton busy={busy === "branding"} onClick={() => save("branding", { branding })} />
279
+ </div>
280
+ </section>
281
+
215
282
  <section className={`mt-6 ${cardClass}`}>
216
283
  <SectionHeader
217
284
  title="Modules"
@@ -271,6 +338,52 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
271
338
  );
272
339
  }
273
340
 
341
+ /**
342
+ * One logo slot: the site's media field when the host provides one, otherwise a
343
+ * plain URL input. Both render the same live preview on the target surface.
344
+ */
345
+ function BrandingLogoField({
346
+ label,
347
+ value,
348
+ onChange,
349
+ MediaField,
350
+ preview,
351
+ }: {
352
+ label: string;
353
+ value: string;
354
+ onChange: (next: string) => void;
355
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
356
+ MediaField?: (props: any) => ReactNode;
357
+ preview: "light" | "dark";
358
+ }) {
359
+ return (
360
+ <div>
361
+ <span className={labelClass}>{label}</span>
362
+ {MediaField ? (
363
+ <MediaField value={value} onChange={onChange} label="" />
364
+ ) : (
365
+ <input
366
+ className={inputClass}
367
+ value={value}
368
+ placeholder="/logo.png"
369
+ onChange={(e) => onChange(e.target.value)}
370
+ />
371
+ )}
372
+ <div
373
+ className={`mt-3 flex h-20 items-center justify-center border border-ink/10 px-4 ${
374
+ preview === "dark" ? "bg-ink" : "bg-white"
375
+ }`}
376
+ >
377
+ {value ? (
378
+ <img src={value} alt="" className="max-h-12 w-auto" />
379
+ ) : (
380
+ <span className="text-xs uppercase tracking-wider text-ink/35">Code default</span>
381
+ )}
382
+ </div>
383
+ </div>
384
+ );
385
+ }
386
+
274
387
  function SectionHeader({
275
388
  title,
276
389
  overridden,
@@ -162,6 +162,7 @@ export function SiteSettingsRoute() {
162
162
  onOpenContactDetails: () => {
163
163
  window.location.href = contactPath;
164
164
  },
165
+ MediaField: host.components.MediaField,
165
166
  }}
166
167
  />
167
168
  );
@@ -64,6 +64,32 @@ export const EMPTY_COMPANY: SiteCompany = {
64
64
  phone: "",
65
65
  };
66
66
 
67
+
68
+ /**
69
+ * Brand identity assets (logo artwork). Locale-neutral URLs; `alt` is plain
70
+ * text because a logo's accessible name is the brand name in every language.
71
+ */
72
+ export type SiteBranding = {
73
+ /** Primary logo/wordmark URL. Empty string means "no logo configured". */
74
+ logo: string;
75
+ /**
76
+ * Optional dark-ink logo used on light surfaces. When empty, the primary
77
+ * artwork is CSS-inverted instead (see `invertLogoOnLightSurface`).
78
+ */
79
+ logoDark: string;
80
+ /** Accessible name for the logo image. Falls back to the site name. */
81
+ logoAlt: string;
82
+ /** Invert the primary artwork on light surfaces when no dark variant exists. */
83
+ invertLogoOnLightSurface: boolean;
84
+ };
85
+
86
+ export const EMPTY_BRANDING: SiteBranding = {
87
+ logo: "",
88
+ logoDark: "",
89
+ logoAlt: "",
90
+ invertLogoOnLightSurface: true,
91
+ };
92
+
67
93
  /** Brand design tokens. Values are CSS colors written into the theme contract. */
68
94
  export type SiteBrandTokens = Record<string, string>;
69
95
 
@@ -86,6 +112,8 @@ export type SiteConfigInput = {
86
112
  /** UI language of the backoffice itself. */
87
113
  backofficeLocale?: string;
88
114
  company?: Partial<SiteCompany>;
115
+ /** Logo artwork defaults; the backoffice may override these. */
116
+ branding?: Partial<SiteBranding>;
89
117
  brand?: SiteBrandTokens;
90
118
  features?: Partial<SiteFeatureFlags>;
91
119
  /** Optional free-form per-site extras shared code never reads. */
@@ -101,6 +129,7 @@ export type SiteConfig = {
101
129
  backofficePath: string;
102
130
  backofficeLocale: string;
103
131
  company: SiteCompany;
132
+ branding: SiteBranding;
104
133
  brand: SiteBrandTokens;
105
134
  features: SiteFeatureFlags;
106
135
  extra: SiteExtra;
@@ -134,6 +163,7 @@ export function defineSiteConfig(input: SiteConfigInput): SiteConfig {
134
163
  backofficePath,
135
164
  backofficeLocale: input.backofficeLocale ?? "en",
136
165
  company: { ...EMPTY_COMPANY, ...(input.company ?? {}) },
166
+ branding: { ...EMPTY_BRANDING, ...(input.branding ?? {}) },
137
167
  brand: { ...(input.brand ?? {}) },
138
168
  features: { ...DEFAULT_FEATURE_FLAGS, ...(input.features ?? {}) },
139
169
  extra: { ...(input.extra ?? {}) },
@@ -170,6 +200,7 @@ export function originIsFromEnvironment(fallback: string, rawEnvValue?: string |
170
200
  export const OVERRIDABLE_SETTING_KEYS = [
171
201
  "name",
172
202
  "company",
203
+ "branding",
173
204
  "brand",
174
205
  "features",
175
206
  ] as const satisfies readonly (keyof SiteConfig)[];
@@ -180,6 +211,7 @@ export type OverridableSettingKey = (typeof OVERRIDABLE_SETTING_KEYS)[number];
180
211
  export type SiteSettingOverrides = Partial<{
181
212
  name: string;
182
213
  company: Partial<SiteCompany>;
214
+ branding: Partial<SiteBranding>;
183
215
  brand: SiteBrandTokens;
184
216
  features: Partial<SiteFeatureFlags>;
185
217
  }>;
@@ -201,6 +233,18 @@ export function parseSiteSettingOverrides(raw: unknown): SiteSettingOverrides {
201
233
  }
202
234
  out.company = company;
203
235
  }
236
+ if (isRecord(raw["branding"])) {
237
+ const source = raw["branding"] as Record<string, unknown>;
238
+ const branding: Partial<SiteBranding> = {};
239
+ for (const key of ["logo", "logoDark", "logoAlt"] as const) {
240
+ const value = source[key];
241
+ if (typeof value === "string") branding[key] = value;
242
+ }
243
+ if (typeof source["invertLogoOnLightSurface"] === "boolean") {
244
+ branding.invertLogoOnLightSurface = source["invertLogoOnLightSurface"];
245
+ }
246
+ out.branding = branding;
247
+ }
204
248
  if (isRecord(raw["brand"])) {
205
249
  const brand: SiteBrandTokens = {};
206
250
  for (const [key, value] of Object.entries(raw["brand"])) {
@@ -226,6 +270,7 @@ export function resolveSiteConfig(base: SiteConfig, overrides: unknown): SiteCon
226
270
  ...base,
227
271
  name: parsed.name ?? base.name,
228
272
  company: { ...base.company, ...(parsed.company ?? {}) },
273
+ branding: { ...base.branding, ...(parsed.branding ?? {}) },
229
274
  brand: { ...base.brand, ...(parsed.brand ?? {}) },
230
275
  features: { ...base.features, ...(parsed.features ?? {}) },
231
276
  };
@@ -259,3 +304,18 @@ export function siteConfig(): SiteConfig {
259
304
  }
260
305
  return active;
261
306
  }
307
+
308
+ /**
309
+ * Resolve the logo to render for a surface. A dark variant wins on light
310
+ * surfaces; otherwise the caller inverts the primary artwork when configured.
311
+ */
312
+ export function brandingLogoFor(
313
+ branding: SiteBranding,
314
+ surface: "light" | "dark",
315
+ ): { src: string; invert: boolean } {
316
+ if (surface === "light" && branding.logoDark) return { src: branding.logoDark, invert: false };
317
+ return {
318
+ src: branding.logo,
319
+ invert: surface === "light" && branding.invertLogoOnLightSurface,
320
+ };
321
+ }
@@ -11,6 +11,9 @@ import { auditAs } from "./audit-core";
11
11
  import {
12
12
  OVERRIDABLE_SETTING_KEYS,
13
13
  parseSiteSettingOverrides,
14
+ resolveSiteConfig,
15
+ type SiteBranding,
16
+ type SiteConfig,
14
17
  type OverridableSettingKey,
15
18
  type SiteSettingOverrides,
16
19
  } from "../config/site-config";
@@ -87,3 +90,23 @@ export async function clearSiteSettingCore(
87
90
  });
88
91
  }
89
92
  }
93
+
94
+ /**
95
+ * Public read of the resolved branding (logo artwork) for the running site.
96
+ *
97
+ * The public header needs the stored logo before any staff session exists, so
98
+ * this takes whatever client the host passes — typically the publishable one —
99
+ * and always falls back to the code defaults when nothing is stored.
100
+ */
101
+ export async function loadSiteBrandingCore(
102
+ client: Client,
103
+ base: SiteConfig,
104
+ ): Promise<SiteBranding> {
105
+ try {
106
+ const overrides = await loadSiteSettingsCore(client);
107
+ return resolveSiteConfig(base, overrides).branding;
108
+ } catch {
109
+ // Branding must never break the header; code defaults are always valid.
110
+ return base.branding;
111
+ }
112
+ }
@@ -21,6 +21,10 @@ export type SiteHeaderProps = {
21
21
  brandName: LocalizedString;
22
22
  /** Logo/wordmark artwork. Light artwork is inverted on light surfaces. */
23
23
  logoSrc: string;
24
+ /** Optional dark-ink artwork used on light surfaces instead of inverting. */
25
+ logoDarkSrc?: string;
26
+ /** Accessible name for the logo image. Defaults to the brand name. */
27
+ logoAlt?: string;
24
28
  /** Set false when the logo artwork is already dark. */
25
29
  invertLogoOnLightSurface?: boolean;
26
30
  /** Primary call to action shown on desktop and at the end of the mobile panel. */
@@ -164,6 +168,8 @@ export function SiteHeader({
164
168
  nav,
165
169
  brandName,
166
170
  logoSrc,
171
+ logoDarkSrc,
172
+ logoAlt,
167
173
  invertLogoOnLightSurface = true,
168
174
  primaryCta,
169
175
  menuLabels,
@@ -201,6 +207,10 @@ export function SiteHeader({
201
207
 
202
208
  const lightText = lightTextPaths.includes(pathname) && !scrolled;
203
209
  const name = t(brandName);
210
+ // A dedicated dark artwork wins on light surfaces; otherwise invert the light one.
211
+ const onLightSurface = !lightText;
212
+ const logo = onLightSurface && logoDarkSrc ? logoDarkSrc : logoSrc;
213
+ const invertLogo = onLightSurface && !logoDarkSrc && invertLogoOnLightSurface;
204
214
  const showBackoffice = Boolean(backoffice?.signedIn);
205
215
 
206
216
  return (
@@ -213,11 +223,11 @@ export function SiteHeader({
213
223
  <div className="container-editorial flex h-16 items-center justify-between gap-4 md:h-20">
214
224
  <Link to="/" className="shrink-0 inline-flex items-center" aria-label={name}>
215
225
  <img
216
- src={logoSrc}
217
- alt={name}
226
+ src={logo}
227
+ alt={logoAlt?.trim() || name}
218
228
  className={[
219
229
  "h-10 w-auto md:h-12 transition-[filter] duration-500",
220
- !lightText && invertLogoOnLightSurface ? "brightness-0" : "",
230
+ invertLogo ? "brightness-0" : "",
221
231
  ].join(" ")}
222
232
  />
223
233
  </Link>