@web-my-money/studio-consumer 1.0.0 → 2.1.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/README.md CHANGED
@@ -12,8 +12,59 @@ for no gain.
12
12
  - `@web-my-money/studio-consumer/analytics`
13
13
  - `@web-my-money/studio-consumer/content`
14
14
  - `@web-my-money/studio-consumer/attribution`
15
+ - `@web-my-money/studio-consumer/headers`
16
+ - `@web-my-money/studio-consumer/brand` (2.0.0)
17
+ - `@web-my-money/studio-consumer/image` (2.1.0)
18
+
19
+ Each entry point exports `PACKAGE_VERSION`, to prove at runtime that a consumer
20
+ is resolving this package rather than a stale copy.
21
+
22
+ ## Brand (2.0.0, breaking)
23
+
24
+ A site declares its brand theme in code. Every invalid input throws, and this
25
+ runs while the root layout renders, so a site with no brand, a malformed theme
26
+ id, or WMM's own theme fails its build instead of silently inheriting WMM's
27
+ palette.
28
+
29
+ ```ts
30
+ // lib/brand.ts — its own module: Next refuses unknown named exports from a layout file
31
+ import { defineSiteBrand } from "@web-my-money/studio-consumer/brand";
32
+
33
+ export const brand = defineSiteBrand({ theme: "acme", style: "flat" });
34
+ ```
35
+
36
+ ```tsx
37
+ // app/layout.tsx
38
+ import { brandRootAttributes } from "@web-my-money/studio-consumer/brand";
39
+ import { brand } from "@/lib/brand";
40
+
41
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
42
+ return <html lang="en" {...brandRootAttributes(brand)}><body>{children}</body></html>;
43
+ }
44
+ ```
45
+
46
+ ```ts
47
+ // app/api/content/manifest/route.ts
48
+ import { brand } from "@/lib/brand";
49
+
50
+ export const GET = createManifestHandler(getManifest, brand);
51
+ export const dynamic = "force-dynamic";
52
+ ```
53
+
54
+ `createManifestHandler` now REQUIRES the brand (the breaking change in 2.0.0), so
55
+ Studio records the theme the site ships. Only a WMM site may use a WMM theme
56
+ (`wmm`, `site`, `light`, `dark`), by passing `wmmSite: true`.
57
+
58
+ ## Image focal point (2.1.0)
59
+
60
+ An image value edited in Studio can carry `focal: { x, y }` (0–1 from the
61
+ top-left): where the subject is. Apply it so responsive crops keep the subject
62
+ in frame. No focal point, or a malformed one, reads as the centre, which is the
63
+ browser default, so existing images render exactly as before.
64
+
65
+ ```tsx
66
+ import { focalObjectPosition } from "@web-my-money/studio-consumer/image";
67
+
68
+ <img src={value.url} alt={value.alt.en} style={{ objectFit: "cover", objectPosition: focalObjectPosition(value) }} />
69
+ ```
15
70
 
16
- This package is currently a skeleton: each entry point exports only a
17
- `PACKAGE_VERSION` constant, used to prove at runtime that a consumer is
18
- resolving this package rather than a stale copy. Content, analytics, and
19
- attribution logic land in later tasks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-my-money/studio-consumer",
3
- "version": "1.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Consumer-side integration for WMM Studio: content, analytics, attribution.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -17,7 +17,9 @@
17
17
  "./analytics": "./src/analytics/index.ts",
18
18
  "./content": "./src/content/index.ts",
19
19
  "./attribution": "./src/attribution/index.ts",
20
- "./headers": "./src/content/headers.mjs"
20
+ "./headers": "./src/content/headers.mjs",
21
+ "./brand": "./src/brand/index.ts",
22
+ "./image": "./src/image/index.ts"
21
23
  },
22
24
  "peerDependencies": {
23
25
  "next": ">=16",
@@ -1,5 +1,5 @@
1
1
  /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "1.0.0";
2
+ export const PACKAGE_VERSION = "2.1.0";
3
3
 
4
4
  export {
5
5
  resolveVariant,
@@ -1,5 +1,5 @@
1
1
  /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "1.0.0";
2
+ export const PACKAGE_VERSION = "2.1.0";
3
3
 
4
4
  export {
5
5
  getAttribution,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Site brand — the theme boundary (Studio spec 2026-10-08, phase 5).
3
+ *
4
+ * A client site declares its brand in CODE, in its root layout:
5
+ *
6
+ * const brand = defineSiteBrand({ theme: "acme", style: "flat" });
7
+ * <html lang="en" {...brandRootAttributes(brand)}>
8
+ *
9
+ * and passes the same `brand` to `createManifestHandler`, so Studio records it.
10
+ *
11
+ * FAIL CLOSED. With no `data-theme`, @web-my-money/tokens falls back to WMM's
12
+ * palette (CSS) and to "dark" (JS). A client site must never get there by
13
+ * omission, so every invalid input THROWS — and because this runs while the root
14
+ * layout renders, a throw is a failed build, not a quietly wrong page.
15
+ *
16
+ * Pure on purpose: no `server-only`, no Next imports, safe in any component.
17
+ */
18
+
19
+ export const PACKAGE_VERSION = "2.1.0";
20
+
21
+ /** Theme ids owned by WMM in the tokens package (themes.json `owner: "wmm"`). */
22
+ export const WMM_THEMES = ["wmm", "site", "light", "dark"] as const;
23
+
24
+ const STYLES = ["flat", "glass"] as const;
25
+ const THEME_ID = /^[a-z][a-z0-9-]{0,40}$/;
26
+
27
+ export type SiteBrand = {
28
+ readonly theme: string;
29
+ readonly style: (typeof STYLES)[number];
30
+ readonly wmmSite: boolean;
31
+ };
32
+
33
+ export function defineSiteBrand(input: {
34
+ theme: string;
35
+ style: (typeof STYLES)[number];
36
+ wmmSite?: boolean;
37
+ }): SiteBrand {
38
+ const { theme, style } = input;
39
+ const wmmSite = input.wmmSite === true;
40
+
41
+ // Refused rather than trimmed/lowercased: normalising here would make the id
42
+ // in the code and the id in the stylesheet two different strings.
43
+ if (typeof theme !== "string" || !THEME_ID.test(theme)) {
44
+ throw new Error(
45
+ `defineSiteBrand: theme ${JSON.stringify(theme)} is not a valid theme id ` +
46
+ "(lowercase letters, digits and dashes, starting with a letter).",
47
+ );
48
+ }
49
+ if ((WMM_THEMES as readonly string[]).includes(theme) && !wmmSite) {
50
+ throw new Error(
51
+ `defineSiteBrand: "${theme}" is WMM's own theme. A client site needs its own ` +
52
+ "theme from the design system (gen-client-theme). Only a WMM site may pass wmmSite: true.",
53
+ );
54
+ }
55
+ if (!(STYLES as readonly string[]).includes(style)) {
56
+ throw new Error(`defineSiteBrand: style must be "flat" or "glass", got ${JSON.stringify(style)}.`);
57
+ }
58
+ return Object.freeze({ theme, style, wmmSite });
59
+ }
60
+
61
+ export function brandRootAttributes(brand: SiteBrand): {
62
+ "data-theme": string;
63
+ "data-style": string;
64
+ } {
65
+ return { "data-theme": brand.theme, "data-style": brand.style };
66
+ }
@@ -1,5 +1,5 @@
1
1
  /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "1.0.0";
2
+ export const PACKAGE_VERSION = "2.1.0";
3
3
 
4
4
  export {
5
5
  createContentClient,
@@ -1,4 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
+ import type { SiteBrand } from "../brand/index";
2
3
  import type { ContentManifest } from "./payload";
3
4
 
4
5
  /**
@@ -22,13 +23,23 @@ import type { ContentManifest } from "./payload";
22
23
  */
23
24
  export function createManifestHandler(
24
25
  getManifest: () => Promise<ContentManifest>,
26
+ /**
27
+ * REQUIRED (2.0.0). The site's brand, from `defineSiteBrand`. Written into the
28
+ * manifest so Studio records which theme this site ships — a site cannot
29
+ * publish a manifest without declaring one. Spread LAST so nothing in the
30
+ * app's own manifest can override it.
31
+ */
32
+ brand: SiteBrand,
25
33
  ): () => Promise<Response> {
26
34
  return async function GET() {
27
35
  const manifest = await getManifest();
28
- return NextResponse.json(manifest, {
29
- headers: {
30
- "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
36
+ return NextResponse.json(
37
+ { ...manifest, brand: { theme: brand.theme, style: brand.style, wmmSite: brand.wmmSite } },
38
+ {
39
+ headers: {
40
+ "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
41
+ },
31
42
  },
32
- });
43
+ );
33
44
  };
34
45
  }
@@ -0,0 +1,14 @@
1
+ /** Pure; safe in client components. */
2
+ export const PACKAGE_VERSION = "2.1.0";
3
+
4
+ /**
5
+ * CSS `object-position` for a Studio image value's focal point (spec §6.2).
6
+ * Anything missing or out of range reads as the centre — the browser default —
7
+ * so an image without a focal point renders exactly as before.
8
+ */
9
+ export function focalObjectPosition(value: unknown): string {
10
+ const f = (value as { focal?: { x?: unknown; y?: unknown } } | null | undefined)?.focal;
11
+ const ok = (n: unknown): n is number => typeof n === "number" && n >= 0 && n <= 1;
12
+ if (!f || !ok(f.x) || !ok(f.y)) return "50% 50%";
13
+ return `${Math.round(f.x * 100)}% ${Math.round(f.y * 100)}%`;
14
+ }